diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 3659bda4bf..cec91f45fe 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -1,15 +1,20 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; 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 FutureOr Function(String channelId) _readChannel; final Future> Function(String channelId) _fetchMembers; final Map Function() _readUserCache; final void Function(String channelId, NostrEvent event) _addLocalMessage; @@ -18,6 +23,7 @@ class SendMessage { SendMessage({ required SignedEventRelay signedEventRelay, + required FutureOr Function(String channelId) readChannel, required Future> Function(String channelId) fetchMembers, required Map Function() readUserCache, @@ -26,6 +32,7 @@ class SendMessage { completeLocalMessage, required void Function(String channelId, String eventId) removeLocalMessage, }) : _signedEventRelay = signedEventRelay, + _readChannel = readChannel, _fetchMembers = fetchMembers, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, @@ -53,14 +60,30 @@ 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. 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 ?? 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) - 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], @@ -167,6 +190,21 @@ final sendMessageProvider = Provider((ref) { session: ref.read(relaySessionProvider.notifier), nsec: config.nsec, ), + 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 f91ce87b2b..a67658ad98 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: (_) => _channel(channelType: 'stream'), fetchMembers: (_) async => const [], readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), @@ -51,6 +53,7 @@ void main() { session: session, nsec: nostr.Keys.generate().nsec, ), + readChannel: (_) => _channel(channelType: 'stream'), fetchMembers: (_) async => const [], readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), @@ -66,14 +69,241 @@ 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( + 'DM recipient tags are lowercase, non-empty, and deduplicated', + () 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, ''], + ), + ); + + final explicitAgent = _agentPubkey.toUpperCase(); + final result = send( + channelId: _channelId, + content: 'hey @agent', + mentionPubkeys: [explicitAgent, ''], + ); + await session.published; + session.accept(); + await result; + + expect(_pTagPubkeys(session.event), [_agentPubkey, _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('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: (_) => channel.future, + ); + + final result = send( + channelId: _channelId, + 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), [_humanPubkey, _agentPubkey]); + }); + 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())); + + await send( + channelId: _channelId, + content: 'hey @someone', + mentionPubkeys: const [_mentionPubkey], + ); + + 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')), + ); + unawaited(session.published.then((_) => session.accept())); + + await send( + channelId: _channelId, + content: 'hey @someone', + mentionPubkeys: const [_mentionPubkey], + ); + + expect(_pTagPubkeys(session.event), [_mentionPubkey]); + }); } const _channelId = '11111111-1111-4111-8111-111111111111'; +const _humanPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const _agentPubkey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const _mentionPubkey = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + +const _rootEventId = + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'; +const _parentEventId = + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + +SendMessage _sendMessage({ + required _PendingPublishRelaySession session, + required String nsec, + required FutureOr 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(); late NostrEvent event; + bool get hasPublished => _published.isCompleted; Future get published => _published.future;