diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8a44b499b5..bbb45006dc 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, + dmParticipantPubkeys: resolvedChannel.isDm + ? resolvedChannel.participantPubkeys + : null, mediaTags: mediaTags, ), ) diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 1771b05f12..b0874c2fa8 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -27,19 +27,25 @@ 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. For a DM, pass + /// [dmParticipantPubkeys] so every counterparty receives a `p` tag. Future call({ required String channelId, required String content, String? parentEventId, String? rootEventId, List? mentionPubkeys, + List? dmParticipantPubkeys, List> mediaTags = const [], }) async { // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. final resolvedMentions = mentionPubkeys ?? await _resolveMentions(content, channelId); + final allMentionPubkeys = [ + ...resolvedMentions, + ...?dmParticipantPubkeys, + ]; final authorPubkey = _signedEventRelay.pubkey; // Normalize mentions: lowercase, deduplicate, exclude self (matching @@ -47,7 +53,7 @@ class SendMessage { final selfLower = authorPubkey?.toLowerCase(); final seenMentions = {?selfLower}; final normalizedMentions = [ - for (final pk in resolvedMentions) + for (final pk in allMentionPubkeys) if (seenMentions.add(pk.toLowerCase())) pk, ]; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 94e95b8fe5..7fa9079e2e 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -144,6 +144,13 @@ class ThreadDetailPage extends HookConsumerWidget { channelNamesMap[ch.name.toLowerCase()] = ch.id; } }); + final loadedChannels = channelsAsync.asData?.value; + final currentChannel = loadedChannels + ?.where((candidate) => candidate.id == channelId) + .firstOrNull; + final dmParticipantPubkeys = currentChannel?.isDm == true + ? currentChannel!.participantPubkeys + : null; return FrostedScaffold( appBar: const FrostedAppBar(title: Text('Thread')), @@ -275,6 +282,7 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, content: content, mentionPubkeys: mentionPubkeys, + dmParticipantPubkeys: dmParticipantPubkeys, parentEventId: threadHead.id, rootEventId: effectiveRootId, mediaTags: mediaTags, diff --git a/mobile/test/features/channels/send_message_dm_test.dart b/mobile/test/features/channels/send_message_dm_test.dart new file mode 100644 index 0000000000..f4345b29ef --- /dev/null +++ b/mobile/test/features/channels/send_message_dm_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.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'; + +void main() { + test( + 'plain DM appends the channel participant without a picker mention', + () async { + final tags = await _send(dmParticipantPubkeys: ['Recipient']); + + expect(tags, [ + ['h', 'dm-channel'], + ['p', 'Recipient'], + ]); + }, + ); + + test( + 'DM mention tags are deduplicated and self-excluded case-insensitively', + () async { + final tags = await _send( + mentionPubkeys: ['EXPLICIT', 'Peer', 'sender'], + dmParticipantPubkeys: ['PEER', 'SENDER', 'second'], + ); + + expect(tags, [ + ['h', 'dm-channel'], + ['p', 'EXPLICIT'], + ['p', 'Peer'], + ['p', 'second'], + ]); + }, + ); + + test('non-DM keeps explicit mention semantics', () async { + final tags = await _send( + mentionPubkeys: ['Explicit'], + dmParticipantPubkeys: null, + ); + + expect(tags, [ + ['h', 'dm-channel'], + ['p', 'Explicit'], + ]); + }); + + test('threaded DM keeps e-tags before p-tags and media tags last', () async { + final tags = await _send( + mentionPubkeys: ['Explicit'], + dmParticipantPubkeys: ['Recipient'], + parentEventId: 'parent', + rootEventId: 'root', + mediaTags: const [ + ['imeta', 'blob'], + ['emoji', 'party'], + ], + ); + + expect(tags, [ + ['h', 'dm-channel'], + ['e', 'root', '', 'root'], + ['e', 'parent', '', 'reply'], + ['p', 'Explicit'], + ['p', 'Recipient'], + ['imeta', 'blob'], + ['emoji', 'party'], + ]); + }); +} + +Future>> _send({ + List mentionPubkeys = const [], + List? dmParticipantPubkeys, + String? parentEventId, + String? rootEventId, + List> mediaTags = const [], +}) async { + final relay = _RecordingSignedEventRelay(); + final sender = SendMessage( + signedEventRelay: relay, + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + ); + + await sender( + channelId: 'dm-channel', + content: 'message', + mentionPubkeys: mentionPubkeys, + dmParticipantPubkeys: dmParticipantPubkeys, + parentEventId: parentEventId, + rootEventId: rootEventId, + mediaTags: mediaTags, + ); + + expect(relay.submittedKind, EventKind.streamMessage); + return relay.submittedTags!; +} + +class _RecordingSignedEventRelay implements SignedEventRelay { + @override + String? get pubkey => 'SENDER'; + + int? submittedKind; + List>? submittedTags; + + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + }) async { + submittedKind = kind; + submittedTags = tags; + return const NostrEvent( + id: 'stub', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: '', + sig: '', + ); + } +}