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
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 @@ -4,13 +4,16 @@ 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';

/// 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 Channel? Function(String channelId) _readChannel;
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 +24,7 @@ class SendMessage {
required SignedEventRelay signedEventRelay,
required Future<List<ChannelMember>> Function(String channelId)
fetchMembers,
required Channel? Function(String channelId) readChannel,
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 +33,7 @@ class SendMessage {
bool Function()? isDeliveryValid,
}) : _signedEventRelay = signedEventRelay,
_fetchMembers = fetchMembers,
_readChannel = readChannel,
_readUserCache = readUserCache,
_addLocalMessage = addLocalMessage,
_completeLocalMessage = completeLocalMessage,
Expand Down Expand Up @@ -56,14 +61,21 @@ class SendMessage {
final resolvedMentions =
mentionPubkeys ?? await _resolveMentions(content, channelId);
final authorPubkey = _signedEventRelay.pubkey;
final channel = _readChannel(channelId);
final recipientPubkeys = <String>[
...resolvedMentions,
if (channel?.isDm == true) ...channel!.participantPubkeys,
];

// Normalize mentions: lowercase, deduplicate, exclude self (matching
// the desktop's normalizeMentionPubkeys).
// A DM addresses every other participant even when the composer text has
// no @mention. Agent harnesses and human notification subscriptions rely
// on those p tags, 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 recipientPubkeys)
if (pk.trim().isNotEmpty && seenMentions.add(pk.trim().toLowerCase()))
pk.trim().toLowerCase(),
];

final tags = <List<String>>[
Expand Down Expand Up @@ -182,6 +194,14 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
),
fetchMembers: (channelId) =>
ref.read(channelMembersProvider(channelId).future),
readChannel: (channelId) {
final channels = ref.read(channelsProvider).value;
if (channels == null) return null;
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
84 changes: 84 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.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 [],
readChannel: (_) => _streamChannel,
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 [],
readChannel: (_) => _streamChannel,
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
completeLocalMessage: (_, eventId) => completedIds.add(eventId),
Expand All @@ -68,6 +71,75 @@ void main() {
expect(removedIds, [localMessages.single.id]);
});

test('adds every other DM participant as a p tag', () async {
final session = _PendingPublishRelaySession();
final keys = nostr.Keys.generate();
const recipient =
'ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789';
final send = SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec),
fetchMembers: (_) async => const [],
readChannel: (_) => Channel(
id: _channelId,
name: 'Direct message',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: keys.public,
createdAt: DateTime.utc(2026),
memberCount: 2,
participantPubkeys: [recipient, keys.public, recipient],
isMember: true,
),
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

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

expect(session.event.tags.where((tag) => tag.first == 'p'), [
['p', recipient.toLowerCase()],
]);

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

test('does not add channel participants to stream messages', () async {
final session = _PendingPublishRelaySession();
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => const [],
readChannel: (_) => _streamChannel,
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

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

expect(session.event.tags.where((tag) => tag.first == 'p'), isEmpty);

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

test('cancels delivery after the active community changes', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
Expand All @@ -94,6 +166,18 @@ void main() {
}

const _channelId = '11111111-1111-4111-8111-111111111111';
final _streamChannel = Channel(
id: _channelId,
name: 'General',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'creator',
createdAt: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
memberCount: 2,
participantPubkeys: ['participant'],
isMember: true,
);

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