Skip to content
Draft
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,
recipientPubkeys: resolvedChannel.isDm
? resolvedChannel.participantPubkeys
: const [],
mediaTags: mediaTags,
),
)
Expand Down
22 changes: 14 additions & 8 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> call({
required String channelId,
required String content,
String? parentEventId,
String? rootEventId,
List<String>? mentionPubkeys,
List<String> recipientPubkeys = const [],
List<List<String>> mediaTags = const [],
}) async {
// Use explicitly passed pubkeys, or resolve @mentions against
Expand All @@ -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 = <String>{?selfLower};
final normalizedMentions = <String>[
for (final pk in resolvedMentions)
if (seenMentions.add(pk.toLowerCase())) pk,
];
final seenRecipients = <String>{?selfLower};
final normalizedRecipients = <String>[];
for (final pubkey in [...recipientPubkeys, ...resolvedMentions]) {
final lower = pubkey.toLowerCase();
if (lower.isNotEmpty && seenRecipients.add(lower)) {
normalizedRecipients.add(lower);
}
}

final tags = <List<String>>[
['h', channelId],
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
for (final pk in normalizedMentions) ['p', pk],
for (final pk in normalizedRecipients) ['p', pk],
...mediaTags,
];

Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,13 @@ class ThreadDetailPage extends HookConsumerWidget {
// Channel names for message content rendering.
final channelsAsync = ref.watch(channelsProvider);
final channelNamesMap = <String, String>{};
var recipientPubkeys = const <String>[];
channelsAsync.whenData((channels) {
for (final ch in channels) {
channelNamesMap[ch.name.toLowerCase()] = ch.id;
if (ch.id == channelId && ch.isDm) {
recipientPubkeys = ch.participantPubkeys;
}
}
});

Expand Down Expand Up @@ -275,6 +279,7 @@ class ThreadDetailPage extends HookConsumerWidget {
channelId: channelId,
content: content,
mentionPubkeys: mentionPubkeys,
recipientPubkeys: recipientPubkeys,
parentEventId: threadHead.id,
rootEventId: effectiveRootId,
mediaTags: mediaTags,
Expand Down
95 changes: 95 additions & 0 deletions mobile/test/features/channels/send_message_provider_test.dart
Original file line number Diff line number Diff line change
@@ -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 <ChannelMember>[],
readUserCache: () => const <String, UserProfile>{},
);

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 <ChannelMember>[],
readUserCache: () => const <String, UserProfile>{},
);

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 <ChannelMember>[],
readUserCache: () => const <String, UserProfile>{},
);

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

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