Skip to content
Open
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
28 changes: 24 additions & 4 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../channels/channels_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_messages_provider.dart';
Expand All @@ -11,6 +12,7 @@ import 'channel_messages_provider.dart';
class SendMessage {
final SignedEventRelay _signedEventRelay;
final Future<List<ChannelMember>> Function(String channelId) _fetchMembers;
final Future<bool> Function(String channelId) _isDirectMessage;
final Map<String, UserProfile> Function() _readUserCache;
final void Function(String channelId, NostrEvent event) _addLocalMessage;
final void Function(String channelId, String eventId) _completeLocalMessage;
Expand All @@ -21,6 +23,7 @@ class SendMessage {
required SignedEventRelay signedEventRelay,
required Future<List<ChannelMember>> Function(String channelId)
fetchMembers,
required Future<bool> Function(String channelId) isDirectMessage,
required Map<String, UserProfile> Function() readUserCache,
required void Function(String channelId, NostrEvent event) addLocalMessage,
required void Function(String channelId, String eventId)
Expand All @@ -29,6 +32,7 @@ class SendMessage {
bool Function()? isDeliveryValid,
}) : _signedEventRelay = signedEventRelay,
_fetchMembers = fetchMembers,
_isDirectMessage = isDirectMessage,
_readUserCache = readUserCache,
_addLocalMessage = addLocalMessage,
_completeLocalMessage = completeLocalMessage,
Expand All @@ -51,19 +55,28 @@ class SendMessage {
List<List<String>> mediaTags = const [],
}) async {
_ensureDeliveryValid();
final isDirectMessage = await _isDirectMessage(channelId);
// Use explicitly passed pubkeys, or resolve @mentions against
// channel members to avoid matching the wrong user.
final resolvedMentions =
mentionPubkeys ?? await _resolveMentions(content, channelId);
final recipients = [...resolvedMentions];
if (isDirectMessage) {
// A DM addresses every other participant even when its text contains no
// explicit @mention. Agent harnesses and human notification
// subscriptions both rely on these p-tags to wake reliably.
final members = await _fetchMembers(channelId);
recipients.addAll(members.map((member) => member.pubkey));
}
final authorPubkey = _signedEventRelay.pubkey;

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

final tags = <List<String>>[
Expand Down Expand Up @@ -182,6 +195,13 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
),
fetchMembers: (channelId) =>
ref.read(channelMembersProvider(channelId).future),
isDirectMessage: (channelId) async {
final channels = await ref.read(channelsProvider.future);
for (final channel in channels) {
if (channel.id == channelId) return channel.isDm;
}
throw StateError('Cannot determine channel type for $channelId');
},
readUserCache: () => ref.read(userCacheProvider),
addLocalMessage: (channelId, event) => ref
.read(channelMessagesProvider(channelId).notifier)
Expand Down
159 changes: 159 additions & 0 deletions mobile/test/features/channels/send_message_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
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';

Expand All @@ -20,6 +21,7 @@ void main() {
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
isDirectMessage: (_) async => false,
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
Expand Down Expand Up @@ -53,6 +55,7 @@ void main() {
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
isDirectMessage: (_) async => false,
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
Expand Down Expand Up @@ -91,6 +94,162 @@ void main() {
),
);
});

test('plain DM messages p-tag every participant except the sender', () async {
final session = _PendingPublishRelaySession();
final senderKeys = nostr.Keys.generate();
final agentPubkey = nostr.Keys.generate().public;
final humanPubkey = nostr.Keys.generate().public;
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: senderKeys.nsec,
),
fetchMembers: (_) async => [
ChannelMember(
pubkey: senderKeys.public,
role: 'owner',
joinedAt: DateTime.utc(2026),
),
ChannelMember(
pubkey: agentPubkey.toUpperCase(),
role: 'bot',
joinedAt: DateTime.utc(2026),
),
ChannelMember(
pubkey: humanPubkey,
role: 'member',
joinedAt: DateTime.utc(2026),
),
],
isDirectMessage: (_) async => true,
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

final result = send(channelId: _channelId, content: 'hello');
await session.published;

final pTags = session.event.tags
.where((tag) => tag.length >= 2 && tag[0] == 'p')
.map((tag) => tag[1])
.toList();
expect(pTags, [agentPubkey, humanPubkey]);

session.accept();
await result;
});

test('DM thread replies p-tag every participant', () async {
final session = _PendingPublishRelaySession();
final senderKeys = nostr.Keys.generate();
final recipientPubkey = nostr.Keys.generate().public;
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: senderKeys.nsec,
),
fetchMembers: (_) async => [
ChannelMember(
pubkey: senderKeys.public,
role: 'owner',
joinedAt: DateTime.utc(2026),
),
ChannelMember(
pubkey: recipientPubkey,
role: 'bot',
joinedAt: DateTime.utc(2026),
),
],
isDirectMessage: (_) async => true,
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

final result = send(
channelId: _channelId,
content: 'thread reply',
parentEventId: 'a' * 64,
rootEventId: 'b' * 64,
);
await session.published;

expect(session.event.tags.where((tag) => tag.firstOrNull == 'p').toList(), [
['p', recipientPubkey],
]);

session.accept();
await result;
});

test('stream messages preserve explicit-mention behavior', () async {
final session = _PendingPublishRelaySession();
final recipientPubkey = nostr.Keys.generate().public;
var fetchedMembers = false;
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async {
fetchedMembers = true;
return const [];
},
isDirectMessage: (_) async => false,
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

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

expect(fetchedMembers, isFalse);
expect(session.event.tags.where((tag) => tag.firstOrNull == 'p').toList(), [
['p', recipientPubkey],
]);

session.accept();
await result;
});

test('fails closed when the channel type cannot be determined', () async {
final session = _PendingPublishRelaySession();
var fetchedMembers = false;
var addedLocalMessage = false;
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async {
fetchedMembers = true;
return const [];
},
isDirectMessage: (_) async => throw StateError('unknown channel'),
readUserCache: () => const {},
addLocalMessage: (_, _) => addedLocalMessage = true,
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

await expectLater(
send(channelId: _channelId, content: 'hello'),
throwsA(isA<StateError>()),
);

expect(fetchedMembers, isFalse);
expect(addedLocalMessage, isFalse);
});
}

const _channelId = '11111111-1111-4111-8111-111111111111';
Expand Down