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 <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-11-12 18:45:10 +00:00
committed by GitHub
parent aa9b44ef36
commit 5f81803cb1
6 changed files with 484 additions and 153 deletions

View File

@@ -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';

View File

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

View File

@@ -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<void>(
builder: (_) =>
_HuddleHistoryPage(channelId: ephemeralChannelId),
),
)
: (canJoin || isThisSession) &&
session.phase != HuddleSessionPhase.leaving
? () => unavailable
? unawaited(
_startMobileHuddleFromChannel(

View File

@@ -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<bool, String>(
(ref, channelId) {
ref.watch(channelsProvider);
final pubkey = ref.watch(myPubkeyProvider);
return pubkey != null &&
ref
.read(channelsProvider.notifier)
.cachedMembersForChannel(channelId)
.any((member) => member.pubkey == pubkey);
},
);

View File

@@ -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<bool> {
_admissionToken = _HuddleAdmissionToken(
admissionEpoch,
backingChannelId,
relayOwnsCleanup: _isDirectCall(parentChannelId),
preserveMembership: _isDirectCall(parentChannelId),
);
} catch (_) {
if (admissionEpoch == _admissionEpoch) {
@@ -251,7 +249,7 @@ final class MobileHuddleController extends Notifier<bool> {
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<bool> {
_admissionToken = _HuddleAdmissionToken(
admissionEpoch,
ephemeralChannelId,
relayOwnsCleanup: relayOwnsCleanup,
preserveMembership: preserveMembership,
);
}
}
@@ -301,6 +299,7 @@ final class MobileHuddleController extends Notifier<bool> {
}
Future<void> _finishBackgroundLeave() async {
final generation = _lifecycleGeneration;
Object? leaveFailure;
StackTrace? leaveFailureStackTrace;
try {
@@ -310,8 +309,10 @@ final class MobileHuddleController extends Notifier<bool> {
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<bool> {
);
}
int _lifecycleGeneration = 0;
Future<void>? _localLeave;
Future<void> _leaveLocalAudio() {
return _localLeave ??= ref
.read(huddleSessionProvider.notifier)
.leave()
.whenComplete(() => _localLeave = null);
}
Future<void> _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<bool> {
// 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<void> leave() async {
final lifecycleGeneration = _lifecycleGeneration;
++_generation;
state = false;
final session = ref.read(huddleSessionProvider);
@@ -365,7 +377,6 @@ final class MobileHuddleController extends Notifier<bool> {
final humanCount =
!session.wasAdmitted ||
backingChannelId == null ||
admissionToken?.relayOwnsCleanup == true ||
_hasOtherOwnerAdmission(session)
? null
: ref
@@ -379,7 +390,7 @@ final class MobileHuddleController extends Notifier<bool> {
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<bool> {
admissionToken != null) {
await _trackLifecycleCleanup(
_finishLeaveLifecycle(
generation: lifecycleGeneration,
admissionToken: admissionToken,
parentChannelId: parentChannelId,
backingChannelId: backingChannelId,
@@ -403,10 +415,11 @@ final class MobileHuddleController extends Notifier<bool> {
}
Future<void> _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<bool> {
.catchError((_) => 2);
await _trackLifecycleCleanup(
_finishLeaveLifecycle(
generation: lifecycleGeneration,
admissionToken: admissionToken,
parentChannelId: session.parentChannelId,
backingChannelId: backingChannelId,
@@ -434,13 +448,16 @@ final class MobileHuddleController extends Notifier<bool> {
}
Future<void> _finishLeaveLifecycle({
required int generation,
required _HuddleAdmissionToken admissionToken,
required String? parentChannelId,
required String backingChannelId,
required Future<int> 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<bool> {
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<bool> {
return;
}
if (admissionToken.preserveMembership) return;
try {
await actions.leaveChannel(backingChannelId);
} catch (_) {
@@ -511,7 +530,7 @@ final class MobileHuddleController extends Notifier<bool> {
if (!_finishedLifecycleAdmissions.add(admissionToken)) return;
Object? failure;
try {
await ref.read(huddleSessionProvider.notifier).leave();
await _leaveLocalAudio();
} catch (error) {
failure = error;
}

View File

@@ -262,6 +262,8 @@ Widget _buildTestable({
String? chatColor,
bool personalChat = false,
required List<NostrEvent> messages,
List<NostrEvent> huddleMessages = const [],
Map<String, ChannelWindowThreadSummary> huddleSummaries = const {},
PresenceCacheNotifier? presenceCache,
List<OwnedAgentDirectoryEntry>? ownedAgents,
List<TypingEntry> 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<int>();
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<int>();
final end = Completer<void>();
final stop = Completer<void>();
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<FilledButton>(
find.widgetWithText(FilledButton, 'Ended'),
);
expect(ended.onPressed, isNull);
});
expect(find.text('Huddle ended'), findsOneWidget);
final ended = tester.widget<FilledButton>(
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<ThreadDetailPage>(
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<FilledButton>(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<Channel> _channels;
List<ChannelMember> backingMembers = [];
@override
List<ChannelMember> cachedMembersForChannel(String channelId) =>
channelId == _huddleChannelId ? backingMembers : const [];
final Map<String, Map<String, ObservedUnreadEvent>> _observedUnread;
_FakeChannelsNotifier(