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
27 changes: 27 additions & 0 deletions mobile/lib/features/channels/message_mention_pubkeys.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/// Return the semantic recipients for an outgoing message.
///
/// Stream messages notify only explicit mentions. A DM addresses every other
/// participant, so it must carry recipient `p` tags even when the composer text
/// contains no `@mention`. Agent harnesses and human notification subscriptions
/// both rely on those tags.
///
/// Mirrors desktop `messageMentionPubkeys` in
/// `desktop/src/features/messages/lib/messageMentionPubkeys.ts`.
List<String> messageMentionPubkeys({
required bool isDm,
required String? senderPubkey,
required Iterable<String> explicitMentions,
required Iterable<String> memberPubkeys,
required Iterable<String> participantPubkeys,
}) {
final candidates = isDm
? [...explicitMentions, ...memberPubkeys, ...participantPubkeys]
: explicitMentions;

final selfLower = senderPubkey?.toLowerCase();
final seen = <String>{?selfLower};
return [
for (final pk in candidates)
if (pk.isNotEmpty && seen.add(pk.toLowerCase())) pk,
];
}
43 changes: 35 additions & 8 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel.dart';
import 'channel_messages_provider.dart';
import 'channels_provider.dart';
import 'message_mention_pubkeys.dart';

/// Sends messages by signing an event with the user's nsec and publishing it
/// over the relay's NIP-42-authenticated WebSocket session.
class SendMessage {
final SignedEventRelay _signedEventRelay;
final Future<List<ChannelMember>> Function(String channelId) _fetchMembers;
final Future<Channel?> Function(String channelId) _fetchChannel;
final Map<String, UserProfile> Function() _readUserCache;
final void Function(String channelId, NostrEvent event) _addLocalMessage;
final void Function(String channelId, String eventId) _completeLocalMessage;
Expand All @@ -20,13 +24,15 @@ class SendMessage {
required SignedEventRelay signedEventRelay,
required Future<List<ChannelMember>> Function(String channelId)
fetchMembers,
required Future<Channel?> Function(String channelId) fetchChannel,
required Map<String, UserProfile> Function() readUserCache,
required void Function(String channelId, NostrEvent event) addLocalMessage,
required void Function(String channelId, String eventId)
completeLocalMessage,
required void Function(String channelId, String eventId) removeLocalMessage,
}) : _signedEventRelay = signedEventRelay,
_fetchMembers = fetchMembers,
_fetchChannel = fetchChannel,
_readUserCache = readUserCache,
_addLocalMessage = addLocalMessage,
_completeLocalMessage = completeLocalMessage,
Expand All @@ -53,14 +59,28 @@ class SendMessage {
mentionPubkeys ?? await _resolveMentions(content, channelId);
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)
if (seenMentions.add(pk.toLowerCase())) pk,
];
// DMs must p-tag every other participant even without @mentions — agent
// harnesses and human notification subscriptions both rely on those tags.
// Mirrors desktop `messageMentionPubkeys`.
final channel = await _fetchChannel(channelId);
final isDm = channel?.isDm ?? false;
var memberPubkeys = const <String>[];
if (isDm) {
try {
memberPubkeys = [
for (final member in await _fetchMembers(channelId)) member.pubkey,
];
} catch (_) {
// Non-fatal — participantPubkeys alone still cover typical 1:1 DMs.
}
}
final normalizedMentions = messageMentionPubkeys(
isDm: isDm,
senderPubkey: authorPubkey,
explicitMentions: resolvedMentions,
memberPubkeys: memberPubkeys,
participantPubkeys: channel?.participantPubkeys ?? const [],
);

final tags = <List<String>>[
['h', channelId],
Expand Down Expand Up @@ -169,6 +189,13 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
),
fetchMembers: (channelId) =>
ref.read(channelMembersProvider(channelId).future),
fetchChannel: (channelId) async {
final channels = await ref.read(channelsProvider.future);
for (final channel in channels) {
if (channel.id == channelId) return channel;
}
return null;
},
readUserCache: () => ref.read(userCacheProvider),
addLocalMessage: (channelId, event) => ref
.read(channelMessagesProvider(channelId).notifier)
Expand Down
58 changes: 58 additions & 0 deletions mobile/test/features/channels/message_mention_pubkeys_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:buzz/features/channels/message_mention_pubkeys.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('messageMentionPubkeys', () {
test('plain DM messages p-tag every recipient except the sender', () {
expect(
messageMentionPubkeys(
isDm: true,
senderPubkey: 'owner',
explicitMentions: const [],
memberPubkeys: const ['OWNER', 'AGENT'],
participantPubkeys: const ['owner', 'agent'],
),
['AGENT'],
);
});

test('DM messages keep explicit mentions and dedupe case variants', () {
expect(
messageMentionPubkeys(
isDm: true,
senderPubkey: 'OWNER',
explicitMentions: const ['AGENT', 'third'],
memberPubkeys: const ['owner', 'agent'],
participantPubkeys: const ['Owner', 'Agent', 'guest'],
),
['AGENT', 'third', 'guest'],
);
});

test('stream messages keep only explicit mentions', () {
expect(
messageMentionPubkeys(
isDm: false,
senderPubkey: 'owner',
explicitMentions: const ['agent'],
memberPubkeys: const ['owner', 'agent', 'other'],
participantPubkeys: const ['someone'],
),
['agent'],
);
});

test('empty and self pubkeys are dropped', () {
expect(
messageMentionPubkeys(
isDm: true,
senderPubkey: 'me',
explicitMentions: const ['', 'me'],
memberPubkeys: const ['me', ''],
participantPubkeys: const ['you'],
),
['you'],
);
});
});
}
107 changes: 105 additions & 2 deletions mobile/test/features/channels/send_message_provider_test.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/features/channels/channel.dart';
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';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;

void main() {
test(
Expand All @@ -19,6 +21,7 @@ void main() {
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
fetchChannel: (_) async => null,
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
Expand Down Expand Up @@ -52,6 +55,7 @@ void main() {
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
fetchChannel: (_) async => null,
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
Expand All @@ -66,9 +70,108 @@ void main() {
expect(completedIds, isEmpty);
expect(removedIds, [localMessages.single.id]);
});

test('plain DM messages p-tag every recipient except the sender', () async {
final keys = nostr.Keys.generate();
final agentPubkey = nostr.Keys.generate().public;
final session = _PendingPublishRelaySession();
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec),
fetchMembers: (_) async => [
ChannelMember(
pubkey: keys.public,
role: 'member',
joinedAt: DateTime.utc(2026),
),
ChannelMember(
pubkey: agentPubkey,
role: 'bot',
joinedAt: DateTime.utc(2026),
),
],
fetchChannel: (_) async => Channel(
id: _dmChannelId,
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: keys.public,
createdAt: DateTime.utc(2026),
memberCount: 2,
participantPubkeys: [keys.public, agentPubkey],
isMember: true,
),
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

final result = send(
channelId: _dmChannelId,
content: 'hello',
mentionPubkeys: const [],
);
await session.published;
session.accept();
await result;

final pTags = [
for (final tag in session.event.tags)
if (tag.isNotEmpty && tag.first == 'p') tag[1].toLowerCase(),
];
expect(pTags, [agentPubkey.toLowerCase()]);
});

test('stream messages do not invent DM recipient p-tags', () async {
final keys = nostr.Keys.generate();
final otherPubkey = nostr.Keys.generate().public;
final session = _PendingPublishRelaySession();
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec),
fetchMembers: (_) async => [
ChannelMember(
pubkey: otherPubkey,
role: 'member',
joinedAt: DateTime.utc(2026),
),
],
fetchChannel: (_) async => Channel(
id: _channelId,
name: 'general',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: keys.public,
createdAt: DateTime.utc(2026),
memberCount: 2,
participantPubkeys: [otherPubkey],
isMember: true,
),
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

final result = send(
channelId: _channelId,
content: 'hello',
mentionPubkeys: const [],
);
await session.published;
session.accept();
await result;

expect(
session.event.tags.any((tag) => tag.isNotEmpty && tag.first == 'p'),
isFalse,
);
});
}

const _channelId = '11111111-1111-4111-8111-111111111111';
const _dmChannelId = '22222222-2222-4222-8222-222222222222';

class _PendingPublishRelaySession extends RelaySessionNotifier {
final Completer<NostrEvent> _result = Completer<NostrEvent>();
Expand Down
Loading