From 5f81803cb16acc7976dabedd03d87bd927a9ef70 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 12 Nov 2026 18:45:10 +0000 Subject: [PATCH] End last-human agent Huddles and preserve mobile transcript access (#9375) ## Summary Leaving an agent DM Huddle previously skipped client lifecycle cleanup, allowing Join to remain visible while the relay waited for the agent. Apply the existing last-human rule to DMs: release audio first, then publish the ending and archive when the human count is at most one. Preserve the existing additional-device and same-Huddle rejoin guards. Retain DM membership when other humans remain so hanging up does not revoke transcript access. Offer **View** only when cached backing-room membership confirms transcript access, opening the original Huddle transcript and reply threads without joining audio. Non-members retain disabled Ended. Preserve Open for an active local session even when an ended event arrives. For admitted or just-left Huddles, switching/removing a community retires pending remote finalization and waits for local audio teardown. Generation guards prevent retired count/end results from launching follow-up writes or interfering with a later call. Existing in-flight Huddle-start rollback behavior is unchanged. Client only; remote relay finalization is separate in #9376. Human counts use backing-channel membership; known duplicate owner admissions prevent client cleanup. Concurrent device arrivals are not made atomic by this client-only change, and human-to-human DMs with retained memberships still rely on relay finalization. ### Related issue Split from the combined Huddle repair at the author's request. Searched open Huddle issues/PRs; no exact duplicate found for this mobile behavior. ### Testing Follow-up fixes are in `7f28dc486`; merged current main in `7a592f19d`. Required push checks pass on the merged head. - Mobile formatting, analysis, and full suite passed (4,705 tests, 71 skipped; supplementary suites: 4 and 27). - DM hangup journey covers sole-human ending, preservation of another owner device, and preservation of membership when another human remains. Existing rejoin/lifecycle regressions remain in the full suite. - View journey reads the original transcript and opens a server-only reply-thread summary without joining audio. - Mutation checks: restoring the DM cleanup exemption fails the last-human journey; removing thread summaries fails View navigation. Restored production code passes. - Independent review completed; follow-up review of the three blocking fixes found no remaining blockers. - Follow-up regressions cover live-call Open after an ended event, reactive history membership revocation, stalled count/end requests during community transition, and background teardown of a later call while retired cleanup is unresolved. Phone/human validation remains pending. Start an agent DM Huddle and leave from the last human device: its card should stop offering Join. Tap View to read the transcript and replies. Repeat with another device still in the Huddle and verify it stays open. Not deployed. --------- Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/huddle_history.dart | 63 +++ .../channel_detail_page/huddle_sheet.dart | 22 +- .../channels/channel_member_snapshots.dart | 13 + .../channels/mobile_huddle_controller.dart | 63 ++- .../channels/channel_detail_page_test.dart | 475 +++++++++++++----- 6 files changed, 484 insertions(+), 153 deletions(-) create mode 100644 mobile/lib/features/channels/channel_detail_page/huddle_history.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a35e300877..2d43ae3b20 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -127,6 +127,7 @@ part 'channel_detail_page/system_rows.dart'; part 'channel_detail_page/thread_working.dart'; part 'channel_detail_page/activity_members_sheet.dart'; part 'channel_detail_page/huddle_sheet.dart'; +part 'channel_detail_page/huddle_history.dart'; part 'channel_detail_page/huddle_agent_connection.dart'; part 'channel_detail_page/huddle_call_avatar.dart'; part 'channel_detail_page/huddle_participant_cluster.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_history.dart b/mobile/lib/features/channels/channel_detail_page/huddle_history.dart new file mode 100644 index 0000000000..6b3da334aa --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/huddle_history.dart @@ -0,0 +1,63 @@ +part of '../channel_detail_page.dart'; + +/// Read the original room, where voice transcripts and Huddle chat are stored. +/// Opening history never joins audio or changes room membership. +class _HuddleHistoryPage extends HookConsumerWidget { + const _HuddleHistoryPage({required this.channelId}); + final String channelId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = channelMessagesProvider(channelId); + final messages = ref.watch(provider); + final currentPubkey = ref.watch(currentPubkeyProvider); + final dateScrollActivity = useDateHeaderScrollActivity(channelId); + final stickyDateHeaderState = useValueNotifier( + StickyDateHeaderState.hidden, + ); + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Huddle')), + body: messages.when( + loading: () => const Center(child: BuzzLoadingIndicator()), + error: (_, _) => LoadErrorView( + message: 'Couldn’t load this Huddle conversation.', + onRetry: () => ref.invalidate(provider), + ), + data: (events) { + final timeline = formatTimeline( + events, + currentPubkey: currentPubkey, + ).where((message) => !message.isSystem).toList(); + if (timeline.isEmpty) { + return const Center(child: Text('No messages in this Huddle.')); + } + return _MessageList( + dateScrollActivity: dateScrollActivity, + stickyDateHeaderState: stickyDateHeaderState, + entries: buildMainTimelineEntries( + timeline, + relaySummaries: ref.read(provider.notifier).threadSummaries, + ), + allMessages: timeline, + initialMessageId: null, + initialThreadRootId: null, + initialThreadRouteBehavior: InitialThreadRouteBehavior.push, + initialOrdinaryUnreadMessageIds: const {}, + initialOldestOrdinaryUnreadMessageId: null, + initialForcedUnreadMessageIds: const {}, + hasInitialUnread: false, + channelId: channelId, + notifier: ref.read(provider.notifier), + isDm: false, + flat: false, + currentPubkey: currentPubkey, + isMember: false, + isArchived: true, + appBarTitleContentHeight: 0, + composerBottomInset: 0, + ); + }, + ), + ); + } +} 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 1cedf1edd6..93bd6c706f 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -350,7 +350,13 @@ class _HuddleJoinSurface extends ConsumerWidget { final canJoin = isMember && !isArchived && !ended && !stale && !blockedByOtherHuddle; - final label = unavailable + final historyState = (ended || stale) && !isThisSession; + final viewHistory = + historyState && + ref.watch(channelViewerIsMemberProvider(ephemeralChannelId)); + final label = viewHistory + ? 'View' + : unavailable ? 'Start new' : switch ((isThisSession, session.phase, ended || stale)) { (true, HuddleSessionPhase.connected, _) => 'Open', @@ -383,7 +389,7 @@ class _HuddleJoinSurface extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - ended || stale || unavailable + historyState || unavailable ? 'Huddle ended' : 'Huddle in progress', style: context.textTheme.labelLarge?.copyWith( @@ -405,9 +411,15 @@ class _HuddleJoinSurface extends ConsumerWidget { const SizedBox(width: Grid.xxs), FilledButton.tonal( key: ValueKey('huddle-$label-$ephemeralChannelId'), - onPressed: - (canJoin || isThisSession) && - session.phase != HuddleSessionPhase.leaving + onPressed: viewHistory + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + _HuddleHistoryPage(channelId: ephemeralChannelId), + ), + ) + : (canJoin || isThisSession) && + session.phase != HuddleSessionPhase.leaving ? () => unavailable ? unawaited( _startMobileHuddleFromChannel( diff --git a/mobile/lib/features/channels/channel_member_snapshots.dart b/mobile/lib/features/channels/channel_member_snapshots.dart index 33f266ba6b..d07130873f 100644 --- a/mobile/lib/features/channels/channel_member_snapshots.dart +++ b/mobile/lib/features/channels/channel_member_snapshots.dart @@ -35,3 +35,16 @@ extension on ChannelsNotifier { _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); } } + +/// Membership in private backing rooms, including rooms hidden from the list. +final channelViewerIsMemberProvider = Provider.autoDispose.family( + (ref, channelId) { + ref.watch(channelsProvider); + final pubkey = ref.watch(myPubkeyProvider); + return pubkey != null && + ref + .read(channelsProvider.notifier) + .cachedMembersForChannel(channelId) + .any((member) => member.pubkey == pubkey); + }, +); diff --git a/mobile/lib/features/channels/mobile_huddle_controller.dart b/mobile/lib/features/channels/mobile_huddle_controller.dart index 2d41483c6a..439ba47b8c 100644 --- a/mobile/lib/features/channels/mobile_huddle_controller.dart +++ b/mobile/lib/features/channels/mobile_huddle_controller.dart @@ -92,15 +92,13 @@ final class _HuddleAdmissionToken { final int epoch; final String backingChannelId; - /// Membership is shared by all of an owner's devices. A snapshot cannot - /// prove that another admission did not arrive during teardown. In DMs, - /// leave audio only; the relay ends the room after its last peer leaves. - final bool relayOwnsCleanup; + /// DM membership also grants access to the saved transcript after hanging up. + final bool preserveMembership; const _HuddleAdmissionToken( this.epoch, this.backingChannelId, { - this.relayOwnsCleanup = false, + this.preserveMembership = false, }); } @@ -212,7 +210,7 @@ final class MobileHuddleController extends Notifier { _admissionToken = _HuddleAdmissionToken( admissionEpoch, backingChannelId, - relayOwnsCleanup: _isDirectCall(parentChannelId), + preserveMembership: _isDirectCall(parentChannelId), ); } catch (_) { if (admissionEpoch == _admissionEpoch) { @@ -251,7 +249,7 @@ final class MobileHuddleController extends Notifier { final admissionEpoch = ++_admissionEpoch; _latestAdmissionTargetBackingChannelId = ephemeralChannelId; final currentPubkey = ref.read(currentPubkeyProvider); - final relayOwnsCleanup = _isDirectCall(parentChannelId); + final preserveMembership = _isDirectCall(parentChannelId); try { await ref .read(huddleSessionProvider.notifier) @@ -277,7 +275,7 @@ final class MobileHuddleController extends Notifier { _admissionToken = _HuddleAdmissionToken( admissionEpoch, ephemeralChannelId, - relayOwnsCleanup: relayOwnsCleanup, + preserveMembership: preserveMembership, ); } } @@ -301,6 +299,7 @@ final class MobileHuddleController extends Notifier { } Future _finishBackgroundLeave() async { + final generation = _lifecycleGeneration; Object? leaveFailure; StackTrace? leaveFailureStackTrace; try { @@ -310,8 +309,10 @@ final class MobileHuddleController extends Notifier { leaveFailureStackTrace = stackTrace; } + if (generation != _lifecycleGeneration) return; final failureCleanup = _failureCleanup; if (failureCleanup != null) await failureCleanup; + if (generation != _lifecycleGeneration) return; if (_lifecycleCleanups.isNotEmpty) { await Future.wait(_lifecycleCleanups.toList()); } @@ -330,7 +331,24 @@ final class MobileHuddleController extends Notifier { ); } + int _lifecycleGeneration = 0; + Future? _localLeave; + + Future _leaveLocalAudio() { + return _localLeave ??= ref + .read(huddleSessionProvider.notifier) + .leave() + .whenComplete(() => _localLeave = null); + } + Future _leaveForTransition() async { + // Retire remote finalization before rebinding the community. In-flight + // writes may finish in their original scope, but cannot launch another. + ++_lifecycleGeneration; + _backgroundLeave = null; + _failureCleanup = null; + _lifecycleCleanups.clear(); + _admissionToken = null; final startFuture = _startFuture; if (startFuture != null) { ++_generation; @@ -342,19 +360,13 @@ final class MobileHuddleController extends Notifier { // settle before the relay is rebound to the next community. } } - final failureCleanup = _failureCleanup; - if (failureCleanup != null) await failureCleanup; - final backgroundLeave = _backgroundLeave; - if (backgroundLeave != null) await backgroundLeave; - if (ref.read(huddleSessionProvider).isInSession) { - await leave(); - } - if (_lifecycleCleanups.isNotEmpty) { - await Future.wait(_lifecycleCleanups.toList()); - } + ++_generation; + state = false; + await _leaveLocalAudio(); } Future leave() async { + final lifecycleGeneration = _lifecycleGeneration; ++_generation; state = false; final session = ref.read(huddleSessionProvider); @@ -365,7 +377,6 @@ final class MobileHuddleController extends Notifier { final humanCount = !session.wasAdmitted || backingChannelId == null || - admissionToken?.relayOwnsCleanup == true || _hasOtherOwnerAdmission(session) ? null : ref @@ -379,7 +390,7 @@ final class MobileHuddleController extends Notifier { Object? localFailure; StackTrace? localFailureStackTrace; try { - await ref.read(huddleSessionProvider.notifier).leave(); + await _leaveLocalAudio(); } catch (error, stackTrace) { localFailure = error; localFailureStackTrace = stackTrace; @@ -390,6 +401,7 @@ final class MobileHuddleController extends Notifier { admissionToken != null) { await _trackLifecycleCleanup( _finishLeaveLifecycle( + generation: lifecycleGeneration, admissionToken: admissionToken, parentChannelId: parentChannelId, backingChannelId: backingChannelId, @@ -403,10 +415,11 @@ final class MobileHuddleController extends Notifier { } Future _cleanupAfterLocalTeardown(HuddleSessionState session) async { + final lifecycleGeneration = _lifecycleGeneration; final backingChannelId = session.ephemeralChannelId; final admissionToken = session.wasAdmitted ? _admissionToken : null; if (admissionToken == null || backingChannelId == null) return; - if (admissionToken.relayOwnsCleanup || _hasOtherOwnerAdmission(session)) { + if (_hasOtherOwnerAdmission(session)) { _admissionToken = null; return; } @@ -416,6 +429,7 @@ final class MobileHuddleController extends Notifier { .catchError((_) => 2); await _trackLifecycleCleanup( _finishLeaveLifecycle( + generation: lifecycleGeneration, admissionToken: admissionToken, parentChannelId: session.parentChannelId, backingChannelId: backingChannelId, @@ -434,13 +448,16 @@ final class MobileHuddleController extends Notifier { } Future _finishLeaveLifecycle({ + required int generation, required _HuddleAdmissionToken admissionToken, required String? parentChannelId, required String backingChannelId, required Future humanCount, }) async { + if (!ref.mounted || generation != _lifecycleGeneration) return; final actions = ref.read(channelActionsProvider); final humansRemaining = await humanCount; + if (!ref.mounted || generation != _lifecycleGeneration) return; if (_newerAdmissionTargetsSameHuddle(admissionToken)) return; final currentSession = ref.read(huddleSessionProvider); if (currentSession.isInSession && @@ -457,6 +474,7 @@ final class MobileHuddleController extends Notifier { ephemeralChannelId: backingChannelId, ); } catch (_) {} + if (!ref.mounted || generation != _lifecycleGeneration) return; if (_newerAdmissionTargetsSameHuddle(admissionToken)) return; final resumedSession = ref.read(huddleSessionProvider); if (resumedSession.isInSession && @@ -469,6 +487,7 @@ final class MobileHuddleController extends Notifier { return; } + if (admissionToken.preserveMembership) return; try { await actions.leaveChannel(backingChannelId); } catch (_) { @@ -511,7 +530,7 @@ final class MobileHuddleController extends Notifier { if (!_finishedLifecycleAdmissions.add(admissionToken)) return; Object? failure; try { - await ref.read(huddleSessionProvider.notifier).leave(); + await _leaveLocalAudio(); } catch (error) { failure = error; } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 8548838305..8c927d6ab0 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -262,6 +262,8 @@ Widget _buildTestable({ String? chatColor, bool personalChat = false, required List messages, + List huddleMessages = const [], + Map huddleSummaries = const {}, PresenceCacheNotifier? presenceCache, List? ownedAgents, List typing = const [], @@ -370,6 +372,13 @@ Widget _buildTestable({ ? channelMessagesNotifier ?? _FakeMessagesNotifier(messages) : fakeMessagesNotifier, ), + channelMessagesProvider(_huddleChannelId).overrideWith( + () => _FakeMessagesNotifier( + huddleMessages, + channelId: _huddleChannelId, + summaries: huddleSummaries, + ), + ), channelTypingProvider( _channelId, ).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)), @@ -10763,103 +10772,120 @@ void main() { }, ); - testWidgets( - 'Codex DM hangup leaves cleanup to relay with a one-owner snapshot', - (tester) async { - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final media = _HuddleTestMedia(); - final transport = _HuddleTestTransport( - peers: const {2: HuddlePeer(pubkey: 'self', peerIndex: 2, epoch: 0)}, - ); - final relaySession = _ReconnectingRelaySession(); - String? leftChannelId; - String? archivedChannelId; - - await tester.pumpWidget( - _buildTestable( - channel: Channel( - id: _channelId, - name: 'Codex', - description: '', - channelType: 'dm', - visibility: 'private', - createdBy: 'self', - createdAt: DateTime(2025), - memberCount: 2, - participants: const ['Self', 'Agent'], - participantPubkeys: const ['self', 'agent'], - isMember: true, - ), - voiceAgents: const {'agent'}, - huddleHumanCountLoader: (_) async => 1, - messages: [ - _huddleMsg( - id: 'last-human-call-leave', - kind: EventKind.huddleStarted, - pubkey: 'desktop', - createdAt: now, - ), - ], - users: const { - 'desktop': UserProfile(pubkey: 'desktop'), - 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), - 'self': UserProfile(pubkey: 'self'), + for (final scenario in [ + (anotherDevice: false, otherHuman: false), + (anotherDevice: true, otherHuman: false), + (anotherDevice: false, otherHuman: true), + ]) { + final anotherDevice = scenario.anotherDevice; + final shouldEnd = !anotherDevice && !scenario.otherHuman; + testWidgets( + 'Codex DM hangup ends only after the last human device: $scenario', + (tester) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final media = _HuddleTestMedia(); + final transport = _HuddleTestTransport( + peers: { + 2: const HuddlePeer(pubkey: 'self', peerIndex: 2, epoch: 0), + if (anotherDevice) + 3: const HuddlePeer(pubkey: 'self', peerIndex: 3, epoch: 0), }, - relayConfigNotifier: _HuddleRelayConfigNotifier(), - relaySessionNotifier: relaySession, - huddleCurrentPubkey: 'self', - huddleMembers: [ - ChannelMember( - pubkey: 'self', - role: 'member', - joinedAt: DateTime(2025), + ); + final relaySession = _ReconnectingRelaySession(); + String? leftChannelId; + String? archivedChannelId; + + await tester.pumpWidget( + _buildTestable( + channel: Channel( + id: _channelId, + name: 'Codex', + description: '', + channelType: 'dm', + visibility: 'private', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, ), - ChannelMember( - pubkey: 'agent', - role: 'bot', - joinedAt: DateTime(2025), + voiceAgents: scenario.otherHuman ? const {} : const {'agent'}, + huddleHumanCountLoader: (_) async => scenario.otherHuman ? 2 : 1, + messages: [ + _huddleMsg( + id: 'last-human-call-leave', + kind: EventKind.huddleStarted, + pubkey: 'desktop', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop'), + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self'), + }, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + relaySessionNotifier: relaySession, + huddleCurrentPubkey: 'self', + huddleMembers: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'agent', + role: scenario.otherHuman ? 'member' : 'bot', + joinedAt: DateTime(2025), + ), + ], + huddleMediaFactory: () => media, + huddleTransportFactory: (_) => transport, + createChannelActions: (ref) => _FakeChannelActions( + ref, + onLeaveChannel: (channelId) async => leftChannelId = channelId, + onArchiveChannel: (channelId) async => + archivedChannelId = channelId, ), - ], - huddleMediaFactory: () => media, - huddleTransportFactory: (_) => transport, - createChannelActions: (ref) => _FakeChannelActions( - ref, - onLeaveChannel: (channelId) async => leftChannelId = channelId, - onArchiveChannel: (channelId) async => - archivedChannelId = channelId, ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Join')); - await tester.pumpAndSettle(); - relaySession.connect(); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('huddle-leave'))); - await tester.pump(); - for ( - var attempt = 0; - attempt < 100 && archivedChannelId == null; - attempt++ - ) { + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + relaySession.connect(); await tester.pump(); - } - for (var attempt = 0; attempt < 20; attempt++) { + await tester.tap(find.byKey(const ValueKey('huddle-leave'))); + await tester.pump(); + for ( + var attempt = 0; + attempt < 100 && archivedChannelId == null; + attempt++ + ) { + await tester.pump(); + } + for (var attempt = 0; attempt < 20; attempt++) { + await tester.pump(); + } + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); - } - await tester.pump(const Duration(milliseconds: 300)); - await tester.pump(const Duration(milliseconds: 300)); - await tester.pump(); - expect(leftChannelId, isNull); - expect(archivedChannelId, isNull); - expect( - relaySession.publishedKinds, - isNot(contains(EventKind.huddleEnded)), - ); - }, - ); + expect(leftChannelId, isNull); + expect( + archivedChannelId, + shouldEnd ? '8d764100-fd8f-44cf-9c98-6d8fbd739b8c' : isNull, + ); + expect( + relaySession.publishedKinds, + shouldEnd + ? contains(EventKind.huddleEnded) + : isNot(contains(EventKind.huddleEnded)), + ); + }, + ); + } testWidgets('top-right call end auto-ends for the last human', ( tester, @@ -11116,15 +11142,21 @@ void main() { ); testWidgets( - 'community transition leaves a newer call after background cleanup', + 'community transition leaves a newer call without waiting for old cleanup', (tester) async { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final oldMedia = _HuddleTestMedia(); final currentMedia = _HuddleTestMedia(); - final media = Queue<_HuddleTestMedia>.of([oldMedia, currentMedia]); + final nextMedia = _HuddleTestMedia(); + final media = Queue<_HuddleTestMedia>.of([ + oldMedia, + currentMedia, + nextMedia, + ]); final transports = Queue<_HuddleTestTransport>.of([ _HuddleTestTransport(), _HuddleTestTransport(), + _HuddleTestTransport(), ]); final oldHumanCount = Completer(); var humanCountCalls = 0; @@ -11185,18 +11217,32 @@ void main() { .run() .then((_) => transitionCompleted = true); await tester.pump(); - expect(transitionCompleted, isFalse); + expect(transitionCompleted, isTrue); - oldHumanCount.complete(2); await transition; expect(currentMedia.state.phase, HuddleMediaPhase.stopped); expect(container.read(huddleSessionProvider).isInSession, isFalse); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await container + .read(mobileHuddleControllerProvider.notifier) + .join( + parentChannelId: _channelId, + ephemeralChannelId: _huddleChannelId, + startedBy: 'desktop', + startedEventId: 'next-community-call', + ); + lifecycle.setLifecycle(AppLifecycleState.paused); + await nextMedia.stopStarted.future; + await tester.pump(); + expect(nextMedia.state.phase, HuddleMediaPhase.stopped); + oldHumanCount.complete(2); + await tester.pump(); }, ); testWidgets( - 'community transition awaits failed-session lifecycle cleanup', + 'community transition cancels failed-session lifecycle cleanup', (tester) async { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final media = _HuddleTestMedia(); @@ -11248,12 +11294,13 @@ void main() { .then((_) => transitionCompleted = true); await tester.pump(); - expect(transitionCompleted, isFalse); + expect(transitionCompleted, isTrue); expect(leftChannelId, isNull); humanCount.complete(2); await transition; - expect(leftChannelId, _huddleChannelId); + await tester.pump(); + expect(leftChannelId, isNull); }, ); @@ -11485,9 +11532,18 @@ void main() { huddleEndPublishGate: endPublishGate.future, ); String? archivedChannelId; + final messages = _FakeMessagesNotifier([ + _huddleMsg( + id: 'last-human-rejoin', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ]); await tester.pumpWidget( _buildTestable( + messagesNotifier: messages, messages: [ _huddleMsg( id: 'last-human-rejoin', @@ -11533,6 +11589,25 @@ void main() { await rejoin; await tester.pump(); + messages.setMessages([ + _huddleMsg( + id: 'last-human-rejoin', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + _huddleMsg( + id: 'rejoin-ended', + kind: EventKind.huddleEnded, + pubkey: 'self', + createdAt: now + 1, + ), + ]); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('huddle-minimize'))); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, 'Open'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'View'), findsNothing); expect(relaySession.publishedKinds, contains(EventKind.huddleEnded)); expect(archivedChannelId, isNull); expect(container.read(huddleSessionProvider).isConnected, isTrue); @@ -11541,6 +11616,88 @@ void main() { }, ); + for (final pending in ['count', 'end']) { + testWidgets('community transition cancels pending Huddle $pending', ( + tester, + ) async { + final count = Completer(); + final end = Completer(); + final stop = Completer(); + final media = _HuddleTestMedia(stopGate: stop.future); + final relay = _ReconnectingRelaySession( + huddleEndPublishGate: end.future, + ); + String? archived; + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'transition', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ], + relayConfigNotifier: _HuddleRelayConfigNotifier(), + relaySessionNotifier: relay, + huddleCurrentPubkey: 'self', + huddleHumanCountLoader: (_) => + pending == 'count' ? count.future : Future.value(1), + huddleMediaFactory: () => media, + huddleTransportFactory: (_) => _HuddleTestTransport(), + createChannelActions: (ref) => _FakeChannelActions( + ref, + onArchiveChannel: (id) async => archived = id, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MobileHuddleShell)), + ); + final leaving = container + .read(mobileHuddleControllerProvider.notifier) + .leave(); + await media.stopStarted.future; + if (pending == 'end') { + stop.complete(); + await relay.huddleEndPublishStarted.future; + } + var transitioned = false; + final transition = container + .read(communityTransitionProvider) + .run() + .then((_) => transitioned = true); + await tester.pump(); + if (pending == 'count') { + expect( + transitioned, + isFalse, + reason: 'local audio teardown still owns the barrier', + ); + stop.complete(); + } + await transition; + await tester.pump(); + expect( + transitioned, + isTrue, + reason: 'remote cleanup must not own the barrier', + ); + await transition; + count.complete(1); + end.complete(); + await leaving; + expect(archived, isNull); + if (pending == 'count') { + expect(relay.publishedKinds, isNot(contains(EventKind.huddleEnded))); + } + expect(container.read(huddleSessionProvider).isInSession, isFalse); + }); + } + testWidgets( 'creator end publish superseded by rejoin cannot archive new admission', (tester) async { @@ -11612,37 +11769,97 @@ void main() { }, ); - testWidgets('disables Join after the matching huddle end event', ( - tester, - ) async { - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await tester.pumpWidget( - _buildTestable( - messages: [ - _huddleMsg( - id: 'ended-huddle-start', - kind: EventKind.huddleStarted, - createdAt: now, - ), - _huddleMsg( - id: 'ended-huddle-end', - kind: EventKind.huddleEnded, - createdAt: now + 1, - ), - ], - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); + testWidgets( + 'views the original transcript after the matching huddle end event', + (tester) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + channelsNotifier: _FakeChannelsNotifier([_testChannel]) + ..backingMembers = [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2026), + ), + ], + messages: [ + _huddleMsg( + id: 'ended-huddle-start', + kind: EventKind.huddleStarted, + createdAt: now, + ), + _huddleMsg( + id: 'ended-huddle-end', + kind: EventKind.huddleEnded, + createdAt: now + 1, + ), + ], + huddleMessages: [ + _textMsg( + id: 'transcript', + pubkey: 'alice', + content: 'The transcript remains available.', + ), + ], + huddleSummaries: { + 'transcript': const ChannelWindowThreadSummary( + replyCount: 2, + descendantCount: 2, + lastReplyAt: 1001, + participantPubkeys: ['alice'], + ), + }, + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); - expect(find.text('Huddle ended'), findsOneWidget); - final ended = tester.widget( - find.widgetWithText(FilledButton, 'Ended'), - ); - expect(ended.onPressed, isNull); - }); + expect(find.text('Huddle ended'), findsOneWidget); + final ended = tester.widget( + find.widgetWithText(FilledButton, 'View'), + ); + expect(ended.onPressed, isNotNull); + await tester.tap(find.widgetWithText(FilledButton, 'View')); + await tester.pumpAndSettle(); + expect(find.text('The transcript remains available.'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Join'), findsNothing); + expect(find.byType(TextField), findsNothing); + await tester.tap( + find.byKey(const ValueKey('thread-summary-transcript')), + ); + await tester.pumpAndSettle(); + final thread = tester.widget( + find.byType(ThreadDetailPage), + ); + expect(thread.channelId, _huddleChannelId); + expect(thread.threadHead.id, 'transcript'); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, 'View'), findsOneWidget); + final container = ProviderScope.containerOf( + tester.element(find.byType(MobileHuddleShell)), + ); + final channels = + container.read(channelsProvider.notifier) as _FakeChannelsNotifier; + channels.backingMembers = []; + channels.setChannels([_testChannel]); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, 'View'), findsNothing); + expect( + tester + .widget(find.widgetWithText(FilledButton, 'Ended')) + .onPressed, + isNull, + ); + }, + ); for (final huddleEvent in [ (kind: EventKind.huddleStarted, action: 'started a huddle'), @@ -20983,6 +21200,12 @@ NostrEvent _channelMetadataEvent( class _FakeChannelsNotifier extends ChannelsNotifier { List _channels; + List backingMembers = []; + + @override + List cachedMembersForChannel(String channelId) => + channelId == _huddleChannelId ? backingMembers : const []; + final Map> _observedUnread; _FakeChannelsNotifier(