diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 730546807f8..4a4762b2863 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; +import '../channels/channels_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_messages_provider.dart'; @@ -11,6 +12,7 @@ import 'channel_messages_provider.dart'; class SendMessage { final SignedEventRelay _signedEventRelay; final Future> Function(String channelId) _fetchMembers; + final Future Function(String channelId) _isDirectMessage; final Map Function() _readUserCache; final void Function(String channelId, NostrEvent event) _addLocalMessage; final void Function(String channelId, String eventId) _completeLocalMessage; @@ -21,6 +23,7 @@ class SendMessage { required SignedEventRelay signedEventRelay, required Future> Function(String channelId) fetchMembers, + required Future Function(String channelId) isDirectMessage, required Map Function() readUserCache, required void Function(String channelId, NostrEvent event) addLocalMessage, required void Function(String channelId, String eventId) @@ -29,6 +32,7 @@ class SendMessage { bool Function()? isDeliveryValid, }) : _signedEventRelay = signedEventRelay, _fetchMembers = fetchMembers, + _isDirectMessage = isDirectMessage, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, _completeLocalMessage = completeLocalMessage, @@ -51,19 +55,28 @@ class SendMessage { List> mediaTags = const [], }) async { _ensureDeliveryValid(); + final isDirectMessage = await _isDirectMessage(channelId); // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. final resolvedMentions = mentionPubkeys ?? await _resolveMentions(content, channelId); + final recipients = [...resolvedMentions]; + if (isDirectMessage) { + // A DM addresses every other participant even when its text contains no + // explicit @mention. Agent harnesses and human notification + // subscriptions both rely on these p-tags to wake reliably. + final members = await _fetchMembers(channelId); + recipients.addAll(members.map((member) => member.pubkey)); + } final authorPubkey = _signedEventRelay.pubkey; - // Normalize mentions: lowercase, deduplicate, exclude self (matching - // the desktop's normalizeMentionPubkeys). + // Normalize recipients: lowercase, deduplicate, exclude self (matching + // the desktop's messageMentionPubkeys). final selfLower = authorPubkey?.toLowerCase(); final seenMentions = {?selfLower}; final normalizedMentions = [ - for (final pk in resolvedMentions) - if (seenMentions.add(pk.toLowerCase())) pk, + for (final pk in recipients) + if (seenMentions.add(pk.toLowerCase())) pk.toLowerCase(), ]; final tags = >[ @@ -182,6 +195,13 @@ final sendMessageProvider = Provider((ref) { ), fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), + isDirectMessage: (channelId) async { + final channels = await ref.read(channelsProvider.future); + for (final channel in channels) { + if (channel.id == channelId) return channel.isDm; + } + throw StateError('Cannot determine channel type for $channelId'); + }, readUserCache: () => ref.read(userCacheProvider), addLocalMessage: (channelId, event) => ref .read(channelMessagesProvider(channelId).notifier) diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 273336f2833..572eb82ae77 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -20,6 +21,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + isDirectMessage: (_) async => false, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -53,6 +55,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + isDirectMessage: (_) async => false, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -91,6 +94,162 @@ void main() { ), ); }); + + test('plain DM messages p-tag every participant except the sender', () async { + final session = _PendingPublishRelaySession(); + final senderKeys = nostr.Keys.generate(); + final agentPubkey = nostr.Keys.generate().public; + final humanPubkey = nostr.Keys.generate().public; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: senderKeys.nsec, + ), + fetchMembers: (_) async => [ + ChannelMember( + pubkey: senderKeys.public, + role: 'owner', + joinedAt: DateTime.utc(2026), + ), + ChannelMember( + pubkey: agentPubkey.toUpperCase(), + role: 'bot', + joinedAt: DateTime.utc(2026), + ), + ChannelMember( + pubkey: humanPubkey, + role: 'member', + joinedAt: DateTime.utc(2026), + ), + ], + isDirectMessage: (_) async => true, + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send(channelId: _channelId, content: 'hello'); + await session.published; + + final pTags = session.event.tags + .where((tag) => tag.length >= 2 && tag[0] == 'p') + .map((tag) => tag[1]) + .toList(); + expect(pTags, [agentPubkey, humanPubkey]); + + session.accept(); + await result; + }); + + test('DM thread replies p-tag every participant', () async { + final session = _PendingPublishRelaySession(); + final senderKeys = nostr.Keys.generate(); + final recipientPubkey = nostr.Keys.generate().public; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: senderKeys.nsec, + ), + fetchMembers: (_) async => [ + ChannelMember( + pubkey: senderKeys.public, + role: 'owner', + joinedAt: DateTime.utc(2026), + ), + ChannelMember( + pubkey: recipientPubkey, + role: 'bot', + joinedAt: DateTime.utc(2026), + ), + ], + isDirectMessage: (_) async => true, + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'thread reply', + parentEventId: 'a' * 64, + rootEventId: 'b' * 64, + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.firstOrNull == 'p').toList(), [ + ['p', recipientPubkey], + ]); + + session.accept(); + await result; + }); + + test('stream messages preserve explicit-mention behavior', () async { + final session = _PendingPublishRelaySession(); + final recipientPubkey = nostr.Keys.generate().public; + var fetchedMembers = false; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async { + fetchedMembers = true; + return const []; + }, + isDirectMessage: (_) async => false, + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello agent', + mentionPubkeys: [recipientPubkey.toUpperCase(), recipientPubkey], + ); + await session.published; + + expect(fetchedMembers, isFalse); + expect(session.event.tags.where((tag) => tag.firstOrNull == 'p').toList(), [ + ['p', recipientPubkey], + ]); + + session.accept(); + await result; + }); + + test('fails closed when the channel type cannot be determined', () async { + final session = _PendingPublishRelaySession(); + var fetchedMembers = false; + var addedLocalMessage = false; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async { + fetchedMembers = true; + return const []; + }, + isDirectMessage: (_) async => throw StateError('unknown channel'), + readUserCache: () => const {}, + addLocalMessage: (_, _) => addedLocalMessage = true, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + await expectLater( + send(channelId: _channelId, content: 'hello'), + throwsA(isA()), + ); + + expect(fetchedMembers, isFalse); + expect(addedLocalMessage, isFalse); + }); } const _channelId = '11111111-1111-4111-8111-111111111111';