Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ class ChannelDetailPage extends HookConsumerWidget {
channelId: channel.id,
content: content,
mentionPubkeys: mentionPubkeys,
dmParticipantPubkeys: resolvedChannel.isDm
? resolvedChannel.participantPubkeys
: null,
mediaTags: mediaTags,
),
)
Expand Down
10 changes: 8 additions & 2 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,33 @@ 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<void> call({
required String channelId,
required String content,
String? parentEventId,
String? rootEventId,
List<String>? mentionPubkeys,
List<String>? dmParticipantPubkeys,
List<List<String>> 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 = <String>[
...resolvedMentions,
...?dmParticipantPubkeys,
];
final authorPubkey = _signedEventRelay.pubkey;

// Normalize mentions: lowercase, deduplicate, exclude self (matching
// the desktop's normalizeMentionPubkeys).
final selfLower = authorPubkey?.toLowerCase();
final seenMentions = <String>{?selfLower};
final normalizedMentions = <String>[
for (final pk in resolvedMentions)
for (final pk in allMentionPubkeys)
if (seenMentions.add(pk.toLowerCase())) pk,
];

Expand Down
8 changes: 8 additions & 0 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down Expand Up @@ -275,6 +282,7 @@ class ThreadDetailPage extends HookConsumerWidget {
channelId: channelId,
content: content,
mentionPubkeys: mentionPubkeys,
dmParticipantPubkeys: dmParticipantPubkeys,
parentEventId: threadHead.id,
rootEventId: effectiveRootId,
mediaTags: mediaTags,
Expand Down
126 changes: 126 additions & 0 deletions mobile/test/features/channels/send_message_dm_test.dart
Original file line number Diff line number Diff line change
@@ -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<List<List<String>>> _send({
List<String> mentionPubkeys = const [],
List<String>? dmParticipantPubkeys,
String? parentEventId,
String? rootEventId,
List<List<String>> mediaTags = const [],
}) async {
final relay = _RecordingSignedEventRelay();
final sender = SendMessage(
signedEventRelay: relay,
fetchMembers: (_) async => const [],
readUserCache: () => const <String, UserProfile>{},
);

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<List<String>>? submittedTags;

@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
}) async {
submittedKind = kind;
submittedTags = tags;
return const NostrEvent(
id: 'stub',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
);
}
}