mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user