Files
buzz/mobile/lib/features/channels/mobile_huddle_controller.dart
klopez4212 5f81803cb1 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>
2026-11-12 10:45:10 -08:00

598 lines
19 KiB
Dart

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/huddle/huddle.dart';
import '../../shared/relay/relay.dart';
import 'channel_management_provider.dart';
import 'dm_voice_agent_provider.dart';
import 'channels_provider.dart';
typedef HuddleHumanCountLoader = Future<int> Function(String channelId);
enum MobileHuddlePresentation { hidden, fullScreen, drawer }
/// Tracks whether the foreground Huddle owns the screen or lives in the
/// persistent app-level drawer. Audio lifecycle remains owned separately by
/// [huddleSessionProvider].
final class MobileHuddlePresentationNotifier
extends Notifier<MobileHuddlePresentation> {
@override
MobileHuddlePresentation build() {
ref.listen(huddleSessionProvider, (_, next) {
if (!next.isInSession && state != MobileHuddlePresentation.hidden) {
state = MobileHuddlePresentation.hidden;
}
});
return MobileHuddlePresentation.hidden;
}
void showFullScreen() => state = MobileHuddlePresentation.fullScreen;
void minimize() {
if (ref.read(huddleSessionProvider).isInSession) {
state = MobileHuddlePresentation.drawer;
}
}
void hide() => state = MobileHuddlePresentation.hidden;
}
final mobileHuddlePresentationProvider =
NotifierProvider<
MobileHuddlePresentationNotifier,
MobileHuddlePresentation
>(MobileHuddlePresentationNotifier.new);
/// Loads the backing-channel human count used by desktop's leave rule.
///
/// A disconnected or failed lookup throws so [MobileHuddleController.leave]
/// can take desktop's safe fallback: assume another human remains and avoid
/// ending the Huddle on transient relay failure.
final huddleHumanCountProvider = Provider<HuddleHumanCountLoader>((ref) {
return (channelId) async {
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
throw StateError('Relay is unavailable for Huddle member lookup.');
}
// Desktop always queries the relay's current kind:39002 snapshot here.
// The UI-facing provider intentionally preserves cached members through
// reconnects, but that cache can lag a leave event and must not decide
// whether the room ends for everyone.
final events = await ref
.read(relaySessionProvider.notifier)
.fetchHistory(NostrFilters.channelMembers(channelId));
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
throw StateError('Relay disconnected during Huddle member lookup.');
}
if (events.isEmpty) return 0;
return membersFromEvent(
events.first,
).where((member) => member.role != 'bot').length;
};
});
/// Complete parent-channel Huddle lifecycle, independent of timeline paging.
final huddleLifecycleProvider = FutureProvider.family<List<NostrEvent>, String>(
(ref, channelId) async {
if (ref.watch(relaySessionProvider).status != SessionStatus.connected) {
return const [];
}
return ref
.read(relaySessionProvider.notifier)
.fetchHistory(NostrFilters.huddleLifecycle(channelId));
},
);
/// One successful relay admission. Its epoch changes only when a newer join or
/// start attempt begins, so duplicate teardown calls cannot invalidate the
/// cleanup already in flight for the same admission.
final class _HuddleAdmissionToken {
final int epoch;
final String backingChannelId;
/// DM membership also grants access to the saved transcript after hanging up.
final bool preserveMembership;
const _HuddleAdmissionToken(
this.epoch,
this.backingChannelId, {
this.preserveMembership = false,
});
}
/// Coordinates the Nostr lifecycle around the foreground audio session.
final class MobileHuddleController extends Notifier<bool> {
var _generation = 0;
var _admissionEpoch = 0;
String? _latestAdmissionTargetBackingChannelId;
_HuddleAdmissionToken? _admissionToken;
final Set<_HuddleAdmissionToken> _finishedLifecycleAdmissions = {};
@override
bool build() {
final unregisterBeforePause = ref
.read(relaySessionProvider.notifier)
.registerBeforePause(_leaveForBackground);
final unregisterBeforeCommunityTransition = ref
.read(communityTransitionProvider)
.register(_leaveForTransition);
ref.onDispose(unregisterBeforePause);
ref.onDispose(unregisterBeforeCommunityTransition);
ref.listen(huddleSessionProvider, (previous, next) {
if (previous?.wasAdmitted == true &&
next.phase == HuddleSessionPhase.failed) {
_startFailureCleanup(next);
}
});
ref.listen(appLifecycleProvider, (_, next) {
if (next == AppLifecycleState.paused ||
next == AppLifecycleState.detached) {
unawaited(_leaveForBackground());
}
});
return false;
}
Future<void>? _startFuture;
Future<void> start({required String parentChannelId, String? agentPubkey}) {
final inFlight = _startFuture;
if (inFlight != null) return inFlight;
late final Future<void> startFuture;
startFuture =
_start(
parentChannelId: parentChannelId,
agentPubkey: agentPubkey,
).whenComplete(() {
if (identical(_startFuture, startFuture)) _startFuture = null;
});
_startFuture = startFuture;
return startFuture;
}
Future<void> _start({
required String parentChannelId,
String? agentPubkey,
}) async {
if (state) return;
final generation = ++_generation;
final admissionEpoch = ++_admissionEpoch;
state = true;
final actions = ref.read(channelActionsProvider);
String? backingChannelId;
var announced = false;
try {
final resolvedAgent = await resolveDmVoiceAgent(ref, parentChannelId);
_ensureCurrent(generation);
if (agentPubkey != null && resolvedAgent != agentPubkey) {
throw StateError('This agent is no longer available for voice.');
}
agentPubkey = resolvedAgent;
backingChannelId = await actions.createHuddleBackingChannel(
parentChannelId: parentChannelId,
);
_latestAdmissionTargetBackingChannelId = backingChannelId;
_ensureCurrent(generation);
if (agentPubkey != null) {
await actions.addMembers(
channelId: backingChannelId,
pubkeys: [agentPubkey],
role: 'bot',
);
_ensureCurrent(generation);
}
final start = await actions.announceHuddleStarted(
parentChannelId: parentChannelId,
ephemeralChannelId: backingChannelId,
);
announced = true;
_ensureCurrent(generation);
final parameters = _parameters(
parentChannelId: parentChannelId,
ephemeralChannelId: backingChannelId,
);
await ref
.read(huddleSessionProvider.notifier)
.join(
parameters,
currentPubkey: ref.read(currentPubkeyProvider),
isCreator: true,
startedEventId: start.id,
);
_ensureCurrent(generation);
final session = ref.read(huddleSessionProvider);
if (session.phase == HuddleSessionPhase.failed) {
throw StateError(session.error ?? 'Unable to join the new Huddle.');
}
_admissionToken = _HuddleAdmissionToken(
admissionEpoch,
backingChannelId,
preserveMembership: _isDirectCall(parentChannelId),
);
} catch (_) {
if (admissionEpoch == _admissionEpoch) {
_latestAdmissionTargetBackingChannelId = null;
}
if (backingChannelId != null) {
if (announced) {
try {
await actions.announceHuddleEnded(
parentChannelId: parentChannelId,
ephemeralChannelId: backingChannelId,
);
} catch (_) {
// Continue the best-effort rollback with channel archival.
}
}
try {
await actions.archiveChannel(backingChannelId);
} catch (_) {
// The original start failure is more useful to the caller.
}
}
rethrow;
} finally {
if (generation == _generation) state = false;
}
}
Future<void> join({
required String parentChannelId,
required String ephemeralChannelId,
required String startedBy,
required String startedEventId,
}) async {
++_generation;
final admissionEpoch = ++_admissionEpoch;
_latestAdmissionTargetBackingChannelId = ephemeralChannelId;
final currentPubkey = ref.read(currentPubkeyProvider);
final preserveMembership = _isDirectCall(parentChannelId);
try {
await ref
.read(huddleSessionProvider.notifier)
.join(
_parameters(
parentChannelId: parentChannelId,
ephemeralChannelId: ephemeralChannelId,
),
currentPubkey: currentPubkey,
isCreator:
currentPubkey != null &&
currentPubkey.toLowerCase() == startedBy.toLowerCase(),
startedEventId: startedEventId,
);
} catch (_) {
if (admissionEpoch == _admissionEpoch) {
_latestAdmissionTargetBackingChannelId = null;
}
rethrow;
}
final session = ref.read(huddleSessionProvider);
if (session.wasAdmitted) {
_admissionToken = _HuddleAdmissionToken(
admissionEpoch,
ephemeralChannelId,
preserveMembership: preserveMembership,
);
}
}
Future<void>? _backgroundLeave;
Future<void>? _failureCleanup;
final Set<Future<void>> _lifecycleCleanups = {};
Future<void> _leaveForBackground() {
final inFlight = _backgroundLeave;
if (inFlight != null) return inFlight;
late final Future<void> backgroundLeave;
backgroundLeave = _finishBackgroundLeave().whenComplete(() {
if (identical(_backgroundLeave, backgroundLeave)) {
_backgroundLeave = null;
}
});
_backgroundLeave = backgroundLeave;
return backgroundLeave;
}
Future<void> _finishBackgroundLeave() async {
final generation = _lifecycleGeneration;
Object? leaveFailure;
StackTrace? leaveFailureStackTrace;
try {
await leave();
} catch (error, stackTrace) {
leaveFailure = error;
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());
}
if (leaveFailure != null) {
Error.throwWithStackTrace(leaveFailure, leaveFailureStackTrace!);
}
}
void _startFailureCleanup(HuddleSessionState session) {
final cleanup = _cleanupAfterLocalTeardown(session);
_failureCleanup = cleanup;
unawaited(
cleanup.whenComplete(() {
if (identical(_failureCleanup, cleanup)) _failureCleanup = null;
}),
);
}
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;
state = false;
try {
await startFuture;
} catch (_) {
// Cancellation is observed by the in-flight start, whose rollback must
// settle before the relay is rebound to the next community.
}
}
++_generation;
state = false;
await _leaveLocalAudio();
}
Future<void> leave() async {
final lifecycleGeneration = _lifecycleGeneration;
++_generation;
state = false;
final session = ref.read(huddleSessionProvider);
final admissionToken = session.wasAdmitted ? _admissionToken : null;
if (identical(_admissionToken, admissionToken)) _admissionToken = null;
final parentChannelId = session.parentChannelId;
final backingChannelId = session.ephemeralChannelId;
final humanCount =
!session.wasAdmitted ||
backingChannelId == null ||
_hasOtherOwnerAdmission(session)
? null
: ref
.read(huddleHumanCountProvider)(backingChannelId)
.catchError((_) => 2);
// Match desktop's user-facing ordering: release capture and transport
// before waiting on relay lifecycle work. The membership query can take
// several seconds or time out; it must never keep the microphone active
// or hold the call page open after the user hangs up.
Object? localFailure;
StackTrace? localFailureStackTrace;
try {
await _leaveLocalAudio();
} catch (error, stackTrace) {
localFailure = error;
localFailureStackTrace = stackTrace;
}
if (backingChannelId != null &&
humanCount != null &&
admissionToken != null) {
await _trackLifecycleCleanup(
_finishLeaveLifecycle(
generation: lifecycleGeneration,
admissionToken: admissionToken,
parentChannelId: parentChannelId,
backingChannelId: backingChannelId,
humanCount: humanCount,
),
);
}
if (localFailure != null) {
Error.throwWithStackTrace(localFailure, localFailureStackTrace!);
}
}
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 (_hasOtherOwnerAdmission(session)) {
_admissionToken = null;
return;
}
if (identical(_admissionToken, admissionToken)) _admissionToken = null;
final humanCount = ref
.read(huddleHumanCountProvider)(backingChannelId)
.catchError((_) => 2);
await _trackLifecycleCleanup(
_finishLeaveLifecycle(
generation: lifecycleGeneration,
admissionToken: admissionToken,
parentChannelId: session.parentChannelId,
backingChannelId: backingChannelId,
humanCount: humanCount,
),
);
}
Future<void> _trackLifecycleCleanup(Future<void> cleanup) async {
_lifecycleCleanups.add(cleanup);
try {
await cleanup;
} finally {
_lifecycleCleanups.remove(cleanup);
}
}
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 &&
currentSession.ephemeralChannelId == backingChannelId) {
return;
}
if (!_finishedLifecycleAdmissions.add(admissionToken)) return;
if (humansRemaining <= 1 && parentChannelId != null) {
// Desktop auto-ends when the departing person is the last human.
// Both lifecycle publication and archival are best effort there.
try {
await actions.announceHuddleEnded(
parentChannelId: parentChannelId,
ephemeralChannelId: backingChannelId,
);
} catch (_) {}
if (!ref.mounted || generation != _lifecycleGeneration) return;
if (_newerAdmissionTargetsSameHuddle(admissionToken)) return;
final resumedSession = ref.read(huddleSessionProvider);
if (resumedSession.isInSession &&
resumedSession.ephemeralChannelId == backingChannelId) {
return;
}
try {
await actions.archiveChannel(backingChannelId);
} catch (_) {}
return;
}
if (admissionToken.preserveMembership) return;
try {
await actions.leaveChannel(backingChannelId);
} catch (_) {
// The audio relay may already have auto-ended and archived the room.
}
}
bool _isDirectCall(String parentChannelId) {
final channel = ref.read(channelByIdProvider(parentChannelId));
return channel?.isDm == true &&
channel!.participantPubkeys.toSet().length == 2;
}
bool _hasOtherOwnerAdmission(HuddleSessionState session) =>
session.currentPubkey != null &&
session.participantPubkeys
.where(
(key) =>
key.toLowerCase() == session.currentPubkey!.toLowerCase(),
)
.length >
1;
Future<void> end() async {
++_generation;
state = false;
final session = ref.read(huddleSessionProvider);
final admissionToken = session.wasAdmitted ? _admissionToken : null;
final parentChannelId = session.parentChannelId;
final backingChannelId = session.ephemeralChannelId;
if (!session.isCreator ||
admissionToken == null ||
parentChannelId == null ||
backingChannelId == null) {
throw StateError('Only the Huddle creator can end it.');
}
if (identical(_admissionToken, admissionToken)) _admissionToken = null;
final actions = ref.read(channelActionsProvider);
if (!_finishedLifecycleAdmissions.add(admissionToken)) return;
Object? failure;
try {
await _leaveLocalAudio();
} catch (error) {
failure = error;
}
if (_newerAdmissionTargetsSameHuddle(admissionToken)) {
if (failure != null) throw failure;
return;
}
try {
await actions.announceHuddleEnded(
parentChannelId: parentChannelId,
ephemeralChannelId: backingChannelId,
);
} catch (error) {
failure = error;
}
if (_newerAdmissionTargetsSameHuddle(admissionToken)) {
if (failure != null) throw failure;
return;
}
try {
await actions.archiveChannel(backingChannelId);
} catch (error) {
failure ??= error;
}
if (_newerAdmissionTargetsSameHuddle(admissionToken)) {
if (failure != null) throw failure;
return;
}
if (failure != null) throw failure;
}
bool _newerAdmissionTargetsSameHuddle(_HuddleAdmissionToken admissionToken) {
if (admissionToken.epoch == _admissionEpoch) return false;
return _latestAdmissionTargetBackingChannelId ==
admissionToken.backingChannelId;
}
HuddleConnectionParameters _parameters({
required String parentChannelId,
required String ephemeralChannelId,
}) {
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) {
throw StateError('A paired identity is required.');
}
return HuddleConnectionParameters(
relayWebSocketUrl: config.wsUrl,
nsec: nsec,
parentChannelId: parentChannelId,
ephemeralChannelId: ephemeralChannelId,
);
}
void _ensureCurrent(int generation) {
if (generation != _generation) {
throw StateError('Huddle start was cancelled.');
}
}
}
/// Foreground Huddle lifecycle operations and their start-in-progress flag.
final mobileHuddleControllerProvider =
NotifierProvider<MobileHuddleController, bool>(MobileHuddleController.new);