From 6f729de6c03f65f013a44d92105605fe43352698 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 07:43:00 +0100 Subject: [PATCH 01/14] Hide Huddles in mobile agent DMs Signed-off-by: kenny lopez --- .../channels/channel_detail_page.dart | 27 +++++++++++- .../channels/channel_detail_page_test.dart | 41 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b195dc9eb81..3449807e7b1 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -146,6 +146,15 @@ int? _channelReadTimestamp({ return dateTimeToUnixSeconds(channel.lastMessageAt); } +bool _isOneToOneAgentDm(Channel channel, Set agentPubkeys) { + if (!channel.isDm) return false; + final participants = channel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet(); + return 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. @@ -260,6 +269,22 @@ class ChannelDetailPage extends HookConsumerWidget { !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 agentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider), + profileOwnedAgentPubkeys: profileOwnedAgentPubkeys, + ); + final showsHuddleAction = + showsComposer && !_isOneToOneAgentDm(resolvedChannel, agentPubkeys); final messagesNotifier = ref.read( channelMessagesProvider(channel.id).notifier, ); @@ -394,7 +419,7 @@ class ChannelDetailPage extends HookConsumerWidget { ), actions: resolvedChannel.isDm ? [ - if (showsComposer) + if (showsHuddleAction) _HuddleButton( channel: resolvedChannel, events: [ diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 4178dbc76a3..6a55b85b042 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -200,6 +200,7 @@ Widget _buildTestable({ required List messages, List typing = const [], Map users = const {}, + Set knownAgentPubkeys = const {}, _FakeUserCacheNotifier? userCacheNotifier, List members = const [], List huddleMembers = const [], @@ -290,6 +291,7 @@ Widget _buildTestable({ }, ), agentOwnersProvider.overrideWith((ref) async => const {}), + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), if (directoryUsers != null) relayDirectoryUsersProvider.overrideWith((ref) async => directoryUsers), if (createChannelActions != null) @@ -501,6 +503,43 @@ 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('keeps the Members action for group DMs', (tester) async { @@ -522,6 +561,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 +571,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip('View members'), findsOneWidget); + expect(find.byTooltip('Start Huddle'), findsOneWidget); }); testWidgets( From 8dc06559a354f808fded39cc36d33b9af5799fdb Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 09:00:17 +0100 Subject: [PATCH 02/14] Handle agent Huddle identity loading Signed-off-by: kenny lopez --- .../channels/channel_detail_page.dart | 35 ++++++-- .../channels/channel_detail_page_test.dart | 79 ++++++++++++++++++- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 3449807e7b1..cd88d0585db 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -147,12 +147,13 @@ int? _channelReadTimestamp({ } bool _isOneToOneAgentDm(Channel channel, Set agentPubkeys) { - if (!channel.isDm) return false; final participants = channel.participantPubkeys .map((pubkey) => pubkey.trim().toLowerCase()) .where((pubkey) => pubkey.isNotEmpty) .toSet(); - return participants.length == 2 && participants.any(agentPubkeys.contains); + return channel.isDm && + participants.length == 2 && + participants.any(agentPubkeys.contains); } /// Controls how a hydrated initial thread is added to the navigation stack. @@ -279,12 +280,34 @@ class ChannelDetailPage extends HookConsumerWidget { ); if (isProfileOwnedAgent) profileOwnedAgentPubkeys.add(normalized); } - final agentPubkeys = agentPubkeysWithProfileOwners( - knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider), - profileOwnedAgentPubkeys: profileOwnedAgentPubkeys, + final agentDirectoryState = ref.watch(agentDirectoryProvider); + final agentOwnersState = ref.watch(agentOwnersProvider); + final channelBotPubkeysState = ref.watch( + channelBotPubkeysProvider(resolvedChannel.id), ); + final agentPubkeys = agentPubkeysWithChannelBots( + knownAgentPubkeys: agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider), + profileOwnedAgentPubkeys: profileOwnedAgentPubkeys, + ), + channelBotPubkeys: + channelBotPubkeysState.asData?.value ?? const {}, + ); + final participantCount = resolvedChannel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .length; + final isOneToOneDm = resolvedChannel.isDm && participantCount == 2; + final isAgentIdentityLoading = + isOneToOneDm && + (agentDirectoryState.isLoading || + agentOwnersState.isLoading || + channelBotPubkeysState.isLoading); final showsHuddleAction = - showsComposer && !_isOneToOneAgentDm(resolvedChannel, agentPubkeys); + showsComposer && + !isAgentIdentityLoading && + !_isOneToOneAgentDm(resolvedChannel, agentPubkeys); final messagesNotifier = ref.read( channelMessagesProvider(channel.id).notifier, ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 6a55b85b042..b83440a5e91 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -201,6 +201,8 @@ Widget _buildTestable({ List typing = const [], Map users = const {}, Set knownAgentPubkeys = const {}, + Future> Function()? loadChannelBotPubkeys, + Future> Function()? loadAgentDirectory, _FakeUserCacheNotifier? userCacheNotifier, List members = const [], List huddleMembers = const [], @@ -281,9 +283,9 @@ 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) @@ -291,6 +293,9 @@ Widget _buildTestable({ }, ), agentOwnersProvider.overrideWith((ref) async => const {}), + agentDirectoryProvider.overrideWith( + (ref) async => loadAgentDirectory?.call() ?? const [], + ), knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), if (directoryUsers != null) relayDirectoryUsersProvider.overrideWith((ref) async => directoryUsers), @@ -542,6 +547,74 @@ void main() { 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('keeps the Members action for group DMs', (tester) async { final dmChannel = Channel( id: _channelId, From 61918ccf783faccedce2065bab2102ba27d6152d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 24 Aug 2026 13:36:59 +0100 Subject: [PATCH 03/14] Wait for agent profile identity Signed-off-by: kenny lopez --- .../channels/channel_detail_page.dart | 16 ++--- .../shared/profile/user_cache_provider.dart | 18 ++++- .../channels/channel_detail_page_test.dart | 68 ++++++++++++++++++- 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index cd88d0585db..49434db0b23 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -114,7 +114,7 @@ Future _preloadMembers(WidgetRef ref, String channelId) async { final members = await ref.read(channelMembersProvider(channelId).future); final pubkeys = members.map((m) => m.pubkey).toList(); if (pubkeys.isNotEmpty) { - notifier.preload(pubkeys); + await notifier.preload(pubkeys); } } catch (_) { // Non-fatal — mentions will just fall back to cache from messages. @@ -266,6 +266,11 @@ class ChannelDetailPage extends HookConsumerWidget { channel; final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; + final memberProfilesPreload = useMemoized( + () => _preloadMembers(ref, resolvedChannel.id), + [resolvedChannel.id], + ); + final memberProfilesPreloadState = useFuture(memberProfilesPreload); final showsComposer = !resolvedChannel.isForum && resolvedChannel.isMember && @@ -303,7 +308,8 @@ class ChannelDetailPage extends HookConsumerWidget { isOneToOneDm && (agentDirectoryState.isLoading || agentOwnersState.isLoading || - channelBotPubkeysState.isLoading); + channelBotPubkeysState.isLoading || + memberProfilesPreloadState.connectionState != ConnectionState.done); final showsHuddleAction = showsComposer && !isAgentIdentityLoading && @@ -349,12 +355,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; diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index c04bf952ae1..99018fe8603 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(); + _batchCompleter = null; }); return {}; } @@ -34,14 +37,18 @@ class UserCacheNotifier extends Notifier> { } /// Preload profiles for a list of pubkeys (e.g. channel members). - void preload(List pubkeys) { - final uncached = pubkeys + 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(); _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 +64,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,6 +74,8 @@ class UserCacheNotifier extends Notifier> { final pubkeys = _pending.toList(); _pending.clear(); + final completer = _batchCompleter; + _batchCompleter = null; try { final session = ref.read(relaySessionProvider.notifier); @@ -82,6 +92,8 @@ class UserCacheNotifier extends Notifier> { state = updated; } catch (_) { // Silently fail — we'll just show pubkeys. + } finally { + completer?.complete(); } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index b83440a5e91..353367c7648 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -615,6 +615,64 @@ void main() { 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(); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + testWidgets('keeps the Members action for group DMs', (tester) async { final dmChannel = Channel( id: _channelId, @@ -12552,7 +12610,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; @@ -12560,6 +12622,10 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { @override UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; + @override + Future preload(List pubkeys) => + _preload?.call(pubkeys) ?? Future.value(); + void replace(UserProfile profile) { state = {...state, profile.pubkey.toLowerCase(): profile}; } From 91bc8b5ab702d7f4205ceabeb8dd3ef7caf7a051 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 14:14:40 +0100 Subject: [PATCH 04/14] Refresh mobile agent identity gates Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 2 +- .../mentions/agent_identity_provider.dart | 2 +- .../channels/channel_detail_page_test.dart | 77 +++++++++++++++++++ .../agent_identity_provider_test.dart | 4 +- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 49434db0b23..53c663255f2 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -268,7 +268,7 @@ class ChannelDetailPage extends HookConsumerWidget { detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; final memberProfilesPreload = useMemoized( () => _preloadMembers(ref, resolvedChannel.id), - [resolvedChannel.id], + [resolvedChannel.id, sessionStatus], ); final memberProfilesPreloadState = useFuture(memberProfilesPreload); final showsComposer = diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index ea6a2ee50fd..4263ac2b8e7 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -193,7 +193,7 @@ class _ChannelBotRoleSubscription extends Notifier { NostrFilter( kinds: const [39002], tags: { - '#h': [channelId], + '#d': [channelId], }, ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000), (_) { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 353367c7648..32da4f8f2ca 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -673,6 +673,83 @@ void main() { expect(find.byTooltip('Start Huddle'), findsNothing); }); + testWidgets('rechecks verified owner profiles after reconnect', ( + tester, + ) async { + final relaySession = _ReconnectingRelaySession(); + final reconnectPreloadCompleter = Completer(); + var memberPreloadCount = 0; + var blockMemberPreload = false; + final userCache = _FakeUserCacheNotifier( + const {}, + preload: (pubkeys) { + if (pubkeys.length == 1) return Future.value(); + memberPreloadCount++; + return blockMemberPreload + ? reconnectPreloadCompleter.future + : Future.value(); + }, + ); + 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.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(); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + await tester.pump(const Duration(milliseconds: 500)); + }); + testWidgets('keeps the Members action for group DMs', (tester) async { final dmChannel = Channel( id: _channelId, diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart index f584aff0419..cd1ab0c5534 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(); From e2130c4c7de43c1a4a4c33395f2725a7cf262f36 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 14:31:02 +0100 Subject: [PATCH 05/14] Fail closed on mobile identity errors Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 18 +++-- .../channels/channel_detail_page_test.dart | 65 ++++++++++++++++++- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 53c663255f2..ae2da0df12d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -107,7 +107,8 @@ 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) async { // Capture references before async gap to avoid using disposed ref. final notifier = ref.read(userCacheProvider.notifier); try { @@ -116,8 +117,10 @@ Future _preloadMembers(WidgetRef ref, String channelId) async { if (pubkeys.isNotEmpty) { await 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; } } @@ -304,15 +307,20 @@ class ChannelDetailPage extends HookConsumerWidget { .toSet() .length; final isOneToOneDm = resolvedChannel.isDm && participantCount == 2; - final isAgentIdentityLoading = + final isAgentIdentityUnresolved = isOneToOneDm && (agentDirectoryState.isLoading || + agentDirectoryState.hasError || agentOwnersState.isLoading || + agentOwnersState.hasError || channelBotPubkeysState.isLoading || - memberProfilesPreloadState.connectionState != ConnectionState.done); + channelBotPubkeysState.hasError || + memberProfilesPreloadState.connectionState != + ConnectionState.done || + memberProfilesPreloadState.data != true); final showsHuddleAction = showsComposer && - !isAgentIdentityLoading && + !isAgentIdentityUnresolved && !_isOneToOneAgentDm(resolvedChannel, agentPubkeys); final messagesNotifier = ref.read( channelMessagesProvider(channel.id).notifier, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 32da4f8f2ca..b9fb0c1b159 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -203,6 +203,7 @@ Widget _buildTestable({ Set knownAgentPubkeys = const {}, Future> Function()? loadChannelBotPubkeys, Future> Function()? loadAgentDirectory, + Future> Function()? loadAgentOwners, _FakeUserCacheNotifier? userCacheNotifier, List members = const [], List huddleMembers = const [], @@ -292,7 +293,9 @@ Widget _buildTestable({ 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 [], ), @@ -750,6 +753,66 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); }); + 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('keeps the Members action for group DMs', (tester) async { final dmChannel = Channel( id: _channelId, From 7ffb04bd8a3789dc8f75efd97f573fa9644d14e7 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 14:40:49 +0100 Subject: [PATCH 06/14] Report mobile profile preload failures Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 2 +- .../shared/profile/user_cache_provider.dart | 19 ++++++----- .../channels/channel_detail_page_test.dart | 20 ++++++------ .../features/channels/reaction_row_test.dart | 2 +- .../profile/user_cache_provider_test.dart | 32 +++++++++++++++++++ 5 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 mobile/test/shared/profile/user_cache_provider_test.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index ae2da0df12d..5724e84bf04 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -115,7 +115,7 @@ Future _preloadMembers(WidgetRef ref, String channelId) async { final members = await ref.read(channelMembersProvider(channelId).future); final pubkeys = members.map((m) => m.pubkey).toList(); if (pubkeys.isNotEmpty) { - await notifier.preload(pubkeys); + return notifier.preload(pubkeys); } return true; } catch (_) { diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 99018fe8603..7c213f936f0 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -13,7 +13,7 @@ import 'user_profile.dart'; class UserCacheNotifier extends Notifier> { final Set _pending = {}; Timer? _batchTimer; - Completer? _batchCompleter; + Completer? _batchCompleter; @override Map build() { @@ -21,7 +21,7 @@ class UserCacheNotifier extends Notifier> { ref.onDispose(() { _batchTimer?.cancel(); _batchTimer = null; - _batchCompleter?.complete(); + _batchCompleter?.complete(false); _batchCompleter = null; }); return {}; @@ -37,16 +37,17 @@ class UserCacheNotifier extends Notifier> { } /// Preload profiles for a list of pubkeys (e.g. channel members). - Future preload(List 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 && !alreadyPending) return Future.value(); + if (uncached.isEmpty && !alreadyPending) return Future.value(true); _pending.addAll(uncached); - final completer = _batchCompleter ??= Completer(); + final completer = _batchCompleter ??= Completer(); _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); return completer.future; } @@ -64,7 +65,7 @@ class UserCacheNotifier extends Notifier> { void _scheduleFetch(String pubkey) { if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; _pending.add(pubkey); - _batchCompleter ??= Completer(); + _batchCompleter ??= Completer(); _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); } @@ -77,6 +78,7 @@ class UserCacheNotifier extends Notifier> { final completer = _batchCompleter; _batchCompleter = null; + var succeeded = false; try { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( @@ -90,10 +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(); + 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 b9fb0c1b159..d8914749468 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -621,7 +621,7 @@ void main() { testWidgets('keeps Huddle hidden while a verified owner profile loads', ( tester, ) async { - final profilePreloadCompleter = Completer(); + final profilePreloadCompleter = Completer(); final userCache = _FakeUserCacheNotifier( const {}, preload: (_) => profilePreloadCompleter.future, @@ -670,7 +670,7 @@ void main() { ownerPubkey: 'owner', ), ); - profilePreloadCompleter.complete(); + profilePreloadCompleter.complete(true); await tester.pumpAndSettle(); expect(find.byTooltip('Start Huddle'), findsNothing); @@ -680,17 +680,17 @@ void main() { tester, ) async { final relaySession = _ReconnectingRelaySession(); - final reconnectPreloadCompleter = Completer(); + final reconnectPreloadCompleter = Completer(); var memberPreloadCount = 0; var blockMemberPreload = false; final userCache = _FakeUserCacheNotifier( const {}, preload: (pubkeys) { - if (pubkeys.length == 1) return Future.value(); + if (pubkeys.length == 1) return Future.value(true); memberPreloadCount++; return blockMemberPreload ? reconnectPreloadCompleter.future - : Future.value(); + : Future.value(true); }, ); final dmChannel = Channel( @@ -746,7 +746,7 @@ void main() { ownerPubkey: 'owner', ), ); - reconnectPreloadCompleter.complete(); + reconnectPreloadCompleter.complete(true); await tester.pumpAndSettle(); expect(find.byTooltip('Start Huddle'), findsNothing); @@ -12750,10 +12750,10 @@ class _FakeChannelMutesNotifier extends ChannelMutesNotifier { class _FakeUserCacheNotifier extends UserCacheNotifier { final Map _users; - final Future Function(List)? _preload; + final Future Function(List)? _preload; _FakeUserCacheNotifier( this._users, { - Future Function(List)? preload, + Future Function(List)? preload, }) : _preload = preload; @override @@ -12763,8 +12763,8 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; @override - Future preload(List pubkeys) => - _preload?.call(pubkeys) ?? Future.value(); + 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/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'); +} From 2f02577ddac43ee43a61c3c766e9ef101b7b3700 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 15:03:13 +0100 Subject: [PATCH 07/14] Refresh open DM agent identity live Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 88 +++++++++- .../channels/channel_detail_page_test.dart | 161 +++++++++++++++++- 2 files changed, 246 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 5724e84bf04..6a31b45212c 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -124,6 +124,35 @@ Future _preloadMembers(WidgetRef ref, String channelId) async { } } +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: 0, + ).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, @@ -307,9 +336,66 @@ class ChannelDetailPage extends HookConsumerWidget { .toSet() .length; final isOneToOneDm = resolvedChannel.isDm && participantCount == 2; + 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 && - (agentDirectoryState.isLoading || + ((sessionStatus == SessionStatus.connected && + !isIdentitySubscriptionReady) || + agentDirectoryState.isLoading || agentDirectoryState.hasError || agentOwnersState.isLoading || agentOwnersState.hasError || diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index d8914749468..0f297113cfc 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -200,7 +200,7 @@ Widget _buildTestable({ required List messages, List typing = const [], Map users = const {}, - Set knownAgentPubkeys = const {}, + Set? knownAgentPubkeys, Future> Function()? loadChannelBotPubkeys, Future> Function()? loadAgentDirectory, Future> Function()? loadAgentOwners, @@ -299,7 +299,8 @@ Widget _buildTestable({ agentDirectoryProvider.overrideWith( (ref) async => loadAgentDirectory?.call() ?? const [], ), - knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), + if (knownAgentPubkeys != null) + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), if (directoryUsers != null) relayDirectoryUsersProvider.overrideWith((ref) async => directoryUsers), if (createChannelActions != null) @@ -813,6 +814,110 @@ void main() { 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(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, @@ -12606,6 +12711,58 @@ 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'); + } +} + class _HuddleReactionRelaySession extends RelaySessionNotifier { NostrFilter? reactionFilter; void Function(NostrEvent)? _reactionListener; From 1149640e432223d62e0563416b4249eccd8bcde6 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 15:14:34 +0100 Subject: [PATCH 08/14] Close DM identity replay gaps Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 25 +++++++++--- .../channels/channel_detail_page_test.dart | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 6a31b45212c..8ba23c28533 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -108,12 +108,19 @@ Future _loadDeepLinkEvents( /// Fetch channel members and preload their profiles into the user cache. /// Returns whether identity resolution completed successfully. -Future _preloadMembers(WidgetRef ref, String channelId) async { +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) { return notifier.preload(pubkeys); } @@ -134,7 +141,7 @@ Future _subscribeToDmIdentityUpdates( NostrFilter( kinds: const [0, 10100], authors: participantPubkeys, - limit: 0, + limit: 100, ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5), (event) { if (event.kind == 0) { @@ -299,8 +306,16 @@ class ChannelDetailPage extends HookConsumerWidget { final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; final memberProfilesPreload = useMemoized( - () => _preloadMembers(ref, resolvedChannel.id), - [resolvedChannel.id, sessionStatus], + () => _preloadMembers( + ref, + resolvedChannel.id, + resolvedChannel.participantPubkeys, + ), + [ + resolvedChannel.id, + sessionStatus, + Object.hashAll(resolvedChannel.participantPubkeys), + ], ); final memberProfilesPreloadState = useFuture(memberProfilesPreload); final showsComposer = diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 0f297113cfc..7e7eeb561aa 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -619,6 +619,44 @@ void main() { 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 { @@ -862,6 +900,7 @@ void main() { 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'); From 8841a57fa4d89064dbafad294330195f1c242cac Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 18:41:45 +0100 Subject: [PATCH 09/14] Fail closed across mobile identity lifecycle Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 21 +-- .../channels/channel_management_provider.dart | 6 +- .../mentions/agent_identity_provider.dart | 53 +++++++- .../channels/channel_detail_page_test.dart | 121 +++++++++++++++++- .../agent_identity_provider_test.dart | 67 +++++++++- 5 files changed, 245 insertions(+), 23 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8ba23c28533..b9a1587cdff 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -334,6 +334,15 @@ class ChannelDetailPage extends HookConsumerWidget { } 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), ); @@ -345,12 +354,6 @@ class ChannelDetailPage extends HookConsumerWidget { channelBotPubkeys: channelBotPubkeysState.asData?.value ?? const {}, ); - final participantCount = resolvedChannel.participantPubkeys - .map((pubkey) => pubkey.trim().toLowerCase()) - .where((pubkey) => pubkey.isNotEmpty) - .toSet() - .length; - final isOneToOneDm = resolvedChannel.isDm && participantCount == 2; final identitySubscriptionPubkeys = isOneToOneDm ? (resolvedChannel.participantPubkeys .map((pubkey) => pubkey.trim().toLowerCase()) @@ -408,12 +411,14 @@ class ChannelDetailPage extends HookConsumerWidget { }, [sessionStatus, resolvedChannel.id, identitySubscriptionKey]); final isAgentIdentityUnresolved = isOneToOneDm && - ((sessionStatus == SessionStatus.connected && - !isIdentitySubscriptionReady) || + (sessionStatus != SessionStatus.connected || + !isIdentitySubscriptionReady || agentDirectoryState.isLoading || agentDirectoryState.hasError || agentOwnersState.isLoading || agentOwnersState.hasError || + !channelMembershipUpdateState.isReady || + channelMembershipUpdateState.error != null || channelBotPubkeysState.isLoading || channelBotPubkeysState.hasError || memberProfilesPreloadState.connectionState != 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 4263ac2b8e7..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 { @@ -198,7 +214,18 @@ class _ChannelBotRoleSubscription extends Notifier { ).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/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7e7eeb561aa..d42ebd525b0 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -338,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) @@ -718,7 +722,7 @@ void main() { testWidgets('rechecks verified owner profiles after reconnect', ( tester, ) async { - final relaySession = _ReconnectingRelaySession(); + final relaySession = _IdentityUpdateRelaySession(); final reconnectPreloadCompleter = Completer(); var memberPreloadCount = 0; var blockMemberPreload = false; @@ -772,9 +776,12 @@ void main() { final memberPreloadsBeforeReconnect = memberPreloadCount; blockMemberPreload = true; - relaySession.connect(); + 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); @@ -792,6 +799,104 @@ void main() { 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 { @@ -12800,6 +12905,14 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { 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 { diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart index cd1ab0c5534..dc5ce8e2f0d 100644 --- a/mobile/test/shared/mentions/agent_identity_provider_test.dart +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -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) { From b704ab59fa45142ee741c8e5ba751db8b150f847 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 19:24:11 +0100 Subject: [PATCH 10/14] Fail closed during membership subscription retries Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../mentions/agent_identity_provider.dart | 13 ++- mobile/lib/shared/relay/relay_session.dart | 86 ++++++------------- .../lib/shared/relay/relay_session_auth.dart | 33 +++++++ .../lib/shared/relay/relay_session_types.dart | 25 ++++++ .../channels/channel_detail_page_test.dart | 11 +++ .../agent_identity_provider_test.dart | 63 +++++++++++++- .../test/shared/relay/relay_session_test.dart | 39 +++++++++ 7 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 mobile/lib/shared/relay/relay_session_auth.dart create mode 100644 mobile/lib/shared/relay/relay_session_types.dart diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index cf51245d134..401f92b0762 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -204,8 +204,9 @@ class _ChannelBotRoleSubscription Future _subscribe(String channelId, int subscriptionVersion) async { final session = ref.read(relaySessionProvider.notifier); + var subscriptionStatus = RelaySubscriptionStatus.retrying; try { - final unsubscribe = await session.subscribe( + final unsubscribe = await session.subscribeWithStatus( NostrFilter( kinds: const [39002], tags: { @@ -228,6 +229,14 @@ class _ChannelBotRoleSubscription ); } }, + onStatusChanged: (status) { + subscriptionStatus = status; + if (!_isCurrent(subscriptionVersion)) return; + state = ChannelMembershipUpdateState( + version: state.version, + isReady: status == RelaySubscriptionStatus.ready, + ); + }, ); if (!_isCurrent(subscriptionVersion)) { unsubscribe(); @@ -236,7 +245,7 @@ class _ChannelBotRoleSubscription _unsubscribe = unsubscribe; state = ChannelMembershipUpdateState( version: state.version, - isReady: true, + isReady: subscriptionStatus == RelaySubscriptionStatus.ready, ); } catch (error) { if (_isCurrent(subscriptionVersion)) { diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 77e10e66ede..f4324083bf6 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -17,17 +17,12 @@ import 'relay_closed_policy.dart'; import 'relay_http_query_client.dart'; import 'relay_provider.dart'; import 'relay_rate_limit_gate.dart'; +import 'relay_session_types.dart'; import 'relay_socket.dart'; -enum SessionStatus { disconnected, connecting, connected, reconnecting } +export 'relay_session_types.dart'; -@immutable -class SessionState { - final SessionStatus status; - final int reconnectAttempt; - - const SessionState({required this.status, this.reconnectAttempt = 0}); -} +part 'relay_session_auth.dart'; class _HistorySubscription { final List events = []; @@ -41,6 +36,7 @@ class _LiveSubscription { final NostrFilter filter; final void Function(NostrEvent) onEvent; final void Function(String message)? onClosed; + final void Function(RelaySubscriptionStatus status)? onStatusChanged; Completer? readyCompleter; int? lastSeenCreatedAt; int closedRetryAttempt = 0; @@ -50,6 +46,7 @@ class _LiveSubscription { required this.filter, required this.onEvent, this.onClosed, + this.onStatusChanged, this.readyCompleter, }); } @@ -57,7 +54,6 @@ class _LiveSubscription { class _ClosedRetry { final _LiveSubscription subscription; final int generation; - _ClosedRetry({required this.subscription, required this.generation}); } @@ -75,16 +71,6 @@ class _BufferedEvent { _BufferedEvent(this.subId, this.event); } -/// Manages websocket subscriptions, batching, reconnection, and pending events. -typedef RelaySocketFactory = - RelaySocket Function({ - required String wsUrl, - required String? nsec, - required void Function(List message) onMessage, - required void Function() onConnected, - required void Function(Object? error) onDisconnected, - }); - class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, @@ -257,13 +243,29 @@ class RelaySessionNotifier extends Notifier { return completer.future; } - /// Subscribe to live events matching [filter]. Returns an unsubscribe - /// function. Live subscriptions survive reconnects — they are replayed with - /// `since: lastSeenCreatedAt - 5s` on reconnect. Future subscribe( NostrFilter filter, void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, + }) => _subscribe(filter, onEvent, onClosed: onClosed); + + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) => _subscribe( + filter, + onEvent, + onClosed: onClosed, + onStatusChanged: onStatusChanged, + ); + + Future _subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + void Function(RelaySubscriptionStatus status)? onStatusChanged, }) async { if (_disposed) throw StateError('Relay session is disposed'); final subId = _nextSubId('l'); @@ -273,12 +275,12 @@ class RelaySessionNotifier extends Notifier { filter: filter, onEvent: onEvent, onClosed: onClosed, + onStatusChanged: onStatusChanged, readyCompleter: readyCompleter, ); _sendReq(subId, filter); - // Wait for EOSE or a short fallback timeout. try { await readyCompleter.future.timeout( const Duration(milliseconds: 500), @@ -297,7 +299,6 @@ class RelaySessionNotifier extends Notifier { return () => _unsubscribe(subId); } - /// Publish an event and wait for the relay's OK confirmation. Future publish( NostrEvent event, { Duration timeout = const Duration(seconds: 8), @@ -324,8 +325,6 @@ class RelaySessionNotifier extends Notifier { return completer.future; } - /// Send a raw message over the WebSocket without waiting for acknowledgement. - /// Used for ephemeral events like typing indicators. void sendRaw(List payload) { _socket?.send(payload); } @@ -639,6 +638,7 @@ class RelaySessionNotifier extends Notifier { final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { _resetClosedRetry(liveSub); + liveSub.onStatusChanged?.call(RelaySubscriptionStatus.ready); // Track last seen timestamp for reconnect replay. if (liveSub.lastSeenCreatedAt == null || event.createdAt > liveSub.lastSeenCreatedAt!) { @@ -668,6 +668,7 @@ class RelaySessionNotifier extends Notifier { final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { _resetClosedRetry(liveSub); + liveSub.onStatusChanged?.call(RelaySubscriptionStatus.ready); } if (liveSub != null && liveSub.readyCompleter != null && @@ -717,6 +718,7 @@ class RelaySessionNotifier extends Notifier { readyCompleter.complete(); liveSub.readyCompleter = null; } + liveSub.onStatusChanged?.call(RelaySubscriptionStatus.retrying); if (liveSub.closedRetryTimer != null) return; final attempt = liveSub.closedRetryAttempt; @@ -965,35 +967,3 @@ final relaySessionProvider = NotifierProvider( RelaySessionNotifier.new, ); - -String buildNip98AuthHeader({ - required String method, - required String url, - required List bodyBytes, - required String? nsec, -}) { - if (nsec == null || nsec.isEmpty) { - throw Exception('Cannot query relay: no signing key available'); - } - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) { - throw Exception('Invalid nsec'); - } - final payloadHash = SHA256Digest() - .process(Uint8List.fromList(bodyBytes)) - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - final event = nostr.Event.from( - kind: 27235, - content: '', - tags: [ - ['u', url], - ['method', method.toUpperCase()], - ['payload', payloadHash], - ['nonce', const Uuid().v4()], - ], - secretKey: privkeyHex, - verify: false, - ); - return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; -} diff --git a/mobile/lib/shared/relay/relay_session_auth.dart b/mobile/lib/shared/relay/relay_session_auth.dart new file mode 100644 index 00000000000..9c6c79ae02d --- /dev/null +++ b/mobile/lib/shared/relay/relay_session_auth.dart @@ -0,0 +1,33 @@ +part of 'relay_session.dart'; + +String buildNip98AuthHeader({ + required String method, + required String url, + required List bodyBytes, + required String? nsec, +}) { + if (nsec == null || nsec.isEmpty) { + throw Exception('Cannot query relay: no signing key available'); + } + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + if (privkeyHex.isEmpty) { + throw Exception('Invalid nsec'); + } + final payloadHash = SHA256Digest() + .process(Uint8List.fromList(bodyBytes)) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final event = nostr.Event.from( + kind: 27235, + content: '', + tags: [ + ['u', url], + ['method', method.toUpperCase()], + ['payload', payloadHash], + ['nonce', const Uuid().v4()], + ], + secretKey: privkeyHex, + verify: false, + ); + return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; +} diff --git a/mobile/lib/shared/relay/relay_session_types.dart b/mobile/lib/shared/relay/relay_session_types.dart new file mode 100644 index 00000000000..fe3ac8c5821 --- /dev/null +++ b/mobile/lib/shared/relay/relay_session_types.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'relay_socket.dart'; + +enum SessionStatus { disconnected, connecting, connected, reconnecting } + +typedef RelaySocketFactory = + RelaySocket Function({ + required String wsUrl, + required String? nsec, + required void Function(List message) onMessage, + required void Function() onConnected, + required void Function(Object? error) onDisconnected, + }); + +@immutable +class SessionState { + final SessionStatus status; + final int reconnectAttempt; + + const SessionState({required this.status, this.reconnectAttempt = 0}); +} + +/// Recovery lifecycle for a live relay subscription. +enum RelaySubscriptionStatus { ready, retrying } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index d42ebd525b0..b44320dcba4 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -12869,6 +12869,17 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async => const []; + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) async { + onStatusChanged(RelaySubscriptionStatus.ready); + return () {}; + } + @override Future subscribe( NostrFilter filter, diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart index dc5ce8e2f0d..c5355f26a43 100644 --- a/mobile/test/shared/mentions/agent_identity_provider_test.dart +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -128,6 +128,44 @@ void main() { expect(state.error, isA()); }); + test('fails closed while bot-role subscription retries', () 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.setSubscriptionStatus(RelaySubscriptionStatus.retrying); + await _pumpEventQueue(); + expect( + container.read(channelMembershipUpdateProvider(_channelId)).isReady, + isFalse, + ); + + relaySession.setSubscriptionStatus(RelaySubscriptionStatus.ready); + await _pumpEventQueue(); + final recovered = container.read( + channelMembershipUpdateProvider(_channelId), + ); + expect(recovered.isReady, isTrue); + expect(recovered.error, isNull); + }); + test('disposes the live role subscription without consumers', () async { final relaySession = _MembershipRelaySessionNotifier([ _membershipEvent(role: 'bot'), @@ -237,15 +275,22 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { } @override - Future subscribe( + Future subscribeWithStatus( NostrFilter filter, void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, }) async { if (subscribeError case final error?) throw error; liveFilters.add(filter); - final subscription = _LiveSubscription(filter, onEvent, onClosed); + final subscription = _LiveSubscription( + filter, + onEvent, + onClosed, + onStatusChanged, + ); _subscriptions.add(subscription); + onStatusChanged(RelaySubscriptionStatus.ready); if (!_subscribed.isCompleted) _subscribed.complete(); return () { unsubscribeCount++; @@ -266,14 +311,26 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { subscription.onClosed?.call(message); } } + + void setSubscriptionStatus(RelaySubscriptionStatus status) { + for (final subscription in List.of(_subscriptions)) { + subscription.onStatusChanged?.call(status); + } + } } class _LiveSubscription { final NostrFilter filter; final void Function(NostrEvent) onEvent; final void Function(String message)? onClosed; + final void Function(RelaySubscriptionStatus status)? onStatusChanged; - const _LiveSubscription(this.filter, this.onEvent, this.onClosed); + const _LiveSubscription( + this.filter, + this.onEvent, + this.onClosed, + this.onStatusChanged, + ); } bool _matches(NostrFilter filter, NostrEvent event) { diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 7332075c3f6..9651fac5c0f 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -749,6 +749,45 @@ void main() { }, ); + test('retryable CLOSED reports retrying until replay is ready', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final statuses = []; + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + + final subscribe = session.subscribeWithStatus( + _channelFilter, + (_) {}, + onStatusChanged: statuses.add, + ); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + expect(statuses, [RelaySubscriptionStatus.ready]); + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: relay overloaded']); + expect(statuses, [ + RelaySubscriptionStatus.ready, + RelaySubscriptionStatus.retrying, + ]); + + timers.single.fire(); + await Future.delayed(Duration.zero); + session.debugHandleMessage(['EOSE', 'l-1']); + expect(statuses, [ + RelaySubscriptionStatus.ready, + RelaySubscriptionStatus.retrying, + RelaySubscriptionStatus.ready, + ]); + unsubscribe(); + }); + test('CLOSED retries back off and reset after EOSE', () async { final timers = <_ManualTimer>[]; final socket = _RecordingRelaySocket(); From ad69d06852a7509e422c3b85bc280926de57a4a2 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 20:03:22 +0100 Subject: [PATCH 11/14] Keep subscription replay fail closed through EOSE Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../mentions/agent_identity_provider.dart | 2 +- mobile/lib/shared/relay/relay_session.dart | 14 +- .../channels/channel_detail_page_test.dart | 121 +++++++++++++++++- .../test/shared/relay/relay_session_test.dart | 20 ++- 4 files changed, 141 insertions(+), 16 deletions(-) diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 401f92b0762..35c063d13d9 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -217,7 +217,7 @@ class _ChannelBotRoleSubscription if (_isCurrent(subscriptionVersion)) { state = ChannelMembershipUpdateState( version: state.version + 1, - isReady: true, + isReady: state.isReady, ); } }, diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index f4324083bf6..cff9cdc81a1 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -634,11 +634,11 @@ class RelaySessionNotifier extends Notifier { return; } - // Live subscriptions get batched. + // Live subscriptions get batched. An EVENT proves the stream is active, + // but not that a retry replay is complete; only EOSE is that boundary. final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { _resetClosedRetry(liveSub); - liveSub.onStatusChanged?.call(RelaySubscriptionStatus.ready); // Track last seen timestamp for reconnect replay. if (liveSub.lastSeenCreatedAt == null || event.createdAt > liveSub.lastSeenCreatedAt!) { @@ -664,20 +664,18 @@ class RelaySessionNotifier extends Notifier { return; } - // Live subscription: signal ready. + // Live subscription: flush replay callbacks before signaling ready. This + // ordering matters for retry replays, whose original ready completer has + // already been released. final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { _resetClosedRetry(liveSub); + _flushBufferedEventsNow(); liveSub.onStatusChanged?.call(RelaySubscriptionStatus.ready); } if (liveSub != null && liveSub.readyCompleter != null && !liveSub.readyCompleter!.isCompleted) { - // EOSE is the boundary between replay and live delivery. Flush any - // replay events before resolving subscribe(), so callers that begin a - // one-shot query immediately afterwards cannot classify a delayed batch - // callback as having arrived during that query. - _flushBufferedEventsNow(); liveSub.readyCompleter!.complete(); liveSub.readyCompleter = null; } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index b44320dcba4..86b15ef78ef 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -202,6 +202,7 @@ Widget _buildTestable({ Map users = const {}, Set? knownAgentPubkeys, Future> Function()? loadChannelBotPubkeys, + bool watchChannelMembershipUpdates = false, Future> Function()? loadAgentDirectory, Future> Function()? loadAgentOwners, _FakeUserCacheNotifier? userCacheNotifier, @@ -284,9 +285,10 @@ Widget _buildTestable({ ), if (huddleMembersNotifier != null) _mutableHuddleMembersProvider.overrideWith(() => huddleMembersNotifier), - channelBotPubkeysProvider(_channelId).overrideWith( - (ref) async => loadChannelBotPubkeys?.call() ?? const {}, - ), + if (!watchChannelMembershipUpdates) + channelBotPubkeysProvider(_channelId).overrideWith( + (ref) async => loadChannelBotPubkeys?.call() ?? const {}, + ), channelBotPubkeysProvider(_huddleChannelId).overrideWith( (ref) async => { for (final member in huddleMembers) @@ -897,6 +899,91 @@ void main() { expect(find.byTooltip('Start Huddle'), findsNothing); }); + testWidgets('keeps Huddle hidden until bot-role replay reaches EOSE', ( + 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, + watchChannelMembershipUpdates: true, + 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.beginMembershipReplay(); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitReplayedMembership( + NostrEvent( + id: 'membership-self', + pubkey: 'relay', + createdAt: 1, + kind: 39002, + tags: const [ + ['d', _channelId], + ['p', 'self'], + ], + content: '', + sig: 'sig', + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitReplayedMembership( + NostrEvent( + id: 'membership-bot', + pubkey: 'relay', + createdAt: 2, + kind: 39002, + tags: const [ + ['d', _channelId], + ['p', 'self'], + ['p', 'alice', '', 'bot'], + ], + content: '', + sig: 'sig', + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.finishMembershipReplay(); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + testWidgets('keeps the Huddle action hidden when identity loading fails', ( tester, ) async { @@ -12859,6 +12946,9 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { NostrFilter? identityFilter; void Function(NostrEvent)? _identityListener; void Function(String message)? _identityClosedListener; + void Function(NostrEvent)? _membershipListener; + void Function(RelaySubscriptionStatus status)? _membershipStatusListener; + NostrEvent? membershipSnapshot; @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -12867,7 +12957,7 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => const []; + }) async => membershipSnapshot == null ? const [] : [membershipSnapshot!]; @override Future subscribeWithStatus( @@ -12876,8 +12966,15 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { void Function(String message)? onClosed, required void Function(RelaySubscriptionStatus status) onStatusChanged, }) async { + _membershipListener = onEvent; + _membershipStatusListener = onStatusChanged; onStatusChanged(RelaySubscriptionStatus.ready); - return () {}; + return () { + if (identical(_membershipListener, onEvent)) { + _membershipListener = null; + _membershipStatusListener = null; + } + }; } @override @@ -12917,6 +13014,20 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { _identityClosedListener?.call('unsupported filter'); } + void beginMembershipReplay() { + _membershipStatusListener?.call(RelaySubscriptionStatus.retrying); + } + + void emitReplayedMembership(NostrEvent event) { + membershipSnapshot = event; + _membershipListener?.call(event); + _membershipStatusListener?.call(RelaySubscriptionStatus.retrying); + } + + void finishMembershipReplay() { + _membershipStatusListener?.call(RelaySubscriptionStatus.ready); + } + void disconnect() { state = const SessionState(status: SessionStatus.disconnected); } diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 9651fac5c0f..c6ae6e9bd8a 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -752,6 +752,7 @@ void main() { test('retryable CLOSED reports retrying until replay is ready', () async { final timers = <_ManualTimer>[]; final socket = _RecordingRelaySocket(); + final deliveredEvents = []; final statuses = []; final session = RelaySessionNotifier( retryTimerFactory: (duration, callback) { @@ -764,8 +765,13 @@ void main() { final subscribe = session.subscribeWithStatus( _channelFilter, - (_) {}, - onStatusChanged: statuses.add, + deliveredEvents.add, + onStatusChanged: (status) { + if (status == RelaySubscriptionStatus.ready) { + expect(deliveredEvents, hasLength(statuses.isEmpty ? 0 : 1)); + } + statuses.add(status); + }, ); session.debugHandleMessage(['EOSE', 'l-1']); final unsubscribe = await subscribe; @@ -779,6 +785,16 @@ void main() { timers.single.fire(); await Future.delayed(Duration.zero); + session.debugHandleMessage([ + 'EVENT', + 'l-1', + _event(createdAt: 30).toJson(), + ]); + expect(statuses, [ + RelaySubscriptionStatus.ready, + RelaySubscriptionStatus.retrying, + ]); + session.debugHandleMessage(['EOSE', 'l-1']); expect(statuses, [ RelaySubscriptionStatus.ready, From a5025dc5fc2805ad4e067b4062d6e35d3e66ab05 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 20:11:09 +0100 Subject: [PATCH 12/14] Close DM identity retry and directory gaps Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 104 +++++++--- .../channels/channel_detail_page_test.dart | 186 +++++++++++++++++- 2 files changed, 267 insertions(+), 23 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b9a1587cdff..a0bf53f3356 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -134,30 +134,74 @@ Future _preloadMembers( Future _subscribeToDmIdentityUpdates( WidgetRef ref, List participantPubkeys, { + required ValueChanged onReadyChanged, + required ValueChanged> onAgentPubkeysChanged, required VoidCallback onFailure, }) async { final session = ref.read(relaySessionProvider.notifier); - return session.subscribe( + var subscriptionStatus = RelaySubscriptionStatus.retrying; + var directLookupComplete = false; + final agentPubkeys = {}; + + void publishAgentPubkeys() { + onAgentPubkeysChanged(Set.unmodifiable(agentPubkeys)); + } + + void handleEvent(NostrEvent 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) { + agentPubkeys.add(event.pubkey.toLowerCase()); + publishAgentPubkeys(); + ref.invalidate(agentDirectoryProvider); + ref.invalidate(agentOwnersProvider); + } + } + + final unsubscribe = await session.subscribeWithStatus( 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); + handleEvent, + onClosed: (_) => onFailure(), + onStatusChanged: (status) { + subscriptionStatus = status; + if (status == RelaySubscriptionStatus.retrying) { + onReadyChanged(false); + } else if (directLookupComplete) { + onReadyChanged(true); } }, - onClosed: (_) => onFailure(), ); + + try { + final profiles = await session.fetchHistory( + NostrFilter( + kinds: const [10100], + authors: participantPubkeys, + limit: participantPubkeys.length, + ), + ); + for (final profile in profiles) { + if (profile.kind == 10100) { + agentPubkeys.add(profile.pubkey.toLowerCase()); + } + } + publishAgentPubkeys(); + directLookupComplete = true; + onReadyChanged(subscriptionStatus == RelaySubscriptionStatus.ready); + return unsubscribe; + } catch (_) { + unsubscribe(); + rethrow; + } } int? _channelReadTimestamp({ @@ -346,14 +390,6 @@ class ChannelDetailPage extends HookConsumerWidget { 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()) @@ -368,9 +404,26 @@ class ChannelDetailPage extends HookConsumerWidget { resolvedChannel.id, identitySubscriptionKey, ]); + final directlyResolvedAgentPubkeys = useValueNotifier({}, [ + sessionStatus, + resolvedChannel.id, + identitySubscriptionKey, + ]); final isIdentitySubscriptionReady = useValueListenable( identitySubscriptionReady, ); + final directAgentPubkeys = useValueListenable(directlyResolvedAgentPubkeys); + final agentPubkeys = agentPubkeysWithChannelBots( + knownAgentPubkeys: agentPubkeysWithProfileOwners( + knownAgentPubkeys: { + ...ref.watch(knownAgentPubkeysProvider), + ...directAgentPubkeys, + }, + profileOwnedAgentPubkeys: profileOwnedAgentPubkeys, + ), + channelBotPubkeys: + channelBotPubkeysState.asData?.value ?? const {}, + ); useEffect(() { if (sessionStatus != SessionStatus.connected || identitySubscriptionPubkeys.isEmpty) { @@ -389,13 +442,20 @@ class ChannelDetailPage extends HookConsumerWidget { final cleanup = await _subscribeToDmIdentityUpdates( ref, identitySubscriptionPubkeys, + onReadyChanged: (isReady) { + if (!disposed && !subscriptionFailed) { + identitySubscriptionReady.value = isReady; + } + }, + onAgentPubkeysChanged: (pubkeys) { + if (!disposed) directlyResolvedAgentPubkeys.value = pubkeys; + }, onFailure: markFailed, ); if (disposed) { cleanup(); } else { unsubscribe = cleanup; - if (!subscriptionFailed) identitySubscriptionReady.value = true; } } catch (error) { if (!disposed) { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 86b15ef78ef..8d7cbdee3fc 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1101,6 +1101,159 @@ void main() { expect(find.byTooltip('Start Huddle'), findsNothing); }); + testWidgets('keeps Huddle hidden while identity replay retries', ( + 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.retryIdentitySubscription(); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitAgentProfile(pubkey: 'alice'); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.readyIdentitySubscription(); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('queries DM participants directly for agent identity', ( + 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(relaySession.directIdentityFilter?.kinds, const [10100]); + expect( + relaySession.directIdentityFilter?.authors, + containsAll(const ['self', 'alice']), + ); + expect(relaySession.directIdentityFilter?.limit, 2); + }); + + testWidgets('hides Huddle for an agent found by direct DM lookup', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession() + ..directIdentityProfiles = const [ + NostrEvent( + id: 'old-agent-profile', + pubkey: 'alice', + createdAt: 1, + kind: 10100, + tags: [], + content: '{"name":"Agent"}', + sig: 'sig', + ), + ]; + 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', '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'), findsNothing); + }); + testWidgets( 'keeps Huddle hidden if the live identity subscription closes', (tester) async { @@ -12944,8 +13097,11 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { class _IdentityUpdateRelaySession extends RelaySessionNotifier { NostrFilter? identityFilter; + NostrFilter? directIdentityFilter; + List directIdentityProfiles = const []; void Function(NostrEvent)? _identityListener; void Function(String message)? _identityClosedListener; + void Function(RelaySubscriptionStatus status)? _identityStatusListener; void Function(NostrEvent)? _membershipListener; void Function(RelaySubscriptionStatus status)? _membershipStatusListener; NostrEvent? membershipSnapshot; @@ -12957,7 +13113,13 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => membershipSnapshot == null ? const [] : [membershipSnapshot!]; + }) async { + if (filter.kinds.contains(10100) && filter.kinds.length == 1) { + directIdentityFilter = filter; + return directIdentityProfiles; + } + return membershipSnapshot == null ? const [] : [membershipSnapshot!]; + } @override Future subscribeWithStatus( @@ -12966,6 +13128,20 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { void Function(String message)? onClosed, required void Function(RelaySubscriptionStatus status) onStatusChanged, }) async { + if (filter.kinds.contains(10100)) { + identityFilter = filter; + _identityListener = onEvent; + _identityClosedListener = onClosed; + _identityStatusListener = onStatusChanged; + onStatusChanged(RelaySubscriptionStatus.ready); + return () { + if (identical(_identityListener, onEvent)) { + _identityListener = null; + _identityClosedListener = null; + _identityStatusListener = null; + } + }; + } _membershipListener = onEvent; _membershipStatusListener = onStatusChanged; onStatusChanged(RelaySubscriptionStatus.ready); @@ -13014,6 +13190,14 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { _identityClosedListener?.call('unsupported filter'); } + void retryIdentitySubscription() { + _identityStatusListener?.call(RelaySubscriptionStatus.retrying); + } + + void readyIdentitySubscription() { + _identityStatusListener?.call(RelaySubscriptionStatus.ready); + } + void beginMembershipReplay() { _membershipStatusListener?.call(RelaySubscriptionStatus.retrying); } From 8b9a126fe534dabdadd9dd426850212a30ee0ca7 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Mon, 24 Aug 2026 20:20:34 +0100 Subject: [PATCH 13/14] Refresh cached DM identity profiles Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 2 +- .../shared/profile/user_cache_provider.dart | 28 +++++++++++ mobile/lib/shared/relay/relay_session.dart | 9 ++++ .../channels/channel_detail_page_test.dart | 50 +++++++++++++++++++ .../profile/user_cache_provider_test.dart | 43 ++++++++++++++++ 5 files changed, 131 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a0bf53f3356..51988818aea 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -122,7 +122,7 @@ Future _preloadMembers( ...participantPubkeys, }.toList(); if (pubkeys.isNotEmpty) { - return notifier.preload(pubkeys); + return notifier.refresh(pubkeys); } return true; } catch (_) { diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 7c213f936f0..6b301fcccec 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -52,6 +52,34 @@ class UserCacheNotifier extends Notifier> { return completer.future; } + /// Force-refresh profiles for identity-sensitive gates. + /// + /// Unlike [preload], this fetches cached pubkeys too so stale human profiles + /// cannot be trusted after a verified agent-owner profile was published. + Future refresh(List pubkeys) async { + final normalized = pubkeys + .map((pubkey) => pubkey.toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .toList(); + if (normalized.isEmpty) return true; + try { + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.profilesBatch(normalized), + ); + final updated = Map.from(state); + for (final event in events) { + final profile = _profileFromEvent(event); + updated[profile.pubkey] = profile; + } + state = updated; + return true; + } catch (_) { + return false; + } + } + /// Applies a live kind:0 profile event to the cache. /// /// Surfaces that keep a participant-scoped profile subscription can use this diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index cff9cdc81a1..b8d13ee34a4 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -249,6 +249,15 @@ class RelaySessionNotifier extends Notifier { void Function(String message)? onClosed, }) => _subscribe(filter, onEvent, onClosed: onClosed); + /// Subscribe to a live stream and observe its recovery lifecycle. + /// + /// The returned future completes after initial EOSE (or the existing + /// fallback timeout) and yields a cleanup callback. [onStatusChanged] emits + /// [RelaySubscriptionStatus.ready] at each EOSE after buffered replay events + /// have been delivered, and [RelaySubscriptionStatus.retrying] immediately + /// when a retryable or rate-limited CLOSED begins backoff. Terminal CLOSED + /// invokes [onClosed] and removes the subscription instead of retrying it. + /// Calling the cleanup callback cancels pending retries and sends CLOSE. Future subscribeWithStatus( NostrFilter filter, void Function(NostrEvent) onEvent, { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 8d7cbdee3fc..b0bebe96733 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -663,6 +663,53 @@ void main() { expect(find.byTooltip('Start Huddle'), findsOneWidget); }); + testWidgets('force-refreshes cached profiles before enabling Huddle', ( + tester, + ) async { + late final _FakeUserCacheNotifier userCache; + userCache = _FakeUserCacheNotifier( + const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Cached Human'), + }, + preload: (_) async { + await Future.delayed(Duration.zero); + userCache.replace( + const UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + ); + return 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, + ), + ); + await tester.pumpAndSettle(); + + expect(userCache.state['agent']?.ownerPubkey, 'owner'); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + testWidgets('keeps Huddle hidden while a verified owner profile loads', ( tester, ) async { @@ -13381,6 +13428,9 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { Future preload(List pubkeys) => _preload?.call(pubkeys) ?? Future.value(true); + @override + Future refresh(List pubkeys) => preload(pubkeys); + void replace(UserProfile profile) { state = {...state, profile.pubkey.toLowerCase(): profile}; } diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index c3f20af5e56..f98d443461d 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -18,6 +18,49 @@ void main() { expect(succeeded, isFalse); }); + + test('refresh queries profiles that are already cached', () async { + final session = _RecordingProfileSession(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.cacheProfileEvent( + const NostrEvent( + id: 'cached-profile', + pubkey: 'agent', + createdAt: 1, + kind: 0, + tags: [], + content: '{"name":"Cached Human"}', + sig: 'sig', + ), + ); + + final succeeded = await cache.refresh(const ['AGENT']); + + expect(succeeded, isTrue); + expect(session.requestedFilter?.kinds, const [0]); + expect(session.requestedFilter?.authors, const ['agent']); + expect(session.requestedFilter?.limit, 1); + }); +} + +class _RecordingProfileSession extends RelaySessionNotifier { + NostrFilter? requestedFilter; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + requestedFilter = filter; + return const []; + } } class _FailingProfileSession extends RelaySessionNotifier { From 26e83d2077a4b617c18bc47701db97c31ba0d924 Mon Sep 17 00:00:00 2001 From: Princess Donut Date: Tue, 25 Aug 2026 07:36:14 +0100 Subject: [PATCH 14/14] Order concurrent profile cache updates Co-authored-by: Princess Donut Signed-off-by: Princess Donut --- .../channels/channel_detail_page.dart | 29 +-- .../shared/profile/user_cache_provider.dart | 45 ++++- .../channels/channel_detail_page_test.dart | 116 +++++++++++- .../profile/user_cache_provider_test.dart | 171 +++++++++++++++++- 4 files changed, 330 insertions(+), 31 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 51988818aea..a5493de11b8 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -107,22 +107,21 @@ Future _loadDeepLinkEvents( } /// Fetch channel members and preload their profiles into the user cache. +/// One-to-one DMs additionally refresh participant profiles for identity gates. /// Returns whether identity resolution completed successfully. Future _preloadMembers( WidgetRef ref, String channelId, - List participantPubkeys, -) async { + List participantPubkeys, { + required bool refreshDmParticipants, +}) 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((member) => member.pubkey), - ...participantPubkeys, - }.toList(); - if (pubkeys.isNotEmpty) { - return notifier.refresh(pubkeys); + await notifier.preload(members.map((member) => member.pubkey).toList()); + if (refreshDmParticipants) { + return notifier.refresh(participantPubkeys); } return true; } catch (_) { @@ -349,15 +348,23 @@ class ChannelDetailPage extends HookConsumerWidget { channel; final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; + final participantCount = resolvedChannel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .length; + final isOneToOneDm = resolvedChannel.isDm && participantCount == 2; final memberProfilesPreload = useMemoized( () => _preloadMembers( ref, resolvedChannel.id, resolvedChannel.participantPubkeys, + refreshDmParticipants: isOneToOneDm, ), [ resolvedChannel.id, sessionStatus, + isOneToOneDm, Object.hashAll(resolvedChannel.participantPubkeys), ], ); @@ -378,12 +385,6 @@ class ChannelDetailPage extends HookConsumerWidget { } 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); diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 6b301fcccec..3bf2ea0d4af 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -12,12 +12,14 @@ import 'user_profile.dart'; /// kind:0 batch query (NIP-01 `authors` filter) every 50ms. class UserCacheNotifier extends Notifier> { final Set _pending = {}; + final Map _profileEventOrders = {}; Timer? _batchTimer; Completer? _batchCompleter; @override Map build() { ref.watch(relayConfigProvider); + _profileEventOrders.clear(); ref.onDispose(() { _batchTimer?.cancel(); _batchTimer = null; @@ -69,10 +71,15 @@ class UserCacheNotifier extends Notifier> { NostrFilters.profilesBatch(normalized), ); final updated = Map.from(state); + final updatedOrders = Map.from( + _profileEventOrders, + ); for (final event in events) { - final profile = _profileFromEvent(event); - updated[profile.pubkey] = profile; + _cacheProfileEvent(event, updated, updatedOrders); } + _profileEventOrders + ..clear() + ..addAll(updatedOrders); state = updated; return true; } catch (_) { @@ -86,8 +93,8 @@ class UserCacheNotifier extends Notifier> { /// to update names and avatars without discarding the rest of the cache. void cacheProfileEvent(NostrEvent event) { if (event.kind != 0) return; - final profile = _profileFromEvent(event); - state = {...state, profile.pubkey: profile}; + final updated = Map.from(state); + if (_cacheProfileEvent(event, updated)) state = updated; } void _scheduleFetch(String pubkey) { @@ -114,11 +121,16 @@ class UserCacheNotifier extends Notifier> { ); final updated = Map.from(state); + final updatedOrders = Map.from( + _profileEventOrders, + ); for (final event in events) { - final profile = _profileFromEvent(event); - updated[profile.pubkey] = profile; + _cacheProfileEvent(event, updated, updatedOrders); } + _profileEventOrders + ..clear() + ..addAll(updatedOrders); state = updated; succeeded = true; } catch (_) { @@ -128,6 +140,27 @@ class UserCacheNotifier extends Notifier> { } } + bool _cacheProfileEvent( + NostrEvent event, + Map profiles, [ + Map? orders, + ]) { + if (event.kind != 0) return false; + final eventOrders = orders ?? _profileEventOrders; + final pubkey = event.pubkey.toLowerCase(); + final current = eventOrders[pubkey]; + final isNewer = + current == null || + event.createdAt > current.createdAt || + (event.createdAt == current.createdAt && + event.id.compareTo(current.eventId) < 0); + if (!isNewer) return false; + + profiles[pubkey] = _profileFromEvent(event); + eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id); + return true; + } + UserProfile _profileFromEvent(NostrEvent event) { final data = ProfileData.fromEvent(event); final pubkey = data.pubkey.toLowerCase(); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index b0bebe96733..92ba962e81f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -13,6 +13,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; @@ -205,7 +206,7 @@ Widget _buildTestable({ bool watchChannelMembershipUpdates = false, Future> Function()? loadAgentDirectory, Future> Function()? loadAgentOwners, - _FakeUserCacheNotifier? userCacheNotifier, + UserCacheNotifier? userCacheNotifier, List members = const [], List huddleMembers = const [], _MutableHuddleMembersNotifier? huddleMembersNotifier, @@ -710,6 +711,76 @@ void main() { expect(find.byTooltip('Start Huddle'), findsNothing); }); + testWidgets('keeps Huddle hidden when live owner profile beats refresh', ( + tester, + ) async { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final profileRefresh = Completer>(); + final relaySession = _IdentityUpdateRelaySession( + profileRefresh: profileRefresh.future, + ); + final userCache = UserCacheNotifier(); + 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: ['self', agent.public], + 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.public, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitProfile( + _profileEvent( + id: 'newer-agent', + pubkey: agent.public, + createdAt: 2, + name: 'Agent', + tags: [_authTag(owner, agent.public)], + ), + ); + profileRefresh.complete([ + _profileEvent( + id: 'older-human', + pubkey: agent.public, + createdAt: 1, + name: 'Human', + ), + ]); + await tester.pumpAndSettle(); + + expect(userCache.state[agent.public]?.ownerPubkey, owner.public); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + testWidgets('keeps Huddle hidden while a verified owner profile loads', ( tester, ) async { @@ -13143,6 +13214,9 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { } class _IdentityUpdateRelaySession extends RelaySessionNotifier { + _IdentityUpdateRelaySession({this.profileRefresh}); + + final Future>? profileRefresh; NostrFilter? identityFilter; NostrFilter? directIdentityFilter; List directIdentityProfiles = const []; @@ -13161,6 +13235,9 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async { + if (filter.kinds.length == 1 && filter.kinds.single == 0) { + return profileRefresh ?? const []; + } if (filter.kinds.contains(10100) && filter.kinds.length == 1) { directIdentityFilter = filter; return directIdentityProfiles; @@ -13219,6 +13296,10 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { }; } + void emitProfile(NostrEvent event) { + _identityListener?.call(event); + } + void emitAgentProfile({required String pubkey}) { _identityListener?.call( NostrEvent( @@ -13410,6 +13491,39 @@ class _FakeChannelMutesNotifier extends ChannelMutesNotifier { } } +NostrEvent _profileEvent({ + required String id, + required String pubkey, + required int createdAt, + required String name, + List> tags = const [], +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: 0, + tags: tags, + content: jsonEncode({'name': name}), + sig: 'sig', +); + +List _authTag(nostr.Keys owner, String agentPubkey) { + final digest = SHA256Digest().process( + Uint8List.fromList( + utf8.encode('nostr:agent-auth:${agentPubkey.toLowerCase()}:'), + ), + ); + final message = digest + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: message), + ]; +} + class _FakeUserCacheNotifier extends UserCacheNotifier { final Map _users; final Future Function(List)? _preload; diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index f98d443461d..9a270861076 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -1,7 +1,13 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + 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'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; void main() { test('preload reports a profile batch failure', () async { @@ -27,15 +33,7 @@ void main() { addTearDown(container.dispose); final cache = container.read(userCacheProvider.notifier); cache.cacheProfileEvent( - const NostrEvent( - id: 'cached-profile', - pubkey: 'agent', - createdAt: 1, - kind: 0, - tags: [], - content: '{"name":"Cached Human"}', - sig: 'sig', - ), + _profileEvent(id: 'cached-profile', createdAt: 1, name: 'Cached Human'), ); final succeeded = await cache.refresh(const ['AGENT']); @@ -45,9 +43,162 @@ void main() { expect(session.requestedFilter?.authors, const ['agent']); expect(session.requestedFilter?.limit, 1); }); + + test('older refresh cannot overwrite a newer live profile', () async { + final refreshCompleter = Completer>(); + final session = _RecordingProfileSession(result: refreshCompleter.future); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final refresh = cache.refresh([agent.public]); + + cache.cacheProfileEvent( + _profileEvent( + id: 'newer-agent', + pubkey: agent.public, + createdAt: 2, + name: 'Agent', + tags: [_authTag(owner, agent.public)], + ), + ); + refreshCompleter.complete([ + _profileEvent( + id: 'older-human', + pubkey: agent.public, + createdAt: 1, + name: 'Human', + ), + ]); + + expect(await refresh, isTrue); + expect(cache.state[agent.public]?.displayName, 'Agent'); + expect(cache.state[agent.public]?.ownerPubkey, owner.public); + }); + + test('newer refresh can remove obsolete owner attribution', () async { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final session = _RecordingProfileSession( + result: Future.value([ + _profileEvent( + id: 'newer-human', + pubkey: agent.public, + createdAt: 2, + name: 'Human', + ), + ]), + ); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.cacheProfileEvent( + _profileEvent( + id: 'older-agent', + pubkey: agent.public, + createdAt: 1, + name: 'Agent', + tags: [_authTag(owner, agent.public)], + ), + ); + + expect(await cache.refresh([agent.public]), isTrue); + expect(cache.state[agent.public]?.displayName, 'Human'); + expect(cache.state[agent.public]?.ownerPubkey, isNull); + }); + + test('non-profile history cannot poison profile order', () async { + final session = _RecordingProfileSession( + results: [ + Future.value([ + _profileEvent( + id: 'non-profile-newer', + createdAt: 3, + name: 'Ignored', + kind: 1, + ), + ]), + Future.value([ + _profileEvent(id: 'valid-older', createdAt: 2, name: 'Valid'), + ]), + ], + ); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + + expect(await cache.refresh(const ['agent']), isTrue); + expect(cache.state['agent'], isNull); + expect(await cache.refresh(const ['agent']), isTrue); + expect(cache.state['agent']?.displayName, 'Valid'); + }); + + test('same-second profile tie keeps the lowest event id', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + + cache.cacheProfileEvent( + _profileEvent(id: 'b', createdAt: 1, name: 'Larger ID'), + ); + cache.cacheProfileEvent( + _profileEvent(id: 'a', createdAt: 1, name: 'Lower ID'), + ); + cache.cacheProfileEvent( + _profileEvent(id: 'c', createdAt: 1, name: 'Later Larger ID'), + ); + + expect(cache.state['agent']?.displayName, 'Lower ID'); + }); +} + +NostrEvent _profileEvent({ + required String id, + required int createdAt, + required String name, + String pubkey = 'agent', + List> tags = const [], + int kind = 0, +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: jsonEncode({'name': name}), + sig: 'sig', +); + +List _authTag(nostr.Keys owner, String agentPubkey) { + final digest = SHA256Digest().process( + Uint8List.fromList( + utf8.encode('nostr:agent-auth:${agentPubkey.toLowerCase()}:'), + ), + ); + final message = digest + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final signature = nostr.Schnorr.sign( + secretKey: owner.secret, + message: message, + ); + return ['auth', owner.public, '', signature]; } class _RecordingProfileSession extends RelaySessionNotifier { + _RecordingProfileSession({ + Future>? result, + List>>? results, + }) : _results = [...?results, ?result]; + + final List>> _results; NostrFilter? requestedFilter; @override @@ -59,7 +210,7 @@ class _RecordingProfileSession extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async { requestedFilter = filter; - return const []; + return _results.isEmpty ? const [] : _results.removeAt(0); } }