fix(mobile): draw Huddle agents with agent avatars

The Huddle roster counted record-only and `bot` agents as agents, but the
call avatars, the spotlight and the overflow roster still chose the agent
avatar only from a NIP-OA profile or a directory label. Such an agent
rendered as a person. All three surfaces now also use the Huddle agent set.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-26 11:11:19 -04:00
parent f795e75a0c
commit 3c12f31d2f
6 changed files with 71 additions and 4 deletions

View File

@@ -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,

View File

@@ -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<String> activeSpeakerPubkeys;
final Map<String, double> speakerLevels;
final Set<String> workingAgentPubkeys;
/// Lowercase pubkeys that the Huddle roster counts as agents.
final Set<String> 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,

View File

@@ -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<String> activeSpeakerPubkeys;
final Map<String, double> speakerLevels;
final Set<String> workingAgentPubkeys;
/// Lowercase pubkeys that the Huddle roster counts as agents.
final Set<String> 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,
),

View File

@@ -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,
),

View File

@@ -718,6 +718,7 @@ class _MobileHuddleCallPage extends ConsumerWidget {
activeSpeakerPubkeys: session.activeSpeakerPubkeys,
speakerLevels: session.speakerLevels,
workingAgentPubkeys: workingAgentPubkeys,
agentPubkeys: huddleBotPubkeys,
retryTooltip: retryTooltip,
retryIcon: retryIcon,
onRetry: onRetry,

View File

@@ -7452,15 +7452,37 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pumpAndSettle();
AvatarImage avatarIn(Finder owner) => tester.widget<AvatarImage>(
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<AvatarImage>(
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(