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
30 changes: 25 additions & 5 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import '../channels/channel_management_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_messages_provider.dart';
import 'channels_provider.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<List<String>> Function(String channelId)
_fetchImplicitRecipients;
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 +23,16 @@ class SendMessage {
required SignedEventRelay signedEventRelay,
required Future<List<ChannelMember>> Function(String channelId)
fetchMembers,
required Future<List<String>> Function(String channelId)
fetchImplicitRecipients,
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,
_fetchImplicitRecipients = fetchImplicitRecipients,
_readUserCache = readUserCache,
_addLocalMessage = addLocalMessage,
_completeLocalMessage = completeLocalMessage,
Expand All @@ -47,10 +53,15 @@ class SendMessage {
List<String>? mentionPubkeys,
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);
// Use explicitly passed pubkeys, or resolve @mentions against channel
// members to avoid matching the wrong user. DMs also address every other
// participant implicitly, matching desktop behavior and allowing agent
// DMs (including multi-person group DMs) to work without visible @mentions.
final resolvedMentions = <String>[
...?mentionPubkeys,
if (mentionPubkeys == null) ...await _resolveMentions(content, channelId),
...await _fetchImplicitRecipients(channelId),
];
final authorPubkey = _signedEventRelay.pubkey;

// Normalize mentions: lowercase, deduplicate, exclude self (matching
Expand All @@ -59,7 +70,7 @@ class SendMessage {
final seenMentions = <String>{?selfLower};
final normalizedMentions = <String>[
for (final pk in resolvedMentions)
if (seenMentions.add(pk.toLowerCase())) pk,
if (seenMentions.add(pk.toLowerCase())) pk.toLowerCase(),
];

final tags = <List<String>>[
Expand Down Expand Up @@ -169,6 +180,15 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
),
fetchMembers: (channelId) =>
ref.read(channelMembersProvider(channelId).future),
fetchImplicitRecipients: (channelId) async {
final channels = await ref.read(channelsProvider.future);
for (final channel in channels) {
if (channel.id == channelId) {
return channel.isDm ? channel.participantPubkeys : const [];
}
}
return const [];
},
readUserCache: () => ref.read(userCacheProvider),
addLocalMessage: (channelId, event) => ref
.read(channelMessagesProvider(channelId).notifier)
Expand Down
69 changes: 69 additions & 0 deletions mobile/test/features/channels/send_message_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ void main() {
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
fetchImplicitRecipients: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
Expand Down Expand Up @@ -52,6 +53,7 @@ void main() {
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
fetchImplicitRecipients: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
Expand All @@ -66,9 +68,76 @@ void main() {
expect(completedIds, isEmpty);
expect(removedIds, [localMessages.single.id]);
});

test(
'adds DM participants as implicit recipients without @mentions',
() async {
final keys = nostr.Keys.generate();
final session = _PendingPublishRelaySession();
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec),
fetchMembers: (_) async => const [],
fetchImplicitRecipients: (_) async => [
keys.public.toUpperCase(),
_agentPubkey.toUpperCase(),
_agentPubkey,
_humanPubkey,
],
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

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

expect(_pTags(session.event), [_agentPubkey, _humanPubkey]);

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

test('merges explicit mentions with implicit DM recipients', () async {
final session = _PendingPublishRelaySession();
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
fetchImplicitRecipients: (_) async => const [_agentPubkey],
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

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

expect(_pTags(session.event), [_humanPubkey, _agentPubkey]);

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

const _channelId = '11111111-1111-4111-8111-111111111111';
const _agentPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const _humanPubkey =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';

List<String> _pTags(NostrEvent event) => [
for (final tag in event.tags)
if (tag.length >= 2 && tag[0] == 'p') tag[1],
];

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