diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 5bfce3adad5..66c80f5d6c6 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -44,6 +44,9 @@ const SPEAKER_LEVEL_TICK_MS: u64 = 50; /// Per-peer arrival window for the TTS interrupt frame counter. const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); const REMOTE_RELEASE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(500); +/// Match Mobile's speaking treatment: an open microphone can emit continuous +/// non-DTX Opus for room tone, so packet type alone is not evidence of speech. +const REMOTE_SPEECH_LEVEL_DBOV: i8 = -55; /// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms. const PLAYOUT_TICK_MS: u64 = 10; @@ -86,19 +89,30 @@ fn normalized_speaker_level(level_dbov: i8) -> f32 { ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) } +fn is_remote_speech_frame(is_dtx: bool, level_dbov: i8) -> bool { + !is_dtx && level_dbov >= REMOTE_SPEECH_LEVEL_DBOV +} + fn update_remote_release_deadline( peer: u8, - is_dtx: bool, + is_speech: bool, remote_floor_owners: &std::collections::HashSet, deadlines: &mut std::collections::HashMap, now: tokio::time::Instant, ) { - if !is_dtx { - deadlines.remove(&peer); - } else if remote_floor_owners.contains(&peer) { - deadlines - .entry(peer) - .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + if remote_floor_owners.contains(&peer) { + if is_speech { + // Refresh from audible speech itself. Some mobile capture paths + // stop producing packets once speech ends, so waiting for a DTX + // or quiet packet can otherwise hold the human floor forever. + deadlines.insert(peer, now + REMOTE_RELEASE_DEBOUNCE); + } else { + // Preserve the deadline from the last audible frame. Continuous + // room-tone packets must not keep extending the human floor. + deadlines + .entry(peer) + .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + } } } @@ -437,19 +451,20 @@ pub(crate) async fn run_playout_recv_loop( continue; } let is_dtx = (header.flags & FLAG_DTX) != 0; - // Only count non-DTX arrivals toward the UI's - // active-speaker set. DTX/comfort packets are emitted - // by an idle peer to keep the codec alive — they - // don't mean the peer is speaking, and shouldn't - // make their tile flash for the 500 ms speaker tick. + let is_remote_speech = + is_remote_speech_frame(is_dtx, header.level_dbov); + // Only count audible arrivals toward the UI's + // active-speaker set. An open mobile microphone can + // continuously emit non-DTX room tone, so require an + // audible level before treating a packet as speech. update_remote_release_deadline( peer_idx, - is_dtx, + is_remote_speech, &remote_floor_owners, &mut remote_release_deadlines, tokio::time::Instant::now(), ); - if !is_dtx { + if is_remote_speech { active_indices.insert(peer_idx); let level = normalized_speaker_level(header.level_dbov); speaker_levels @@ -497,7 +512,7 @@ pub(crate) async fn run_playout_recv_loop( // Count only remote-human speech toward floor onset. // Agent audio still plays, but it must not acquire the // human floor or suppress another agent's response. - if !is_dtx && remote_human { + if is_remote_speech && remote_human { if last_frame_reset.elapsed() >= FRAME_WINDOW { frame_counts.clear(); last_frame_reset = tokio::time::Instant::now(); @@ -507,6 +522,14 @@ pub(crate) async fn run_playout_recv_loop( if *count >= REMOTE_SPEECH_THRESHOLD { human_floor.enter_remote(peer_idx); remote_floor_owners.insert(peer_idx); + // The threshold-crossing frame is processed + // before this peer becomes an owner. Arm its + // release here so silence need not arrive in a + // later packet to let queued TTS continue. + remote_release_deadlines.insert( + peer_idx, + tokio::time::Instant::now() + REMOTE_RELEASE_DEBOUNCE, + ); if tts_active.load(Ordering::Acquire) { tts_cancel.store(true, Ordering::Release); } @@ -647,18 +670,18 @@ mod tests { use super::*; #[test] - fn continuous_dtx_does_not_extend_remote_floor_deadline() { + fn continuous_silence_does_not_extend_remote_floor_deadline() { let peer = 7; let started = tokio::time::Instant::now(); let owners = std::collections::HashSet::from([peer]); let mut deadlines = std::collections::HashMap::new(); - update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + update_remote_release_deadline(peer, false, &owners, &mut deadlines, started); let armed = deadlines[&peer]; for elapsed_ms in [100, 200, 300, 400] { update_remote_release_deadline( peer, - true, + false, &owners, &mut deadlines, started + std::time::Duration::from_millis(elapsed_ms), @@ -678,11 +701,32 @@ mod tests { } #[test] - fn dtx_from_non_owner_does_not_arm_remote_floor_deadline() { + fn last_speech_frame_arms_remote_floor_release_without_follow_up_audio() { + let peer = 7; + let started = tokio::time::Instant::now(); + let owners = std::collections::HashSet::from([peer]); + let mut deadlines = std::collections::HashMap::new(); + + update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + let armed = started + REMOTE_RELEASE_DEBOUNCE; + assert_eq!(deadlines[&peer], armed); + + let human_floor = HumanFloor::new(); + human_floor.enter_remote(peer); + let mut owners = owners; + release_expired_remote_floors(armed, &mut owners, &mut deadlines, &human_floor); + + assert!(!human_floor.is_blocked()); + assert!(owners.is_empty()); + assert!(deadlines.is_empty()); + } + + #[test] + fn silence_from_non_owner_does_not_arm_remote_floor_deadline() { let mut deadlines = std::collections::HashMap::new(); update_remote_release_deadline( 7, - true, + false, &std::collections::HashSet::new(), &mut deadlines, tokio::time::Instant::now(), @@ -690,6 +734,15 @@ mod tests { assert!(deadlines.is_empty()); } + #[test] + fn remote_speech_requires_non_dtx_audio_above_the_activity_floor() { + assert!(!is_remote_speech_frame(true, 0)); + assert!(!is_remote_speech_frame(false, -127)); + assert!(!is_remote_speech_frame(false, -56)); + assert!(is_remote_speech_frame(false, -55)); + assert!(is_remote_speech_frame(false, -12)); + } + #[test] fn speaker_level_maps_conversational_range() { assert_eq!(normalized_speaker_level(-127), 0.0); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a5493de11b8..d07cf2425b2 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -19,6 +19,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/bouncing_dots_indicator.dart'; import '../../shared/widgets/concentric_sheet_surface.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; 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 d89ca027e19..7442cb8155d 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 @@ -1,5 +1,7 @@ part of '../channel_detail_page.dart'; +const _huddleAgentResponseResetDelay = Duration(milliseconds: 1200); + class _HuddleCallAvatar extends HookConsumerWidget { const _HuddleCallAvatar({ required this.pubkey, @@ -7,6 +9,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { required this.fallbackLabel, required this.active, required this.speakerLevel, + required this.preparingResponse, required this.onTap, this.isSelf = false, this.frameSize = _huddleAvatarFrameSize, @@ -17,6 +20,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { final String? fallbackLabel; final bool active; final double speakerLevel; + final bool preparingResponse; final VoidCallback? onTap; final bool isSelf; final double frameSize; @@ -24,6 +28,62 @@ class _HuddleCallAvatar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final reducedMotion = MediaQuery.disableAnimationsOf(context); + final responseHasStarted = useState(false); + final responseResetTimer = useRef(null); + // Tracks the previous working signal so we can detect its edges, and + // whether the working signal has cleared since audio latched the response. + // A completed working cycle distinguishes a genuinely new turn from late + // typing that merely trails the turn already spoken. + final wasPreparing = useRef(false); + final workingCycleCompleted = useRef(false); + useEffect(() { + final preparingStarted = preparingResponse && !wasPreparing.value; + final preparingCleared = !preparingResponse && wasPreparing.value; + wasPreparing.value = preparingResponse; + if (active) { + // Audio for the current turn latches that a response has begun and + // starts a fresh turn, so any prior working cycle no longer applies. + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + responseHasStarted.value = true; + workingCycleCompleted.value = false; + } else if (preparingStarted && + responseHasStarted.value && + workingCycleCompleted.value) { + // A new working turn began after the previous turn's working signal + // already cleared, so the "already spoke" suppression no longer + // applies — allow the preparing indicator to show again. + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + responseHasStarted.value = false; + workingCycleCompleted.value = false; + } else if (preparingResponse) { + // Working signal (including late typing for the turn just spoken) holds + // the suppression alive; keep the reset timer cancelled. + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + } else if (responseHasStarted.value && responseResetTimer.value == null) { + responseResetTimer.value = Timer(_huddleAgentResponseResetDelay, () { + responseResetTimer.value = null; + responseHasStarted.value = false; + workingCycleCompleted.value = false; + }); + } + // Record that this turn's working signal has completed a cycle once it + // clears after audio latched, so the next working turn is not mistaken + // for trailing typing. + if (preparingCleared && responseHasStarted.value) { + workingCycleCompleted.value = true; + } + return null; + }, [active, preparingResponse, responseHasStarted.value]); + useEffect( + () => + () => responseResetTimer.value?.cancel(), + const [], + ); + final showPreparingResponse = + preparingResponse && !active && !responseHasStarted.value; final scale = frameSize / _huddleAvatarFrameSize; final avatarRadius = _huddleAvatarRadius * scale; final speakingRingSize = _huddleSpeakingRingSize * scale; @@ -65,12 +125,22 @@ class _HuddleCallAvatar extends HookConsumerWidget { isSelf: isSelf, ); + final semanticStates = [ + label, + if (showPreparingResponse) 'preparing a response', + if (active) 'speaking', + ].join(', '); + return SizedBox( width: frameSize, child: Semantics( - label: active ? '$label, speaking' : label, + label: semanticStates, hint: onTap == null ? null : 'Tap to focus participant', button: onTap != null, + // The outer node excludes descendant semantics, so the child + // indicator's live region never reaches assistive tech. Promote this + // node to a live region while preparing so the label change announces. + liveRegion: showPreparingResponse, onTap: onTap, excludeSemantics: true, child: GestureDetector( @@ -121,15 +191,46 @@ class _HuddleCallAvatar extends HookConsumerWidget { ), ), ), - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: avatarRadius, - backgroundColor: context.colors.primaryContainer, - fallback: Icon( - LucideIcons.userRound, - size: fallbackIconSize, - color: context.colors.onPrimaryContainer, - ), + AnimatedSwitcher( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: showPreparingResponse + ? Container( + key: ValueKey( + 'huddle-agent-preparing-response-$pubkey', + ), + width: avatarRadius * 2, + height: avatarRadius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primaryContainer, + ), + alignment: Alignment.center, + child: BouncingDotsIndicator( + color: context.colors.onPrimaryContainer, + dotSize: 6 * scale, + gap: 4 * scale, + semanticLabel: + '$label is preparing a response', + ), + ) + : AvatarImage( + key: ValueKey('huddle-avatar-image-$pubkey'), + imageUrl: profile?.avatarUrl, + radius: avatarRadius, + backgroundColor: + context.colors.primaryContainer, + fallback: Icon( + LucideIcons.userRound, + size: fallbackIconSize, + color: context.colors.onPrimaryContainer, + ), + ), ), ], ), 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 ee2a564b2e3..5e0d7922d0b 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 @@ -10,6 +10,7 @@ class _HuddleCallParticipants extends StatelessWidget { required this.localPubkey, required this.activeSpeakerPubkeys, required this.speakerLevels, + required this.workingAgentPubkeys, required this.retryTooltip, required this.retryIcon, required this.onRetry, @@ -25,6 +26,7 @@ class _HuddleCallParticipants extends StatelessWidget { final String? localPubkey; final Set activeSpeakerPubkeys; final Map speakerLevels; + final Set workingAgentPubkeys; final String retryTooltip; final IconData retryIcon; final VoidCallback onRetry; @@ -126,6 +128,7 @@ class _HuddleCallParticipants extends StatelessWidget { speakerLevel: localPubkey == null ? 0 : speakerLevels[localPubkey] ?? 0, + preparingResponse: false, isSelf: true, onTap: null, ), @@ -145,6 +148,7 @@ class _HuddleCallParticipants extends StatelessWidget { fallbackLabels: fallbackLabels, activeSpeakerPubkeys: activeSpeakerPubkeys, speakerLevels: speakerLevels, + workingAgentPubkeys: workingAgentPubkeys, 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 7f0f80b17d9..d966d260580 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 @@ -40,6 +40,7 @@ class _HuddleParticipantCluster extends HookWidget { required this.fallbackLabels, required this.activeSpeakerPubkeys, required this.speakerLevels, + required this.workingAgentPubkeys, required this.movementDuration, required this.entryDuration, required this.exitDuration, @@ -52,6 +53,7 @@ class _HuddleParticipantCluster extends HookWidget { final Map fallbackLabels; final Set activeSpeakerPubkeys; final Map speakerLevels; + final Set workingAgentPubkeys; final Duration movementDuration; final Duration entryDuration; final Duration exitDuration; @@ -139,6 +141,7 @@ class _HuddleParticipantCluster extends HookWidget { fallbackLabel: fallbackLabels[pubkey], active: activeSpeakerPubkeys.contains(pubkey), speakerLevel: speakerLevels[pubkey] ?? 0, + preparingResponse: workingAgentPubkeys.contains(pubkey), slot: lastSlots[pubkey]!, present: visiblePubkeySet.contains(pubkey), movementDuration: movementDuration, @@ -173,6 +176,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { required this.fallbackLabel, required this.active, required this.speakerLevel, + required this.preparingResponse, required this.slot, required this.present, required this.movementDuration, @@ -186,6 +190,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { final String? fallbackLabel; final bool active; final double speakerLevel; + final bool preparingResponse; final _HuddleClusterSlot slot; final bool present; final Duration movementDuration; @@ -237,6 +242,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { fallbackLabel: fallbackLabel, active: active, speakerLevel: speakerLevel, + preparingResponse: preparingResponse, 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 247d1168306..215009365f4 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 @@ -5,6 +5,7 @@ const _huddleParticipantSpotlightRadius = 68.0; void _showHuddleParticipantSpotlight({ required BuildContext context, + required String ephemeralChannelId, required String pubkey, required bool isSelf, }) { @@ -12,6 +13,7 @@ void _showHuddleParticipantSpotlight({ context: context, barrierLabel: 'Dismiss participant', childBuilder: (dialogContext) => _HuddleParticipantSpotlight( + ephemeralChannelId: ephemeralChannelId, pubkey: pubkey, isSelf: isSelf, onDismiss: () => Navigator.of(dialogContext).pop(), @@ -19,11 +21,15 @@ void _showHuddleParticipantSpotlight({ ); } -void _showHuddleParticipantRoster({required BuildContext context}) { +void _showHuddleParticipantRoster({ + required BuildContext context, + required String ephemeralChannelId, +}) { _showHuddleParticipantOverlay( context: context, barrierLabel: 'Dismiss participant list', - childBuilder: (_) => const _HuddleParticipantRoster(), + childBuilder: (_) => + _HuddleParticipantRoster(ephemeralChannelId: ephemeralChannelId), ); } @@ -123,22 +129,24 @@ class _HuddleParticipantOverlay extends StatelessWidget { class _HuddleParticipantSpotlight extends ConsumerWidget { const _HuddleParticipantSpotlight({ + required this.ephemeralChannelId, required this.pubkey, required this.isSelf, required this.onDismiss, }); + final String ephemeralChannelId; final String pubkey; final bool isSelf; final VoidCallback onDismiss; @override Widget build(BuildContext context, WidgetRef ref) { - final participantPresent = ref.watch( - huddleSessionProvider.select( - (session) => isSelf || session.participantPubkeys.contains(pubkey), - ), - ); + final participantPresent = + isSelf || + ref + .watch(_huddleLogicalParticipantPubkeysProvider(ephemeralChannelId)) + .contains(pubkey); if (!participantPresent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) onDismiss(); @@ -233,13 +241,16 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { } class _HuddleParticipantRoster extends ConsumerWidget { - const _HuddleParticipantRoster(); + const _HuddleParticipantRoster({required this.ephemeralChannelId}); + + final String ephemeralChannelId; @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(huddleSessionProvider); final localPubkey = session.currentPubkey?.toLowerCase(); - final pubkeys = session.participantPubkeys + final pubkeys = ref + .watch(_huddleLogicalParticipantPubkeysProvider(ephemeralChannelId)) .where((pubkey) => pubkey != localPubkey) .skip(_huddleClusterVisibleParticipantCount) .toList(growable: false); 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 6f1b21339ce..5b0abb5766e 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -12,6 +12,35 @@ final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose _HuddleParticipantProfileUpdates.new, ); +final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + // Select only roster-relevant fields. Watching the whole session would + // recompute at the 50 ms speaker-level flush cadence, restarting the + // downstream profile subscription ~20x/sec while anyone is speaking. + final session = ref.watch( + huddleSessionProvider.select( + (state) => ( + ephemeralChannelId: state.ephemeralChannelId, + currentPubkey: state.currentPubkey, + participantPubkeys: state.participantPubkeys, + wasAdmitted: state.wasAdmitted, + ), + ), + ); + final backingMembers = + ref.watch(channelMembersProvider(channelId)).value ?? + const []; + return _huddleParticipantPubkeys( + sessionParticipantPubkeys: session.ephemeralChannelId == channelId + ? session.participantPubkeys + : const [], + currentPubkey: session.currentPubkey, + members: session.wasAdmitted + ? backingMembers.where((member) => member.isBot) + : backingMembers, + ); + }); + class _HuddleParticipantProfileUpdates extends Notifier { _HuddleParticipantProfileUpdates(this.channelId); @@ -21,27 +50,9 @@ class _HuddleParticipantProfileUpdates extends Notifier { @override int build() { - final session = ref.watch( - huddleSessionProvider.select( - (state) => ( - ephemeralChannelId: state.ephemeralChannelId, - currentPubkey: state.currentPubkey, - participantPubkeys: state.participantPubkeys, - wasAdmitted: state.wasAdmitted, - ), - ), - ); - final members = session.wasAdmitted - ? const [] - : ref.watch(channelMembersProvider(channelId)).value ?? - const []; final relayState = ref.watch(relaySessionProvider); - final participantPubkeys = _huddleParticipantPubkeys( - sessionParticipantPubkeys: session.ephemeralChannelId == channelId - ? session.participantPubkeys - : const [], - currentPubkey: session.currentPubkey, - members: members, + final participantPubkeys = ref.watch( + _huddleLogicalParticipantPubkeysProvider(channelId), ); final subscriptionVersion = ++_subscriptionVersion; _clearSubscription(); @@ -547,13 +558,11 @@ class _MobileHuddleCallPage extends ConsumerWidget { !unavailable && session.microphonePermissionRequired; final localPubkey = session.currentPubkey?.toLowerCase(); - final backingMembers = - ref.watch(channelMembersProvider(invite.ephemeralChannelId)).value ?? - const []; - final participantPubkeys = _huddleParticipantPubkeys( - sessionParticipantPubkeys: session.participantPubkeys, - currentPubkey: localPubkey, - members: session.wasAdmitted ? const [] : backingMembers, + // Audio peers remain authoritative for humans after admission. Agents are + // logical Huddle participants as soon as their bot membership is published, + // before their send-only audio connection starts speaking. + final participantPubkeys = ref.watch( + _huddleLogicalParticipantPubkeysProvider(invite.ephemeralChannelId), ); final remotePubkeys = participantPubkeys .where((pubkey) => pubkey != localPubkey) @@ -564,6 +573,29 @@ class _MobileHuddleCallPage extends ConsumerWidget { ); final profiles = ref.watch(userCacheProvider); final directoryDisplayNames = ref.watch(agentDirectoryDisplayNamesProvider); + final huddleTypingEntries = ref.watch( + channelTypingProvider(invite.ephemeralChannelId), + ); + final parentAgentPubkeys = ref.watch( + agentMentionPubkeysProvider(invite.parentChannelId), + ); + // Ephemeral Huddle bot membership is authoritative for native enrollment; + // parent classification is only best-effort and may miss a valid Huddle + // bot. Union both so a Huddle-only agent still enters the preparing state. + final huddleBotPubkeys = { + for (final member + in ref + .watch(channelMembersProvider(invite.ephemeralChannelId)) + .value ?? + const []) + if (member.isBot) member.pubkey.trim().toLowerCase(), + }; + final workingAgentPubkeys = { + for (final entry in huddleTypingEntries) + if (parentAgentPubkeys.contains(entry.pubkey.toLowerCase()) || + huddleBotPubkeys.contains(entry.pubkey.toLowerCase())) + entry.pubkey.toLowerCase(), + }; final reactionSenderName = _huddleReactionSenderName( localPubkey: localPubkey, profile: localPubkey == null ? null : profiles[localPubkey], @@ -651,6 +683,7 @@ class _MobileHuddleCallPage extends ConsumerWidget { localPubkey: localPubkey, activeSpeakerPubkeys: session.activeSpeakerPubkeys, speakerLevels: session.speakerLevels, + workingAgentPubkeys: workingAgentPubkeys, retryTooltip: retryTooltip, retryIcon: retryIcon, onRetry: onRetry, @@ -658,12 +691,15 @@ class _MobileHuddleCallPage extends ConsumerWidget { final isSelf = pubkey == localPubkey || pubkey.isEmpty; _showHuddleParticipantSpotlight( context: context, + ephemeralChannelId: invite.ephemeralChannelId, pubkey: pubkey, isSelf: isSelf, ); }, - onOverflowTap: () => - _showHuddleParticipantRoster(context: context), + onOverflowTap: () => _showHuddleParticipantRoster( + context: context, + ephemeralChannelId: invite.ephemeralChannelId, + ), ), ), if (connected) diff --git a/mobile/lib/shared/widgets/bouncing_dots_indicator.dart b/mobile/lib/shared/widgets/bouncing_dots_indicator.dart new file mode 100644 index 00000000000..6026c1db0ff --- /dev/null +++ b/mobile/lib/shared/widgets/bouncing_dots_indicator.dart @@ -0,0 +1,90 @@ +import 'dart:math' show pi, sin; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Three subtly bouncing dots for short, indeterminate waiting states. +class BouncingDotsIndicator extends HookWidget { + /// The color of each dot. + final Color color; + + /// The diameter of each dot. + final double dotSize; + + /// The horizontal space between dots. + final double gap; + + /// The accessibility announcement for this waiting state. + final String semanticLabel; + + /// Creates a three-dot indeterminate activity indicator. + const BouncingDotsIndicator({ + required this.color, + required this.semanticLabel, + this.dotSize = 6, + this.gap = 4, + super.key, + }); + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final animation = useAnimationController( + duration: const Duration(milliseconds: 1000), + ); + + useEffect(() { + if (reducedMotion) { + animation + ..stop() + ..value = 0; + } else { + animation.repeat(); + } + return animation.stop; + }, [animation, reducedMotion]); + + return Semantics( + label: semanticLabel, + liveRegion: true, + child: ExcludeSemantics( + child: AnimatedBuilder( + animation: animation, + builder: (context, _) => Row( + key: const ValueKey('bouncing-dots-indicator'), + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) SizedBox(width: gap), + Transform.translate( + key: ValueKey('bouncing-dot-$index'), + offset: Offset( + 0, + reducedMotion + ? 0 + : -dotSize * + 0.32 * + _positiveWave(animation.value, index), + ), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + child: SizedBox.square(dimension: dotSize), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +double _positiveWave(double progress, int index) { + // Match desktop onboarding's 120 ms stagger while keeping the motion soft. + final staggeredProgress = progress - (index * 0.12); + return sin(staggeredProgress * 2 * pi).clamp(0, 1).toDouble(); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 3aabad062ec..08b7e570c8f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -220,6 +220,7 @@ Widget _buildTestable({ ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, _FakeTypingNotifier? typingNotifier, + _FakeTypingNotifier? huddleTypingNotifier, String? canvasContent, String? initialMessageId, String? initialThreadRootId, @@ -259,6 +260,11 @@ Widget _buildTestable({ channelTypingProvider( _channelId, ).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)), + channelTypingProvider(_huddleChannelId).overrideWith( + () => + huddleTypingNotifier ?? + _FakeTypingNotifier(const [], channelId: _huddleChannelId), + ), userCacheProvider.overrideWith( () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), @@ -5622,6 +5628,431 @@ void main() { ); }); + testWidgets('shows response dots only until the agent starts speaking', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'working-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + members: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + loadChannelBotPubkeys: () async => const {'agent'}, + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + // The preparing transition must be announced live. The outer avatar node + // excludes descendant semantics, so it must itself become a live region. + expect( + tester + .getSemantics( + find.byKey(const ValueKey('huddle-participant-avatar-agent')), + ) + .flagsCollection + .isLiveRegion, + isTrue, + ); + final dotFinder = find.byKey(const ValueKey('bouncing-dot-2')); + final initialDotOffset = tester + .widget(dotFinder) + .transform + .getTranslation() + .y; + await tester.pump(const Duration(milliseconds: 120)); + expect( + tester.widget(dotFinder).transform.getTranslation().y, + isNot(initialDotOffset), + ); + + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Pollen, speaking'), findsOneWidget); + + // A brief audio gap must not revive the waiting state while the same + // working signal is still active. + await tester.pump(const Duration(milliseconds: 650)); + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + + typing.setEntries(const []); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + }); + + testWidgets( + 'shows preparing indicator for a Huddle-only bot without a parent role', + (tester) async { + // Regression: a valid ephemeral Huddle bot with no parent bot role and + // no directory identity must still enter the preparing state from its + // Huddle typing. Classification must derive from authoritative + // ephemeral Huddle bot membership, not parent-channel classification. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'huddle-only-working-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + // Bot membership is supplied ONLY through the ephemeral Huddle, and + // deliberately not through the parent channel (`members`). + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + }, + ); + + testWidgets('does not revive response dots when typing follows audio', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier(const [], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'audio-before-working-signal', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + expect(find.bySemanticsLabel('Pollen, speaking'), findsOneWidget); + + // Let the active-speaker window close before the independently + // transported typing signal arrives. + await tester.pump(const Duration(milliseconds: 650)); + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + }); + + testWidgets('revives response dots for a new turn after audio completes', ( + tester, + ) async { + // Regression: a completed working cycle followed by a genuinely new one + // must show the preparing indicator again. A single audio-seen latch + // conflates late same-turn typing (suppress) with a fresh turn (show). + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier(const [], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'new-turn-after-audio', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + // Turn 1: audio speaks, then late same-turn typing must stay suppressed. + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 650)); + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + + // Turn 1's working signal completes. + typing.setEntries(const []); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + // Turn 2: a fresh working signal within the 1.2 s cooldown must show the + // preparing indicator and announce it live again. + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('huddle-participant-avatar-agent')), + ) + .flagsCollection + .isLiveRegion, + isTrue, + ); + }); + + testWidgets( + 'does not restart the profile subscription on speaker-level updates', + (tester) async { + // Regression: the logical-participant provider must select only + // roster-relevant session fields. Watching the whole session would + // recompute at the 50 ms speaker-level flush cadence, tearing down and + // recreating the kind-0 profile subscription ~20x/sec while anyone is + // speaking. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final relaySession = _ProfileSubscriptionRelaySession(); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'speaker-level-profile-churn', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + relaySessionNotifier: relaySession, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + final baseline = relaySession.profileSubscriptions; + expect(baseline, greaterThan(0)); + + // Drive continuous speaker-level flushes: each frame within the 600 ms + // active window refreshes the level and schedules a 50 ms flush that + // republishes the whole session state. + for (var i = 0; i < 10; i++) { + transport.emitRemoteAudio(peerIndex: 2, sequence: i + 1); + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.pumpAndSettle(); + + expect(relaySession.profileSubscriptions, baseline); + }, + ); + testWidgets( 'opens the sparse full-screen call with avatar and audio controls', (tester) async { @@ -6260,6 +6691,7 @@ void main() { testWidgets( 'caps a dense Huddle roster at ten avatars with an overflow count', (tester) async { + const membershipAgentPubkey = 'membership-agent'; tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -6293,6 +6725,10 @@ void main() { ], users: { 'self': const UserProfile(pubkey: 'self', displayName: 'Self'), + membershipAgentPubkey: const UserProfile( + pubkey: membershipAgentPubkey, + displayName: 'Membership agent', + ), for (final pubkey in remotePubkeys) pubkey: UserProfile(pubkey: pubkey, displayName: pubkey), }, @@ -6303,6 +6739,11 @@ void main() { role: 'member', joinedAt: DateTime(2025), ), + ChannelMember( + pubkey: membershipAgentPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), ], relayConfigNotifier: _HuddleRelayConfigNotifier(), huddleCurrentPubkey: 'self', @@ -6345,7 +6786,7 @@ void main() { find.byKey(const ValueKey('huddle-participant-overflow')), findsOneWidget, ); - expect(find.text('+14'), findsOneWidget); + expect(find.text('+15'), findsOneWidget); await tester.tap( find.byKey(const ValueKey('huddle-participant-overflow')), @@ -6356,7 +6797,7 @@ void main() { find.byKey(const ValueKey('huddle-participant-roster')), findsOneWidget, ); - expect(find.text('14 more people'), findsOneWidget); + expect(find.text('15 more people'), findsOneWidget); expect( find.byKey(const ValueKey('huddle-participant-roster-row-guest-10')), findsOneWidget, @@ -6377,6 +6818,20 @@ void main() { find.byKey(const ValueKey('huddle-participant-roster-row-guest-23')), findsOneWidget, ); + final membershipAgentRow = find.byKey( + const ValueKey( + 'huddle-participant-roster-row-$membershipAgentPubkey', + ), + ); + await tester.scrollUntilVisible( + membershipAgentRow, + 200, + scrollable: find.descendant( + of: find.byKey(const ValueKey('huddle-participant-roster-list')), + matching: find.byType(Scrollable), + ), + ); + expect(membershipAgentRow, findsOneWidget); await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); expect( @@ -6530,138 +6985,211 @@ void main() { }, ); - testWidgets('springs admitted participants in and out', (tester) async { - const addedMemberPubkey = - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final membersNotifier = _MutableHuddleMembersNotifier(const []); - final transport = _HuddleTestTransport(); + testWidgets( + 'shows admitted agents from membership before their audio peer joins', + (tester) async { + const addedMemberPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final membersNotifier = _MutableHuddleMembersNotifier(const []); + final transport = _HuddleTestTransport(); - await tester.pumpWidget( - _buildTestable( - messages: [ - _huddleMsg( - id: 'authoritative-live-roster', - kind: EventKind.huddleStarted, - pubkey: 'self', - createdAt: now, - ), - ], - users: const { - 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'), - 'self': UserProfile(pubkey: 'self', displayName: 'Self'), - addedMemberPubkey: UserProfile( - pubkey: addedMemberPubkey, - displayName: 'Added member', - ), - }, - huddleMembersNotifier: membersNotifier, - relayConfigNotifier: _HuddleRelayConfigNotifier(), - relaySessionNotifier: _ReconnectingRelaySession(), - huddleCurrentPubkey: 'self', - huddleMediaFactory: _HuddleTestMedia.new, - huddleTransportFactory: (_) => transport, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Join')); - await tester.pumpAndSettle(); + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'authoritative-live-roster', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + addedMemberPubkey: UserProfile( + pubkey: addedMemberPubkey, + displayName: 'Added member', + ), + }, + huddleMembersNotifier: membersNotifier, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + relaySessionNotifier: _ReconnectingRelaySession(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); - final desktopAvatar = find.byKey( - const ValueKey('huddle-speaking-ring-desktop'), - ); - final initialDesktopCenter = tester.getCenter(desktopAvatar); + final desktopAvatar = find.byKey( + const ValueKey('huddle-speaking-ring-desktop'), + ); + final initialDesktopCenter = tester.getCenter(desktopAvatar); - membersNotifier.replace([ - ChannelMember( - pubkey: addedMemberPubkey, - role: 'member', - joinedAt: DateTime(2025), - ), - ]); - await tester.pump(); - await tester.pump(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsNothing, - ); + membersNotifier.replace([ + ChannelMember( + pubkey: addedMemberPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); - transport.emitPeerJoin( - const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0), - ); - await tester.pump(); + final addedMemberScale = find.byKey( + const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'), + ); + expect( + tester.widget(addedMemberScale).transform.storage[0], + closeTo(0.72, 0.01), + ); + await tester.pump(const Duration(milliseconds: 120)); + final movingDesktopCenter = tester.getCenter(desktopAvatar); + expect( + tester.widget(addedMemberScale).transform.storage[0], + greaterThan(0.72), + ); + expect( + (movingDesktopCenter - initialDesktopCenter).distance, + greaterThan(1), + ); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-participant-avatar-desktop')), + findsOneWidget, + ); - final addedMemberScale = find.byKey( - const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'), - ); - expect( - tester.widget(addedMemberScale).transform.storage[0], - closeTo(0.72, 0.01), - ); - await tester.pump(const Duration(milliseconds: 120)); - final movingDesktopCenter = tester.getCenter(desktopAvatar); - expect( - tester.widget(addedMemberScale).transform.storage[0], - greaterThan(0.72), - ); - expect( - (movingDesktopCenter - initialDesktopCenter).distance, - greaterThan(1), - ); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('huddle-participant-avatar-desktop')), - findsOneWidget, - ); + await tester.tap( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('huddle-participant-spotlight')), + findsOneWidget, + ); + expect(find.text('Added member'), findsOneWidget); + await tester.tapAt(const Offset(8, 8)); + await tester.pumpAndSettle(); - await tester.pumpAndSettle(); - final settledDesktopCenter = tester.getCenter(desktopAvatar); - expect( - (settledDesktopCenter - movingDesktopCenter).distance, - greaterThan(1), - ); - expect( - (settledDesktopCenter - initialDesktopCenter).distance, - greaterThan(1), - ); - expect( - tester.widget(addedMemberScale).transform.storage[0], - closeTo(1, 0.01), - ); + await tester.pumpAndSettle(); + final settledDesktopCenter = tester.getCenter(desktopAvatar); + expect( + (settledDesktopCenter - movingDesktopCenter).distance, + greaterThan(1), + ); + expect( + (settledDesktopCenter - initialDesktopCenter).distance, + greaterThan(1), + ); + expect( + tester.widget(addedMemberScale).transform.storage[0], + closeTo(1, 0.01), + ); - transport.emitPeerLeave(3); - await tester.pump(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsOneWidget, - ); - await tester.pump(const Duration(milliseconds: 54)); - expect( - tester.widget(addedMemberScale).transform.storage[0], - greaterThan(1.05), - ); - await tester.pump(const Duration(milliseconds: 140)); - expect( - tester.widget(addedMemberScale).transform.storage[0], - lessThan(1), - ); - await tester.pumpAndSettle(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsNothing, - ); - }); + // The avatar reflects logical membership, so an audio connection ending + // does not remove the agent. Removing its membership does. + transport.emitPeerJoin( + const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0), + ); + await tester.pump(); + transport.emitPeerLeave(3); + await tester.pump(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + + membersNotifier.replace(const []); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsNothing, + ); + }, + ); + + testWidgets( + 'does not show admitted human membership without an audio peer', + (tester) async { + const invitedHumanPubkey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final membersNotifier = _MutableHuddleMembersNotifier(const []); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'human-membership-is-not-audio-presence', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Desktop'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + invitedHumanPubkey: UserProfile( + pubkey: invitedHumanPubkey, + displayName: 'Invited human', + ), + }, + huddleMembersNotifier: membersNotifier, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => _HuddleTestTransport(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + membersNotifier.replace([ + ChannelMember( + pubkey: invitedHumanPubkey, + role: 'member', + joinedAt: DateTime(2025), + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$invitedHumanPubkey'), + ), + findsNothing, + ); + }, + ); testWidgets('top-right call end leaves audio and the backing channel', ( tester, @@ -13350,6 +13878,35 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { } } +class _ProfileSubscriptionRelaySession extends RelaySessionNotifier { + int profileSubscriptions = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async => event; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + if (filter.kinds.contains(0)) profileSubscriptions++; + return () {}; + } +} + class _HuddleReactionRelaySession extends RelaySessionNotifier { NostrFilter? reactionFilter; void Function(NostrEvent)? _reactionListener; @@ -13861,10 +14418,14 @@ final class _HuddleTestTransport implements HuddleTransportClient { ); } - void emitRemoteAudio({int levelDbov = -30, int sequence = 1}) { + void emitRemoteAudio({ + int peerIndex = 1, + int levelDbov = -30, + int sequence = 1, + }) { _remoteFrames.add( HuddleRemoteAudioFrame( - peerIndex: 1, + peerIndex: peerIndex, epoch: 0, header: HuddleAudioHeader( sequence: sequence,