diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 41de674c4d..3360b5be76 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -12,6 +12,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { required this.preparingResponse, required this.onTap, this.contextualLabel, + this.isHuddleAgent = false, this.isSelf = false, this.frameSize = _huddleAvatarFrameSize, }); @@ -22,6 +23,9 @@ class _HuddleCallAvatar extends HookConsumerWidget { /// The huddle-scoped identity label, when resolved. final String? contextualLabel; + + /// Whether the Huddle roster counts this participant as an agent. + final bool isHuddleAgent; final bool active; final double speakerLevel; final bool preparingResponse; @@ -129,7 +133,8 @@ class _HuddleCallAvatar extends HookConsumerWidget { contextualLabel: contextualLabel, isSelf: isSelf, ); - final isAgent = profile?.isAgent == true || fallbackLabel != null; + final isAgent = + isHuddleAgent || profile?.isAgent == true || fallbackLabel != null; final semanticStates = [ label, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart index 30399d6b59..75a3d814a3 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart @@ -12,6 +12,7 @@ class _HuddleCallParticipants extends StatelessWidget { required this.activeSpeakerPubkeys, required this.speakerLevels, required this.workingAgentPubkeys, + required this.agentPubkeys, required this.retryTooltip, required this.retryIcon, required this.onRetry, @@ -31,6 +32,9 @@ class _HuddleCallParticipants extends StatelessWidget { final Set activeSpeakerPubkeys; final Map speakerLevels; final Set workingAgentPubkeys; + + /// Lowercase pubkeys that the Huddle roster counts as agents. + final Set agentPubkeys; final String retryTooltip; final IconData retryIcon; final VoidCallback onRetry; @@ -154,6 +158,7 @@ class _HuddleCallParticipants extends StatelessWidget { activeSpeakerPubkeys: activeSpeakerPubkeys, speakerLevels: speakerLevels, workingAgentPubkeys: workingAgentPubkeys, + agentPubkeys: agentPubkeys, movementDuration: movementDuration, entryDuration: entryDuration, exitDuration: exitDuration, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart index fbbe42cb38..f95d8b86f8 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart @@ -42,6 +42,7 @@ class _HuddleParticipantCluster extends HookWidget { required this.activeSpeakerPubkeys, required this.speakerLevels, required this.workingAgentPubkeys, + required this.agentPubkeys, required this.movementDuration, required this.entryDuration, required this.exitDuration, @@ -58,6 +59,9 @@ class _HuddleParticipantCluster extends HookWidget { final Set activeSpeakerPubkeys; final Map speakerLevels; final Set workingAgentPubkeys; + + /// Lowercase pubkeys that the Huddle roster counts as agents. + final Set agentPubkeys; final Duration movementDuration; final Duration entryDuration; final Duration exitDuration; @@ -147,6 +151,7 @@ class _HuddleParticipantCluster extends HookWidget { active: activeSpeakerPubkeys.contains(pubkey), speakerLevel: speakerLevels[pubkey] ?? 0, preparingResponse: workingAgentPubkeys.contains(pubkey), + isHuddleAgent: agentPubkeys.contains(pubkey.toLowerCase()), slot: lastSlots[pubkey]!, present: visiblePubkeySet.contains(pubkey), movementDuration: movementDuration, @@ -183,6 +188,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { required this.active, required this.speakerLevel, required this.preparingResponse, + required this.isHuddleAgent, required this.slot, required this.present, required this.movementDuration, @@ -198,6 +204,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { final bool active; final double speakerLevel; final bool preparingResponse; + final bool isHuddleAgent; final _HuddleClusterSlot slot; final bool present; final Duration movementDuration; @@ -251,6 +258,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { active: active, speakerLevel: speakerLevel, preparingResponse: preparingResponse, + isHuddleAgent: isHuddleAgent, frameSize: frameSize, onTap: onTap, ), diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index 0411fbaef0..71c7ec76df 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -175,7 +175,13 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { ), isSelf: isSelf, ); - final isAgent = profile?.isAgent == true || fallbackLabel != null; + final isHuddleAgent = ref.watch( + huddleAgentPubkeysProvider( + ephemeralChannelId, + ).select((agents) => agents.contains(pubkey.toLowerCase())), + ); + final isAgent = + isHuddleAgent || profile?.isAgent == true || fallbackLabel != null; return Semantics( label: active ? '$label, speaking' : label, @@ -274,6 +280,9 @@ class _HuddleParticipantRoster extends ConsumerWidget { } final profiles = ref.watch(userCacheProvider); final fallbackLabels = ref.watch(agentDirectoryDisplayNamesProvider); + final agentPubkeys = ref.watch( + huddleAgentPubkeysProvider(ephemeralChannelId), + ); final names = ref.watch(_huddleIdentityNamesProvider(ephemeralChannelId)); final activeSpeakerPubkeys = ref.watch( huddleSessionProvider.select((session) => session.activeSpeakerPubkeys), @@ -362,6 +371,9 @@ class _HuddleParticipantRoster extends ConsumerWidget { color: context.colors.onPrimaryContainer, ), isAgent: + agentPubkeys.contains( + pubkey.toLowerCase(), + ) || profile?.isAgent == true || fallbackLabels[pubkey] != null, ), diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 396722b887..96025931ff 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -718,6 +718,7 @@ class _MobileHuddleCallPage extends ConsumerWidget { activeSpeakerPubkeys: session.activeSpeakerPubkeys, speakerLevels: session.speakerLevels, workingAgentPubkeys: workingAgentPubkeys, + agentPubkeys: huddleBotPubkeys, retryTooltip: retryTooltip, retryIcon: retryIcon, onRetry: onRetry, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index eba9a6276b..9a7c739dad 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -7452,15 +7452,37 @@ void main() { await tester.tap(find.widgetWithText(FilledButton, 'Join')); await tester.pumpAndSettle(); + AvatarImage avatarIn(Finder owner) => tester.widget( + find.descendant(of: owner, matching: find.byType(AvatarImage)), + ); + final agentAvatar = find.byKey( + const ValueKey('huddle-participant-avatar-agent'), + ); + expect(agentAvatar, findsOneWidget); + // The record makes it an agent, so it renders as one too. + expect(avatarIn(agentAvatar).isAgent, isTrue); expect( - find.byKey(const ValueKey('huddle-participant-avatar-agent')), - findsOneWidget, + avatarIn( + find.byKey(const ValueKey('huddle-participant-avatar-self')), + ).isAgent, + isFalse, ); // A person who is not on audio is not in the roster after admission. expect( find.byKey(const ValueKey('huddle-participant-avatar-person')), findsNothing, ); + + await tester.tap(agentAvatar); + await tester.pumpAndSettle(); + expect( + avatarIn( + find.byKey( + const ValueKey('huddle-participant-spotlight-avatar-agent'), + ), + ).isAgent, + isTrue, + ); }, ); @@ -8534,6 +8556,20 @@ void main() { ), ); expect(membershipAgentRow, findsOneWidget); + // The `bot` role alone makes it an agent: no profile attestation or + // directory label is needed for the agent avatar. + AvatarImage rowAvatar(Finder row) => tester.widget( + find.descendant(of: row, matching: find.byType(AvatarImage)), + ); + expect(rowAvatar(membershipAgentRow).isAgent, isTrue); + expect( + rowAvatar( + find.byKey( + const ValueKey('huddle-participant-roster-row-guest-23'), + ), + ).isAgent, + isFalse, + ); await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); expect(