From 31564121e7d5b87c6e0e46b57af94a1872348b6d Mon Sep 17 00:00:00 2001 From: Zachary Date: Mon, 27 Jul 2026 21:43:33 -0400 Subject: [PATCH 1/4] feat(mobile): p-tag all DM participants on every message send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match desktop's DM contract: kind-9 messages published in a DM channel now carry ["p", pk] tags for every other DM participant (union of explicit @mentions and the channel's participantPubkeys, minus the sender, deduplicated), so the buzz-acp harness's mention-gated #p subscription sees plain DM messages and agents respond without an @mention. The fan-out lives in SendMessage.call — mobile's single publish-time chokepoint for kind-9 — via a new injected readChannel closure wired from the cached channelsProvider state (synchronous; never blocks a send). If the channel isn't cached yet or isn't a DM, behavior is unchanged. Non-DM channels keep explicit-mentions-only semantics. Wire-level tests assert the p tags on the recorded signed event for plain DM sends, non-DM sends, and the not-yet-cached fallback. Signed-off-by: Zachary --- .../channels/send_message_provider.dart | 21 +++- .../channels/send_message_provider_test.dart | 112 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 3659bda4bf..c0c17a9882 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -4,12 +4,15 @@ import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'channel.dart'; import 'channel_messages_provider.dart'; +import 'channels_provider.dart'; /// Sends messages by signing an event with the user's nsec and publishing it /// over the relay's NIP-42-authenticated WebSocket session. class SendMessage { final SignedEventRelay _signedEventRelay; + final Channel? Function(String channelId) _readChannel; final Future> Function(String channelId) _fetchMembers; final Map Function() _readUserCache; final void Function(String channelId, NostrEvent event) _addLocalMessage; @@ -18,6 +21,7 @@ class SendMessage { SendMessage({ required SignedEventRelay signedEventRelay, + required Channel? Function(String channelId) readChannel, required Future> Function(String channelId) fetchMembers, required Map Function() readUserCache, @@ -26,6 +30,7 @@ class SendMessage { completeLocalMessage, required void Function(String channelId, String eventId) removeLocalMessage, }) : _signedEventRelay = signedEventRelay, + _readChannel = readChannel, _fetchMembers = fetchMembers, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, @@ -53,12 +58,21 @@ class SendMessage { mentionPubkeys ?? await _resolveMentions(content, channelId); final authorPubkey = _signedEventRelay.pubkey; + // In a DM, fan out p-tags to every other participant so mention-gated + // agent subscriptions see plain messages (matching the desktop's + // messageMentionPubkeys). Synchronous cached lookup — if the channel + // isn't cached yet, the send proceeds with explicit mentions only. + final channel = _readChannel(channelId); + final fanoutPubkeys = (channel?.isDm ?? false) + ? channel!.participantPubkeys + : const []; + // Normalize mentions: lowercase, deduplicate, exclude self (matching // the desktop's normalizeMentionPubkeys). final selfLower = authorPubkey?.toLowerCase(); final seenMentions = {?selfLower}; final normalizedMentions = [ - for (final pk in resolvedMentions) + for (final pk in [...resolvedMentions, ...fanoutPubkeys]) if (seenMentions.add(pk.toLowerCase())) pk, ]; @@ -167,6 +181,11 @@ final sendMessageProvider = Provider((ref) { session: ref.read(relaySessionProvider.notifier), nsec: config.nsec, ), + readChannel: (channelId) => ref + .read(channelsProvider) + .value + ?.where((channel) => channel.id == channelId) + .firstOrNull, fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), readUserCache: () => ref.read(userCacheProvider), diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index f91ce87b2b..348a45cc97 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -18,6 +19,7 @@ void main() { session: session, nsec: nostr.Keys.generate().nsec, ), + readChannel: (_) => null, fetchMembers: (_) async => const [], readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), @@ -51,6 +53,7 @@ void main() { session: session, nsec: nostr.Keys.generate().nsec, ), + readChannel: (_) => null, fetchMembers: (_) async => const [], readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), @@ -66,10 +69,119 @@ void main() { expect(completedIds, isEmpty); expect(removedIds, [localMessages.single.id]); }); + + test('plain DM send p-tags every participant except the sender', () async { + final session = _PendingPublishRelaySession(); + final keys = nostr.Keys.generate(); + final send = _sendMessage( + session: session, + nsec: keys.nsec, + readChannel: (channelId) => _channel( + channelType: 'dm', + // Roster includes the sender's own pubkey — it must be excluded. + participantPubkeys: [keys.public, _humanPubkey, _agentPubkey], + ), + ); + + final result = send( + channelId: _channelId, + content: 'hey, can you look at this?', + mentionPubkeys: const [], + ); + await session.published; + session.accept(); + await result; + + expect(_pTagPubkeys(session.event), [_humanPubkey, _agentPubkey]); + }); + + test('non-DM channel carries explicit mentions only', () async { + final session = _PendingPublishRelaySession(); + final send = _sendMessage( + session: session, + nsec: nostr.Keys.generate().nsec, + readChannel: (channelId) => _channel( + channelType: 'stream', + participantPubkeys: const [_humanPubkey, _agentPubkey], + ), + ); + + final result = send( + channelId: _channelId, + content: 'hey @someone', + mentionPubkeys: const [_mentionPubkey], + ); + await session.published; + session.accept(); + await result; + + expect(_pTagPubkeys(session.event), [_mentionPubkey]); + }); + + test('sends without fan-out when the channel is not cached yet', () async { + final session = _PendingPublishRelaySession(); + final send = _sendMessage( + session: session, + nsec: nostr.Keys.generate().nsec, + readChannel: (_) => null, + ); + + final result = send( + channelId: _channelId, + content: 'hello', + mentionPubkeys: const [], + ); + await session.published; + session.accept(); + await result; + + expect(_pTagPubkeys(session.event), isEmpty); + }); } const _channelId = '11111111-1111-4111-8111-111111111111'; +const _humanPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const _agentPubkey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const _mentionPubkey = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + +SendMessage _sendMessage({ + required _PendingPublishRelaySession session, + required String nsec, + required Channel? Function(String channelId) readChannel, +}) => SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: nsec), + readChannel: readChannel, + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, +); + +Channel _channel({ + required String channelType, + List participantPubkeys = const [], +}) => Channel( + id: _channelId, + name: 'chat', + channelType: channelType, + visibility: 'private', + description: '', + createdBy: _humanPubkey, + createdAt: DateTime.utc(2026), + memberCount: participantPubkeys.length, + participantPubkeys: participantPubkeys, +); + +List _pTagPubkeys(NostrEvent event) => [ + for (final tag in event.tags) + if (tag.isNotEmpty && tag.first == 'p') tag[1], +]; + class _PendingPublishRelaySession extends RelaySessionNotifier { final Completer _result = Completer(); final Completer _published = Completer(); From 4808153af8dc715d9ac0d97752e3a1f16c7d596b Mon Sep 17 00:00:00 2001 From: Zachary Date: Mon, 27 Jul 2026 21:47:20 -0400 Subject: [PATCH 2/4] test(mobile): harden DM p-tag fan-out contract for mention overlap and thread replies Two wire-level tests on SendMessage: a case-mismatched explicit mention overlapping a DM roster entry dedupes to a single p tag, and a DM thread reply carries the participant fan-out alongside its root/reply e-tags. Signed-off-by: Zachary --- .../channels/send_message_provider_test.dart | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 348a45cc97..863adeeeff 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -118,6 +118,75 @@ void main() { expect(_pTagPubkeys(session.event), [_mentionPubkey]); }); + test( + 'explicit mention overlapping a fan-out participant dedupes to one p tag', + () async { + final session = _PendingPublishRelaySession(); + final keys = nostr.Keys.generate(); + final send = _sendMessage( + session: session, + nsec: keys.nsec, + readChannel: (channelId) => _channel( + channelType: 'dm', + participantPubkeys: [keys.public, _humanPubkey, _agentPubkey], + ), + ); + + // The explicit mention arrives uppercase while the roster holds the + // lowercase form — dedupe is keyed on the lowercased pubkey. + final explicitAgent = _agentPubkey.toUpperCase(); + final result = send( + channelId: _channelId, + content: 'hey @agent', + mentionPubkeys: [explicitAgent], + ); + await session.published; + session.accept(); + await result; + + // The case-mismatched overlap collapses to a single p tag (first-seen + // casing wins), and the rest of the roster still fans out. + expect(_pTagPubkeys(session.event), [explicitAgent, _humanPubkey]); + }, + ); + + test( + 'DM thread reply carries fan-out p tags alongside reply e-tags', + () async { + final session = _PendingPublishRelaySession(); + final keys = nostr.Keys.generate(); + final send = _sendMessage( + session: session, + nsec: keys.nsec, + readChannel: (channelId) => _channel( + channelType: 'dm', + participantPubkeys: [keys.public, _humanPubkey, _agentPubkey], + ), + ); + + // Nested thread reply, as the thread page sends it: parent + root. + final result = send( + channelId: _channelId, + content: 'replying in-thread', + parentEventId: _parentEventId, + rootEventId: _rootEventId, + mentionPubkeys: const [], + ); + await session.published; + session.accept(); + await result; + + expect( + session.event.tags, + containsAll([ + ['e', _rootEventId, '', 'root'], + ['e', _parentEventId, '', 'reply'], + ]), + ); + expect(_pTagPubkeys(session.event), [_humanPubkey, _agentPubkey]); + }, + ); + test('sends without fan-out when the channel is not cached yet', () async { final session = _PendingPublishRelaySession(); final send = _sendMessage( @@ -148,6 +217,11 @@ const _agentPubkey = const _mentionPubkey = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; +const _rootEventId = + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'; +const _parentEventId = + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + SendMessage _sendMessage({ required _PendingPublishRelaySession session, required String nsec, From 1750a15fcb9025004c984180ff2d64c30e2b9371 Mon Sep 17 00:00:00 2001 From: Zachary Date: Tue, 28 Jul 2026 08:42:29 -0400 Subject: [PATCH 3/4] fix(mobile): address DM recipient review Signed-off-by: Zachary --- .../channels/send_message_provider.dart | 50 +++++++++----- .../channels/send_message_provider_test.dart | 65 +++++++++++++++---- 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index c0c17a9882..7e79d4a343 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; @@ -12,7 +14,7 @@ import 'channels_provider.dart'; /// over the relay's NIP-42-authenticated WebSocket session. class SendMessage { final SignedEventRelay _signedEventRelay; - final Channel? Function(String channelId) _readChannel; + final FutureOr Function(String channelId) _readChannel; final Future> Function(String channelId) _fetchMembers; final Map Function() _readUserCache; final void Function(String channelId, NostrEvent event) _addLocalMessage; @@ -21,7 +23,7 @@ class SendMessage { SendMessage({ required SignedEventRelay signedEventRelay, - required Channel? Function(String channelId) readChannel, + required FutureOr Function(String channelId) readChannel, required Future> Function(String channelId) fetchMembers, required Map Function() readUserCache, @@ -60,21 +62,27 @@ class SendMessage { // In a DM, fan out p-tags to every other participant so mention-gated // agent subscriptions see plain messages (matching the desktop's - // messageMentionPubkeys). Synchronous cached lookup — if the channel - // isn't cached yet, the send proceeds with explicit mentions only. - final channel = _readChannel(channelId); - final fanoutPubkeys = (channel?.isDm ?? false) - ? channel!.participantPubkeys + // messageMentionPubkeys). Wait for the authoritative channel list when + // the synchronous cache has not loaded this channel yet. + final channel = await _readChannel(channelId); + if (channel == null) { + throw StateError('Channel not found: $channelId'); + } + final fanoutPubkeys = channel.isDm + ? channel.participantPubkeys : const []; // Normalize mentions: lowercase, deduplicate, exclude self (matching // the desktop's normalizeMentionPubkeys). final selfLower = authorPubkey?.toLowerCase(); final seenMentions = {?selfLower}; - final normalizedMentions = [ - for (final pk in [...resolvedMentions, ...fanoutPubkeys]) - if (seenMentions.add(pk.toLowerCase())) pk, - ]; + final normalizedMentions = []; + for (final pk in [...resolvedMentions, ...fanoutPubkeys]) { + final lower = pk.toLowerCase(); + if (lower.isNotEmpty && seenMentions.add(lower)) { + normalizedMentions.add(lower); + } + } final tags = >[ ['h', channelId], @@ -181,11 +189,21 @@ final sendMessageProvider = Provider((ref) { session: ref.read(relaySessionProvider.notifier), nsec: config.nsec, ), - readChannel: (channelId) => ref - .read(channelsProvider) - .value - ?.where((channel) => channel.id == channelId) - .firstOrNull, + readChannel: (channelId) { + final cachedChannel = ref + .read(channelsProvider) + .value + ?.where((channel) => channel.id == channelId) + .firstOrNull; + if (cachedChannel != null) return cachedChannel; + return ref + .read(channelsProvider.future) + .then( + (channels) => channels + .where((channel) => channel.id == channelId) + .firstOrNull, + ); + }, fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), readUserCache: () => ref.read(userCacheProvider), diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 863adeeeff..9014f75f57 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -19,7 +19,7 @@ void main() { session: session, nsec: nostr.Keys.generate().nsec, ), - readChannel: (_) => null, + readChannel: (_) => _channel(channelType: 'stream'), fetchMembers: (_) async => const [], readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), @@ -53,7 +53,7 @@ void main() { session: session, nsec: nostr.Keys.generate().nsec, ), - readChannel: (_) => null, + readChannel: (_) => _channel(channelType: 'stream'), fetchMembers: (_) async => const [], readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), @@ -119,7 +119,7 @@ void main() { }); test( - 'explicit mention overlapping a fan-out participant dedupes to one p tag', + 'DM recipient tags are lowercase, non-empty, and deduplicated', () async { final session = _PendingPublishRelaySession(); final keys = nostr.Keys.generate(); @@ -128,25 +128,21 @@ void main() { nsec: keys.nsec, readChannel: (channelId) => _channel( channelType: 'dm', - participantPubkeys: [keys.public, _humanPubkey, _agentPubkey], + participantPubkeys: [keys.public, _humanPubkey, _agentPubkey, ''], ), ); - // The explicit mention arrives uppercase while the roster holds the - // lowercase form — dedupe is keyed on the lowercased pubkey. final explicitAgent = _agentPubkey.toUpperCase(); final result = send( channelId: _channelId, content: 'hey @agent', - mentionPubkeys: [explicitAgent], + mentionPubkeys: [explicitAgent, ''], ); await session.published; session.accept(); await result; - // The case-mismatched overlap collapses to a single p tag (first-seen - // casing wins), and the rest of the roster still fans out. - expect(_pTagPubkeys(session.event), [explicitAgent, _humanPubkey]); + expect(_pTagPubkeys(session.event), [_agentPubkey, _humanPubkey]); }, ); @@ -187,12 +183,13 @@ void main() { }, ); - test('sends without fan-out when the channel is not cached yet', () async { + test('waits for channel loading before publishing a plain DM', () async { final session = _PendingPublishRelaySession(); + final channel = Completer(); final send = _sendMessage( session: session, nsec: nostr.Keys.generate().nsec, - readChannel: (_) => null, + readChannel: (_) => channel.future, ); final result = send( @@ -200,11 +197,50 @@ void main() { content: 'hello', mentionPubkeys: const [], ); + await Future.delayed(Duration.zero); + + expect(session.hasPublished, isFalse); + + channel.complete( + _channel( + channelType: 'dm', + participantPubkeys: const [_humanPubkey, _agentPubkey], + ), + ); await session.published; session.accept(); await result; - expect(_pTagPubkeys(session.event), isEmpty); + expect(_pTagPubkeys(session.event), [_humanPubkey, _agentPubkey]); + }); + test('rejects a send when the channel cannot be resolved', () async { + final session = _PendingPublishRelaySession(); + final send = _sendMessage( + session: session, + nsec: nostr.Keys.generate().nsec, + readChannel: (_) => null, + ); + + final result = send( + channelId: _channelId, + content: 'hello', + mentionPubkeys: const [], + ); + final failure = expectLater( + result, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(_channelId), + ), + ), + ); + await Future.delayed(Duration.zero); + if (session.hasPublished) session.reject(); + + await failure; + expect(session.hasPublished, isFalse); }); } @@ -225,7 +261,7 @@ const _parentEventId = SendMessage _sendMessage({ required _PendingPublishRelaySession session, required String nsec, - required Channel? Function(String channelId) readChannel, + required FutureOr Function(String channelId) readChannel, }) => SendMessage( signedEventRelay: SignedEventRelay(session: session, nsec: nsec), readChannel: readChannel, @@ -260,6 +296,7 @@ class _PendingPublishRelaySession extends RelaySessionNotifier { final Completer _result = Completer(); final Completer _published = Completer(); late NostrEvent event; + bool get hasPublished => _published.isCompleted; Future get published => _published.future; From 7c9fa80beed385aa7bdc497d1ea71bf39629040d Mon Sep 17 00:00:00 2001 From: Zachary Date: Tue, 28 Jul 2026 09:09:17 -0400 Subject: [PATCH 4/4] fix(mobile): preserve sends on channel lookup failure Signed-off-by: Zachary --- .../channels/send_message_provider.dart | 17 ++++---- .../channels/send_message_provider_test.dart | 41 +++++++++++-------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 7e79d4a343..cec91f45fe 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -61,15 +61,16 @@ class SendMessage { final authorPubkey = _signedEventRelay.pubkey; // In a DM, fan out p-tags to every other participant so mention-gated - // agent subscriptions see plain messages (matching the desktop's - // messageMentionPubkeys). Wait for the authoritative channel list when - // the synchronous cache has not loaded this channel yet. - final channel = await _readChannel(channelId); - if (channel == null) { - throw StateError('Channel not found: $channelId'); + // agent subscriptions see plain messages. Wait for initial channel + // loading, but preserve explicit-mention sends if the list is unavailable. + Channel? channel; + try { + channel = await _readChannel(channelId); + } catch (_) { + channel = null; } - final fanoutPubkeys = channel.isDm - ? channel.participantPubkeys + final fanoutPubkeys = (channel?.isDm ?? false) + ? channel!.participantPubkeys : const []; // Normalize mentions: lowercase, deduplicate, exclude self (matching diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 9014f75f57..a67658ad98 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -213,34 +213,41 @@ void main() { expect(_pTagPubkeys(session.event), [_humanPubkey, _agentPubkey]); }); - test('rejects a send when the channel cannot be resolved', () async { + test('sends explicit mentions when the channel is missing', () async { final session = _PendingPublishRelaySession(); final send = _sendMessage( session: session, nsec: nostr.Keys.generate().nsec, readChannel: (_) => null, ); + unawaited(session.published.then((_) => session.accept())); - final result = send( + await send( channelId: _channelId, - content: 'hello', - mentionPubkeys: const [], + content: 'hey @someone', + mentionPubkeys: const [_mentionPubkey], ); - final failure = expectLater( - result, - throwsA( - isA().having( - (error) => error.message, - 'message', - contains(_channelId), - ), - ), + + expect(_pTagPubkeys(session.event), [_mentionPubkey]); + }); + + test('sends explicit mentions when the channel lookup fails', () async { + final session = _PendingPublishRelaySession(); + final send = _sendMessage( + session: session, + nsec: nostr.Keys.generate().nsec, + readChannel: (_) => + Future.error(Exception('channel list unavailable')), ); - await Future.delayed(Duration.zero); - if (session.hasPublished) session.reject(); + unawaited(session.published.then((_) => session.accept())); - await failure; - expect(session.hasPublished, isFalse); + await send( + channelId: _channelId, + content: 'hey @someone', + mentionPubkeys: const [_mentionPubkey], + ); + + expect(_pTagPubkeys(session.event), [_mentionPubkey]); }); }