diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b195dc9eb81..b9a1587cdff 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -107,20 +107,59 @@ Future _loadDeepLinkEvents( } /// Fetch channel members and preload their profiles into the user cache. -Future _preloadMembers(WidgetRef ref, String channelId) async { +/// Returns whether identity resolution completed successfully. +Future _preloadMembers( + WidgetRef ref, + String channelId, + List participantPubkeys, +) async { // Capture references before async gap to avoid using disposed ref. final notifier = ref.read(userCacheProvider.notifier); try { final members = await ref.read(channelMembersProvider(channelId).future); - final pubkeys = members.map((m) => m.pubkey).toList(); + final pubkeys = { + ...members.map((member) => member.pubkey), + ...participantPubkeys, + }.toList(); if (pubkeys.isNotEmpty) { - notifier.preload(pubkeys); + return notifier.preload(pubkeys); } + return true; } catch (_) { - // Non-fatal — mentions will just fall back to cache from messages. + // Identity remains unresolved, so agent-only actions stay hidden. + return false; } } +Future _subscribeToDmIdentityUpdates( + WidgetRef ref, + List participantPubkeys, { + required VoidCallback onFailure, +}) async { + final session = ref.read(relaySessionProvider.notifier); + return session.subscribe( + NostrFilter( + kinds: const [0, 10100], + authors: participantPubkeys, + limit: 100, + ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5), + (event) { + if (event.kind == 0) { + try { + ref.read(userCacheProvider.notifier).cacheProfileEvent(event); + } catch (error) { + debugPrint('[DmIdentity] invalid live profile: $error'); + onFailure(); + } + } else if (event.kind == 10100) { + ref.invalidate(agentDirectoryProvider); + ref.invalidate(agentOwnersProvider); + } + }, + onClosed: (_) => onFailure(), + ); +} + int? _channelReadTimestamp({ required Channel channel, required AsyncValue> messagesState, @@ -146,6 +185,16 @@ int? _channelReadTimestamp({ return dateTimeToUnixSeconds(channel.lastMessageAt); } +bool _isOneToOneAgentDm(Channel channel, Set agentPubkeys) { + final participants = channel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet(); + return channel.isDm && + participants.length == 2 && + participants.any(agentPubkeys.contains); +} + /// Controls how a hydrated initial thread is added to the navigation stack. enum InitialThreadRouteBehavior { /// Keep the channel route beneath the thread. @@ -256,10 +305,129 @@ class ChannelDetailPage extends HookConsumerWidget { channel; final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; + final memberProfilesPreload = useMemoized( + () => _preloadMembers( + ref, + resolvedChannel.id, + resolvedChannel.participantPubkeys, + ), + [ + resolvedChannel.id, + sessionStatus, + Object.hashAll(resolvedChannel.participantPubkeys), + ], + ); + final memberProfilesPreloadState = useFuture(memberProfilesPreload); final showsComposer = !resolvedChannel.isForum && resolvedChannel.isMember && !resolvedChannel.isArchived; + final profileOwnedAgentPubkeys = []; + for (final participantPubkey in resolvedChannel.participantPubkeys) { + final normalized = participantPubkey.trim().toLowerCase(); + final isProfileOwnedAgent = ref.watch( + userCacheProvider.select( + (cache) => cache[normalized]?.ownerPubkey != null, + ), + ); + if (isProfileOwnedAgent) profileOwnedAgentPubkeys.add(normalized); + } + final agentDirectoryState = ref.watch(agentDirectoryProvider); + final agentOwnersState = ref.watch(agentOwnersProvider); + final participantCount = resolvedChannel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .length; + final isOneToOneDm = resolvedChannel.isDm && participantCount == 2; + final channelMembershipUpdateState = isOneToOneDm + ? ref.watch(channelMembershipUpdateProvider(resolvedChannel.id)) + : const ChannelMembershipUpdateState(isReady: true); + final channelBotPubkeysState = ref.watch( + channelBotPubkeysProvider(resolvedChannel.id), + ); + final agentPubkeys = agentPubkeysWithChannelBots( + knownAgentPubkeys: agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider), + profileOwnedAgentPubkeys: profileOwnedAgentPubkeys, + ), + channelBotPubkeys: + channelBotPubkeysState.asData?.value ?? const {}, + ); + final identitySubscriptionPubkeys = isOneToOneDm + ? (resolvedChannel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .toList() + ..sort()) + : const []; + final identitySubscriptionKey = Object.hashAll(identitySubscriptionPubkeys); + final identitySubscriptionReady = useValueNotifier(false, [ + sessionStatus, + resolvedChannel.id, + identitySubscriptionKey, + ]); + final isIdentitySubscriptionReady = useValueListenable( + identitySubscriptionReady, + ); + useEffect(() { + if (sessionStatus != SessionStatus.connected || + identitySubscriptionPubkeys.isEmpty) { + return null; + } + var disposed = false; + var subscriptionFailed = false; + void markFailed() { + subscriptionFailed = true; + if (!disposed) identitySubscriptionReady.value = false; + } + + void Function()? unsubscribe; + Future.microtask(() async { + try { + final cleanup = await _subscribeToDmIdentityUpdates( + ref, + identitySubscriptionPubkeys, + onFailure: markFailed, + ); + if (disposed) { + cleanup(); + } else { + unsubscribe = cleanup; + if (!subscriptionFailed) identitySubscriptionReady.value = true; + } + } catch (error) { + if (!disposed) { + debugPrint('[DmIdentity] live subscription failed: $error'); + markFailed(); + } + } + }); + return () { + disposed = true; + unsubscribe?.call(); + }; + }, [sessionStatus, resolvedChannel.id, identitySubscriptionKey]); + final isAgentIdentityUnresolved = + isOneToOneDm && + (sessionStatus != SessionStatus.connected || + !isIdentitySubscriptionReady || + agentDirectoryState.isLoading || + agentDirectoryState.hasError || + agentOwnersState.isLoading || + agentOwnersState.hasError || + !channelMembershipUpdateState.isReady || + channelMembershipUpdateState.error != null || + channelBotPubkeysState.isLoading || + channelBotPubkeysState.hasError || + memberProfilesPreloadState.connectionState != + ConnectionState.done || + memberProfilesPreloadState.data != true); + final showsHuddleAction = + showsComposer && + !isAgentIdentityUnresolved && + !_isOneToOneAgentDm(resolvedChannel, agentPubkeys); final messagesNotifier = ref.read( channelMessagesProvider(channel.id).notifier, ); @@ -301,12 +469,6 @@ class ChannelDetailPage extends HookConsumerWidget { return session.registerVisibleChannel(channel.id); }, [channel.id]); - // Preload channel member profiles so @mentions resolve correctly. - useEffect(() { - _preloadMembers(ref, channel.id); - return null; - }, [channel.id]); - useEffect( () { if (channel.isForum) return null; @@ -394,7 +556,7 @@ class ChannelDetailPage extends HookConsumerWidget { ), actions: resolvedChannel.isDm ? [ - if (showsComposer) + if (showsHuddleAction) _HuddleButton( channel: resolvedChannel, events: [ diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 9e021ab5178..119f5dce7b0 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -488,7 +488,11 @@ final channelDetailsProvider = FutureProvider.family(( /// Channel members from kind:39002 NIP-29 members event. final channelMembersProvider = FutureProvider.autoDispose .family, String>((ref, channelId) async { - ref.watch(channelMembershipUpdateProvider(channelId)); + ref.watch( + channelMembershipUpdateProvider( + channelId, + ).select((update) => update.version), + ); final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl; final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); final snapshotCache = ref.read(_channelMembersSnapshotCacheProvider); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index ea6a2ee50fd..cf51245d134 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -161,10 +161,24 @@ Map mentionNamesWithDirectoryLabels({ String _agentFallbackLabel(String pubkey) => pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey; +/// Readiness and refresh state for a channel's live membership subscription. +class ChannelMembershipUpdateState { + final int version; + final bool isReady; + final Object? error; + + const ChannelMembershipUpdateState({ + this.version = 0, + this.isReady = false, + this.error, + }); +} + /// Keeps the role feed alive for consumers that render mentions outside the /// channel timeline, such as search results. A membership change refreshes the /// shared bot-role lookup below, regardless of which surface owns the channel. -class _ChannelBotRoleSubscription extends Notifier { +class _ChannelBotRoleSubscription + extends Notifier { final String channelId; void Function()? _unsubscribe; int _subscriptionVersion = 0; @@ -172,7 +186,7 @@ class _ChannelBotRoleSubscription extends Notifier { _ChannelBotRoleSubscription(this.channelId); @override - int build() { + ChannelMembershipUpdateState build() { final sessionState = ref.watch(relaySessionProvider); final subscriptionVersion = ++_subscriptionVersion; _clearSubscription(); @@ -181,9 +195,11 @@ class _ChannelBotRoleSubscription extends Notifier { _clearSubscription(); }); - if (sessionState.status != SessionStatus.connected) return 0; + if (sessionState.status != SessionStatus.connected) { + return const ChannelMembershipUpdateState(); + } Future.microtask(() => _subscribe(channelId, subscriptionVersion)); - return 0; + return const ChannelMembershipUpdateState(); } Future _subscribe(String channelId, int subscriptionVersion) async { @@ -193,12 +209,23 @@ class _ChannelBotRoleSubscription extends Notifier { NostrFilter( kinds: const [39002], tags: { - '#h': [channelId], + '#d': [channelId], }, ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000), (_) { if (_isCurrent(subscriptionVersion)) { - state++; + state = ChannelMembershipUpdateState( + version: state.version + 1, + isReady: true, + ); + } + }, + onClosed: (message) { + if (_isCurrent(subscriptionVersion)) { + state = ChannelMembershipUpdateState( + version: state.version, + error: Exception(message), + ); } }, ); @@ -207,8 +234,16 @@ class _ChannelBotRoleSubscription extends Notifier { return; } _unsubscribe = unsubscribe; + state = ChannelMembershipUpdateState( + version: state.version, + isReady: true, + ); } catch (error) { if (_isCurrent(subscriptionVersion)) { + state = ChannelMembershipUpdateState( + version: state.version, + error: error, + ); debugPrint( '[ChannelBotRoleSubscription] failed for $channelId: $error', ); @@ -229,14 +264,18 @@ class _ChannelBotRoleSubscription extends Notifier { /// changes. Channel-member and agent-role views share this source so remote /// membership updates refresh both snapshots together. final channelMembershipUpdateProvider = NotifierProvider.autoDispose - .family<_ChannelBotRoleSubscription, int, String>( + .family<_ChannelBotRoleSubscription, ChannelMembershipUpdateState, String>( _ChannelBotRoleSubscription.new, ); /// Bot pubkeys currently assigned a channel bot role. final channelBotPubkeysProvider = FutureProvider.autoDispose .family, String>((ref, channelId) async { - ref.watch(channelMembershipUpdateProvider(channelId)); + ref.watch( + channelMembershipUpdateProvider( + channelId, + ).select((update) => update.version), + ); final sessionState = ref.watch(relaySessionProvider); if (sessionState.status != SessionStatus.connected) return const {}; final session = ref.read(relaySessionProvider.notifier); diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index c04bf952ae1..7c213f936f0 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -13,6 +13,7 @@ import 'user_profile.dart'; class UserCacheNotifier extends Notifier> { final Set _pending = {}; Timer? _batchTimer; + Completer? _batchCompleter; @override Map build() { @@ -20,6 +21,8 @@ class UserCacheNotifier extends Notifier> { ref.onDispose(() { _batchTimer?.cancel(); _batchTimer = null; + _batchCompleter?.complete(false); + _batchCompleter = null; }); return {}; } @@ -34,14 +37,19 @@ class UserCacheNotifier extends Notifier> { } /// Preload profiles for a list of pubkeys (e.g. channel members). - void preload(List pubkeys) { - final uncached = pubkeys + /// Returns whether the batch completed successfully. + Future preload(List pubkeys) { + final normalized = pubkeys.map((pk) => pk.toLowerCase()).toSet(); + final alreadyPending = normalized.any(_pending.contains); + final uncached = normalized .map((pk) => pk.toLowerCase()) .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) .toList(); - if (uncached.isEmpty) return; + if (uncached.isEmpty && !alreadyPending) return Future.value(true); _pending.addAll(uncached); + final completer = _batchCompleter ??= Completer(); _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + return completer.future; } /// Applies a live kind:0 profile event to the cache. @@ -57,6 +65,7 @@ class UserCacheNotifier extends Notifier> { void _scheduleFetch(String pubkey) { if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; _pending.add(pubkey); + _batchCompleter ??= Completer(); _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); } @@ -66,7 +75,10 @@ class UserCacheNotifier extends Notifier> { final pubkeys = _pending.toList(); _pending.clear(); + final completer = _batchCompleter; + _batchCompleter = null; + var succeeded = false; try { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( @@ -80,8 +92,11 @@ class UserCacheNotifier extends Notifier> { } state = updated; + succeeded = true; } catch (_) { - // Silently fail — we'll just show pubkeys. + // Silently fail — non-gating callers will just show pubkeys. + } finally { + completer?.complete(succeeded); } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 4178dbc76a3..d42ebd525b0 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -200,6 +200,10 @@ Widget _buildTestable({ required List messages, List typing = const [], Map users = const {}, + Set? knownAgentPubkeys, + Future> Function()? loadChannelBotPubkeys, + Future> Function()? loadAgentDirectory, + Future> Function()? loadAgentOwners, _FakeUserCacheNotifier? userCacheNotifier, List members = const [], List huddleMembers = const [], @@ -280,16 +284,23 @@ Widget _buildTestable({ ), if (huddleMembersNotifier != null) _mutableHuddleMembersProvider.overrideWith(() => huddleMembersNotifier), - channelBotPubkeysProvider( - _channelId, - ).overrideWith((ref) async => const {}), + channelBotPubkeysProvider(_channelId).overrideWith( + (ref) async => loadChannelBotPubkeys?.call() ?? const {}, + ), channelBotPubkeysProvider(_huddleChannelId).overrideWith( (ref) async => { for (final member in huddleMembers) if (member.isBot) member.pubkey.toLowerCase(), }, ), - agentOwnersProvider.overrideWith((ref) async => const {}), + agentOwnersProvider.overrideWith( + (ref) async => loadAgentOwners?.call() ?? const {}, + ), + agentDirectoryProvider.overrideWith( + (ref) async => loadAgentDirectory?.call() ?? const [], + ), + if (knownAgentPubkeys != null) + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), if (directoryUsers != null) relayDirectoryUsersProvider.overrideWith((ref) async => directoryUsers), if (createChannelActions != null) @@ -327,8 +338,12 @@ Widget _buildTestable({ ), mediaHttpClientProvider.overrideWithValue(mediaClient), ], - if (relaySessionNotifier != null) - relaySessionProvider.overrideWith(() => relaySessionNotifier), + if (relaySessionNotifier != null || + (resolvedChannel.isDm && + resolvedChannel.participantPubkeys.toSet().length == 2)) + relaySessionProvider.overrideWith( + () => relaySessionNotifier ?? _IdentityUpdateRelaySession(), + ), if (relayConfigNotifier != null) relayConfigProvider.overrideWith(() => relayConfigNotifier), if (huddleMediaFactory != null) @@ -501,8 +516,552 @@ void main() { expect(presence.style?.fontSize, 14); expect(presence.style?.fontWeight, FontWeight.w400); expect(find.byTooltip('View members'), findsNothing); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + }); + + testWidgets('hides the Huddle action in a one-to-one agent DM', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message with an agent', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + users: const { + 'agent': UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('hides the Huddle action for a channel bot DM', (tester) async { + final dmChannel = Channel( + id: _channelId, + name: 'Bot DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message with a channel bot', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Bot'], + participantPubkeys: const ['self', 'bot'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadChannelBotPubkeys: () async => const {'bot'}, + users: const {'bot': UserProfile(pubkey: 'bot', displayName: 'Bot')}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps the Huddle action hidden while agent identity loads', ( + tester, + ) async { + final directoryCompleter = Completer>(); + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadAgentDirectory: () => directoryCompleter.future, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pump(); + + expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); + expect(find.byTooltip('Start Huddle'), findsNothing); + + directoryCompleter.complete(const []); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsOneWidget); + }); + + testWidgets('preloads DM participant profiles without a member snapshot', ( + tester, + ) async { + final preloadedPubkeys = []; + final userCache = _FakeUserCacheNotifier( + const {}, + preload: (pubkeys) async { + preloadedPubkeys.addAll(pubkeys); + return true; + }, + ); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + ), + ); + await tester.pumpAndSettle(); + + expect(preloadedPubkeys, containsAll(const ['self', 'alice'])); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + }); + + testWidgets('keeps Huddle hidden while a verified owner profile loads', ( + tester, + ) async { + final profilePreloadCompleter = Completer(); + final userCache = _FakeUserCacheNotifier( + const {}, + preload: (_) => profilePreloadCompleter.future, + ); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pump(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + + userCache.replace( + const UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + ); + profilePreloadCompleter.complete(true); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('rechecks verified owner profiles after reconnect', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final reconnectPreloadCompleter = Completer(); + var memberPreloadCount = 0; + var blockMemberPreload = false; + final userCache = _FakeUserCacheNotifier( + const {}, + preload: (pubkeys) { + if (pubkeys.length == 1) return Future.value(true); + memberPreloadCount++; + return blockMemberPreload + ? reconnectPreloadCompleter.future + : Future.value(true); + }, + ); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsOneWidget); + final memberPreloadsBeforeReconnect = memberPreloadCount; + blockMemberPreload = true; + + relaySession.disconnect(); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.connect(); + await tester.pump(); + expect(memberPreloadCount, greaterThan(memberPreloadsBeforeReconnect)); + expect(find.byTooltip('Start Huddle'), findsNothing); + + userCache.replace( + const UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + ); + reconnectPreloadCompleter.complete(true); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + await tester.pump(const Duration(milliseconds: 500)); + }); + + testWidgets('keeps directory-only agent Huddle hidden after disconnect', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + loadAgentDirectory: () async => const [ + AgentDirectoryEntry(pubkey: 'agent'), + ], + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.disconnect(); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); }); + testWidgets('keeps bot-role-only Huddle hidden after disconnect', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Bot DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Bot'], + participantPubkeys: const ['self', 'bot'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + loadChannelBotPubkeys: () async => const {'bot'}, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'bot', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.disconnect(); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps the Huddle action hidden when identity loading fails', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadAgentOwners: () => Future.error('identity unavailable'), + disableRetries: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps the Huddle action hidden when member preload fails', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadMembers: () => Future.error('members unavailable'), + disableRetries: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('hides Huddle when a participant becomes an agent live', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + var directoryLoadCount = 0; + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + loadAgentDirectory: () async { + directoryLoadCount++; + return directoryLoadCount == 1 + ? const [] + : const [AgentDirectoryEntry(pubkey: 'alice')]; + }, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(relaySession.identityFilter?.kinds, const [0, 10100]); + expect(relaySession.identityFilter?.authors, contains('alice')); + expect(relaySession.identityFilter?.limit, 100); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + + relaySession.emitAgentProfile(pubkey: 'alice'); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets( + 'keeps Huddle hidden if the live identity subscription closes', + (tester) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsOneWidget); + + relaySession.closeIdentitySubscription(); + await tester.pump(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }, + ); + testWidgets('keeps the Members action for group DMs', (tester) async { final dmChannel = Channel( id: _channelId, @@ -522,6 +1081,7 @@ void main() { _buildTestable( messages: const [], channel: dmChannel, + knownAgentPubkeys: const {'alice'}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -531,6 +1091,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip('View members'), findsOneWidget); + expect(find.byTooltip('Start Huddle'), findsOneWidget); }); testWidgets( @@ -12294,6 +12855,66 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { } } +class _IdentityUpdateRelaySession extends RelaySessionNotifier { + NostrFilter? identityFilter; + void Function(NostrEvent)? _identityListener; + void Function(String message)? _identityClosedListener; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + if (filter.kinds.contains(10100)) { + identityFilter = filter; + _identityListener = onEvent; + _identityClosedListener = onClosed; + } + return () { + if (identical(_identityListener, onEvent)) { + _identityListener = null; + _identityClosedListener = null; + } + }; + } + + void emitAgentProfile({required String pubkey}) { + _identityListener?.call( + NostrEvent( + id: 'agent-profile-$pubkey', + pubkey: pubkey, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + kind: 10100, + tags: const [], + content: '{"name":"Agent"}', + sig: 'sig', + ), + ); + } + + void closeIdentitySubscription() { + _identityClosedListener?.call('unsupported filter'); + } + + void disconnect() { + state = const SessionState(status: SessionStatus.disconnected); + } + + void connect() { + state = const SessionState(status: SessionStatus.connected); + } +} + class _HuddleReactionRelaySession extends RelaySessionNotifier { NostrFilter? reactionFilter; void Function(NostrEvent)? _reactionListener; @@ -12438,7 +13059,11 @@ class _FakeChannelMutesNotifier extends ChannelMutesNotifier { class _FakeUserCacheNotifier extends UserCacheNotifier { final Map _users; - _FakeUserCacheNotifier(this._users); + final Future Function(List)? _preload; + _FakeUserCacheNotifier( + this._users, { + Future Function(List)? preload, + }) : _preload = preload; @override Map build() => _users; @@ -12446,6 +13071,10 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { @override UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; + @override + Future preload(List pubkeys) => + _preload?.call(pubkeys) ?? Future.value(true); + void replace(UserProfile profile) { state = {...state, profile.pubkey.toLowerCase(): profile}; } diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 44bb450670b..1f3ef745ef1 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -90,7 +90,7 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { Map build() => _profiles; @override - Future preload(Iterable pubkeys) async {} + Future preload(List pubkeys) async => true; } void main() { diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart index f584aff0419..dc5ce8e2f0d 100644 --- a/mobile/test/shared/mentions/agent_identity_provider_test.dart +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -29,8 +29,8 @@ void main() { }); await relaySession.subscribed; expect(relaySession.liveFilters.single.kinds, const [39002]); - expect(relaySession.liveFilters.single.tags['#h'], [_channelId]); - expect(relaySession.liveFilters.single.tags['#d'], isNull); + expect(relaySession.liveFilters.single.tags['#d'], [_channelId]); + expect(relaySession.liveFilters.single.tags['#h'], isNull); relaySession.emit(_membershipEvent(role: 'member')); await _pumpEventQueue(); @@ -76,6 +76,58 @@ void main() { ); }); + test('surfaces bot-role subscription setup failure', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'member'), + ], subscribeError: StateError('subscription unavailable')); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembershipUpdateProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + await _pumpEventQueue(); + + final state = container.read(channelMembershipUpdateProvider(_channelId)); + expect(state.isReady, isFalse); + expect(state.error, isA()); + }); + + test('surfaces terminal bot-role subscription closure', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembershipUpdateProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + await relaySession.subscribed; + await _pumpEventQueue(); + expect( + container.read(channelMembershipUpdateProvider(_channelId)).isReady, + isTrue, + ); + + relaySession.closeSubscription('unsupported filter'); + await _pumpEventQueue(); + + final state = container.read(channelMembershipUpdateProvider(_channelId)); + expect(state.isReady, isFalse); + expect(state.error, isA()); + }); + test('disposes the live role subscription without consumers', () async { final relaySession = _MembershipRelaySessionNotifier([ _membershipEvent(role: 'bot'), @@ -162,13 +214,14 @@ Future _pumpEventQueue() async { class _MembershipRelaySessionNotifier extends RelaySessionNotifier { final List _memberships; + final Object? subscribeError; final List liveFilters = []; final List<_LiveSubscription> _subscriptions = []; final Completer _subscribed = Completer(); var unsubscribeCount = 0; var _membershipIndex = 0; - _MembershipRelaySessionNotifier(this._memberships); + _MembershipRelaySessionNotifier(this._memberships, {this.subscribeError}); Future get subscribed => _subscribed.future; @@ -189,8 +242,9 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, }) async { + if (subscribeError case final error?) throw error; liveFilters.add(filter); - final subscription = _LiveSubscription(filter, onEvent); + final subscription = _LiveSubscription(filter, onEvent, onClosed); _subscriptions.add(subscription); if (!_subscribed.isCompleted) _subscribed.complete(); return () { @@ -206,13 +260,20 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { } } } + + void closeSubscription(String message) { + for (final subscription in List.of(_subscriptions)) { + subscription.onClosed?.call(message); + } + } } class _LiveSubscription { final NostrFilter filter; final void Function(NostrEvent) onEvent; + final void Function(String message)? onClosed; - const _LiveSubscription(this.filter, this.onEvent); + const _LiveSubscription(this.filter, this.onEvent, this.onClosed); } bool _matches(NostrFilter filter, NostrEvent event) { diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart new file mode 100644 index 00000000000..c3f20af5e56 --- /dev/null +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -0,0 +1,32 @@ +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + test('preload reports a profile batch failure', () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(_FailingProfileSession.new), + ], + ); + addTearDown(container.dispose); + + final succeeded = await container.read(userCacheProvider.notifier).preload( + const ['agent'], + ); + + expect(succeeded, isFalse); + }); +} + +class _FailingProfileSession extends RelaySessionNotifier { + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) => Future.error('profile unavailable'); +}