fix(mobile): offer role controls for agents (#9475)

Mobile hides every role control for a channel member whose role is
`bot`. On relays running [#8494 bot role from
owner](https://github.com/block/buzz/pull/8494), any agent with a
recorded owner is reported as `bot` whenever it holds an ordinary role.
So if an admin demotes an owned agent from admin to member, the
refreshed roster says `bot` and mobile no longer offers "Make channel
admin": the demotion can't be undone from mobile.

This change gives agents the same role controls as people, matching
desktop:

- The member profile actions offer "Make channel admin" for `bot`
members. "Change to member" still appears only for admins and guests, so
an agent that is already ordinary isn't offered it.
- The members sheet shows the Role selector for agents, with Member
selected for `bot` (the two are permission-equivalent). The row label
still reads "Agent".

**Rollout:** ship this in a mobile release before relays with #8494
deploy.

Review discussion: [buzz-mobile-tomb-review-work
thread](buzz://message?channel=95213a25-483a-458b-9bc3-71891fa5aef6&id=4714950ee67bf04464a59c1deb3470972c0809c7be780183da6b4a2e77243714).

### Regressions

None expected. The relay already accepts admin, member and guest for
agents (owned agents can hold admin today), so the newly shown actions
submit role changes it accepts. On current relays an agent added as
`bot` now also shows these controls; tapping Member on its selector is a
no-op because Member is already selected.

### Validation

Changed tests: the profile-actions role table now expects "Make channel
admin" for `bot`; the promote, demote journey continues with the
refreshed role coming back as `bot` and expects promotion to be offered
again; the members-sheet agent row expects the Role selector with Member
selected.

Full mobile suite: 4,828 passed, 70 skipped, 1 failed. The failure,
`video_controls_test.dart` "iOS timeline requests native Liquid Glass
and fades with the toolbar", is unrelated media-viewer code; it passed
when rerun on its own, and the pre-push hook's full mobile run passed.

Simulator check against a live relay: an agent added with the `bot`
role, viewed by the channel owner in its member profile.

| Before | After |
| --- | --- |
| <img alt="before"
src="https://github.com/user-attachments/assets/2503ae98-b198-46c3-85c9-0b85dae2c4ae"
width="300"> | <img alt="after"
src="https://github.com/user-attachments/assets/82abf55a-de56-45aa-86c5-7917877e388e"
width="300"> |

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-11-13 14:05:48 -08:00
committed by GitHub
parent b6db61faa0
commit 04af7081c8
4 changed files with 44 additions and 36 deletions

View File

@@ -130,22 +130,20 @@ class ChannelMemberProfileActions extends HookConsumerWidget {
AppListCard(
horizontalPadding: 0,
children: [
if (!member.isBot) ...[
if (member.role != 'admin')
AppListRow(
icon: BuzzIcons.shieldCheck,
title: 'Make channel admin',
onTap: busy.value ? null : () => perform(role: 'admin'),
),
if (member.role == 'admin' || member.role == 'guest')
AppListRow(
icon: BuzzIcons.user,
title: member.role == 'guest'
? 'Make member'
: 'Change to member',
onTap: busy.value ? null : () => perform(role: 'member'),
),
],
if (member.role != 'admin')
AppListRow(
icon: BuzzIcons.shieldCheck,
title: 'Make channel admin',
onTap: busy.value ? null : () => perform(role: 'admin'),
),
if (member.role == 'admin' || member.role == 'guest')
AppListRow(
icon: BuzzIcons.user,
title: member.role == 'guest'
? 'Make member'
: 'Change to member',
onTap: busy.value ? null : () => perform(role: 'member'),
),
AppListRow(
icon: BuzzIcons.userMinus,
title: 'Remove from channel',

View File

@@ -313,7 +313,6 @@ class _MemberTile extends ConsumerWidget {
: ref
.read(channelIdentityNamesProvider(channelId))
.labelFor(member.pubkey);
final canChangeRole = showManagementActions && !member.isBot;
showBuzzModalBottomSheet<void>(
context: context,
title: label,
@@ -344,23 +343,21 @@ class _MemberTile extends ConsumerWidget {
],
),
if (showManagementActions) ...[
if (canChangeRole) ...[
const SizedBox(height: Grid.xxs),
_RoleSelector(
selectedRole: member.role,
onChanged: (role) async {
Navigator.of(sheetContext).pop();
await ref
.read(channelActionsProvider)
.changeMemberRole(
channelId: channelId,
pubkey: member.pubkey,
role: role,
);
},
),
const SizedBox(height: Grid.xs),
],
const SizedBox(height: Grid.xxs),
_RoleSelector(
selectedRole: member.isBot ? 'member' : member.role,
onChanged: (role) async {
Navigator.of(sheetContext).pop();
await ref
.read(channelActionsProvider)
.changeMemberRole(
channelId: channelId,
pubkey: member.pubkey,
role: role,
);
},
),
const SizedBox(height: Grid.xs),
SheetActionSection(
horizontalPadding: Grid.gutter,
children: [

View File

@@ -246,13 +246,20 @@ void main() {
await tester.tap(find.text('Change to member'));
await tester.pumpAndSettle();
expect(actions.calls, ['test:alice:admin', 'test:alice:member']);
// An owned agent demoted to ordinary membership is now reported as bot.
// That label must not remove the owner's ability to promote it again.
role = 'bot';
container.invalidate(channelMembersProvider('test'));
await tester.pumpAndSettle();
expect(find.text('Make channel admin'), findsOneWidget);
});
for (final role in ['member', 'admin', 'guest', 'bot']) {
testWidgets('management rows for $role', (tester) async {
await _pump(tester, targetRole: role);
expect(
find.text('Make channel admin'),
role == 'member' || role == 'guest' ? findsOneWidget : findsNothing,
role == 'admin' ? findsNothing : findsOneWidget,
);
expect(
find.text('Change to member'),

View File

@@ -259,7 +259,13 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('View activity'), findsOneWidget);
expect(find.text('Role'), findsNothing);
expect(find.text('Role'), findsOneWidget);
expect(
tester
.widget<SegmentedButton<String>>(find.byType(SegmentedButton<String>))
.selected,
{'member'},
);
expect(tester.takeException(), isNull);
});