diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8a44b499b5..6e06b0d512 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -326,6 +326,9 @@ class ChannelDetailPage extends HookConsumerWidget { channelId: channel.id, content: content, mentionPubkeys: mentionPubkeys, + recipientPubkeys: resolvedChannel.isDm + ? resolvedChannel.participantPubkeys + : const [], mediaTags: mediaTags, ), ) diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 1771b05f12..d3b02273cb 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -27,13 +27,16 @@ class SendMessage { /// If [rootEventId] is null it defaults to [parentEventId] (direct reply to /// thread head). Tags are built to match the desktop's `buildReplyTags` /// convention with `root` / `reply` markers. Pass [mediaTags] to append - /// relay-validated `imeta` tags and NIP-30 `emoji` tags. + /// relay-validated `imeta` tags and NIP-30 `emoji` tags. Direct-message + /// callers must pass [recipientPubkeys] so the other participant receives a + /// `p` tag even when the message body contains no explicit @mention. Future call({ required String channelId, required String content, String? parentEventId, String? rootEventId, List? mentionPubkeys, + List recipientPubkeys = const [], List> mediaTags = const [], }) async { // Use explicitly passed pubkeys, or resolve @mentions against @@ -42,19 +45,22 @@ class SendMessage { mentionPubkeys ?? await _resolveMentions(content, channelId); final authorPubkey = _signedEventRelay.pubkey; - // Normalize mentions: lowercase, deduplicate, exclude self (matching + // Normalize recipients: 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 seenRecipients = {?selfLower}; + final normalizedRecipients = []; + for (final pubkey in [...recipientPubkeys, ...resolvedMentions]) { + final lower = pubkey.toLowerCase(); + if (lower.isNotEmpty && seenRecipients.add(lower)) { + normalizedRecipients.add(lower); + } + } final tags = >[ ['h', channelId], if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId), - for (final pk in normalizedMentions) ['p', pk], + for (final pk in normalizedRecipients) ['p', pk], ...mediaTags, ]; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 94e95b8fe5..e526d5e4b0 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -139,9 +139,13 @@ class ThreadDetailPage extends HookConsumerWidget { // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channelNamesMap = {}; + var recipientPubkeys = const []; channelsAsync.whenData((channels) { for (final ch in channels) { channelNamesMap[ch.name.toLowerCase()] = ch.id; + if (ch.id == channelId && ch.isDm) { + recipientPubkeys = ch.participantPubkeys; + } } }); @@ -275,6 +279,7 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, content: content, mentionPubkeys: mentionPubkeys, + recipientPubkeys: recipientPubkeys, parentEventId: threadHead.id, rootEventId: effectiveRootId, mediaTags: mediaTags, diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart new file mode 100644 index 0000000000..2ba1c72c34 --- /dev/null +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -0,0 +1,95 @@ +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/send_message_provider.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('adds DM recipients without requiring an explicit mention', () async { + final relay = _RecordingSignedEventRelay(pubkey: 'self'); + final sendMessage = SendMessage( + signedEventRelay: relay, + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + ); + + await sendMessage.call( + channelId: 'dm-channel', + content: 'hello from mobile', + recipientPubkeys: const ['SELF', 'AGENT', 'agent', ''], + ); + + expect(relay.tags, [ + ['h', 'dm-channel'], + ['p', 'agent'], + ]); + }); + + test('keeps non-DM messages mention-only', () async { + final relay = _RecordingSignedEventRelay(pubkey: 'self'); + final sendMessage = SendMessage( + signedEventRelay: relay, + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + ); + + await sendMessage.call( + channelId: 'shared-channel', + content: 'ordinary channel message', + ); + + expect(relay.tags, [ + ['h', 'shared-channel'], + ]); + }); + + test('deduplicates DM recipients and explicit mentions', () async { + final relay = _RecordingSignedEventRelay(pubkey: 'self'); + final sendMessage = SendMessage( + signedEventRelay: relay, + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + ); + + await sendMessage.call( + channelId: 'dm-channel', + content: '@agent please check this', + recipientPubkeys: const ['agent'], + mentionPubkeys: const ['agent', 'reviewer'], + ); + + expect(relay.tags, [ + ['h', 'dm-channel'], + ['p', 'agent'], + ['p', 'reviewer'], + ]); + }); +} + +class _RecordingSignedEventRelay implements SignedEventRelay { + _RecordingSignedEventRelay({required this.pubkey}); + + @override + final String? pubkey; + + List>? tags; + + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + }) async { + this.tags = tags; + return const NostrEvent( + id: 'stub', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: '', + sig: '', + ); + } +}