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
46 changes: 42 additions & 4 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import 'dart:async';

import 'package:hooks_riverpod/hooks_riverpod.dart';

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 FutureOr<Channel?> Function(String channelId) _readChannel;
final Future<List<ChannelMember>> Function(String channelId) _fetchMembers;
final Map<String, UserProfile> Function() _readUserCache;
final void Function(String channelId, NostrEvent event) _addLocalMessage;
Expand All @@ -18,6 +23,7 @@ class SendMessage {

SendMessage({
required SignedEventRelay signedEventRelay,
required FutureOr<Channel?> Function(String channelId) readChannel,
required Future<List<ChannelMember>> Function(String channelId)
fetchMembers,
required Map<String, UserProfile> Function() readUserCache,
Expand All @@ -26,6 +32,7 @@ class SendMessage {
completeLocalMessage,
required void Function(String channelId, String eventId) removeLocalMessage,
}) : _signedEventRelay = signedEventRelay,
_readChannel = readChannel,
_fetchMembers = fetchMembers,
_readUserCache = readUserCache,
_addLocalMessage = addLocalMessage,
Expand Down Expand Up @@ -53,14 +60,30 @@ class SendMessage {
mentionPubkeys ?? await _resolveMentions(content, channelId);
final authorPubkey = _signedEventRelay.pubkey;

// In a DM, fan out p-tags to every other participant so mention-gated
// agent subscriptions see plain messages. Wait for initial channel
// loading, but preserve explicit-mention sends if the list is unavailable.
Channel? channel;
try {
channel = await _readChannel(channelId);
} catch (_) {
channel = null;
Comment on lines +67 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the DM roster when channel lookup fails

When _readChannel throws or returns null while a DM route remains usable—for example, during a channelsProvider refresh error—this fallback treats the DM as a non-DM and publishes a plain message without any p tags. ACP agents subscribe through #p, so the send appears successful but the agent never receives or responds to it; the added failure tests only exercise explicit mentions, which mask this behavior. Fresh evidence relative to the earlier lookup discussion is that the current catch-and-null path now explicitly swallows the failure and selects an empty roster, so the route-resolved Channel should be supplied to preserve DM fan-out.

Useful? React with 👍 / 👎.

}
final fanoutPubkeys = (channel?.isDm ?? false)
? channel!.participantPubkeys
: const <String>[];

// 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,
];
final normalizedMentions = <String>[];
for (final pk in [...resolvedMentions, ...fanoutPubkeys]) {
final lower = pk.toLowerCase();
if (lower.isNotEmpty && seenMentions.add(lower)) {
normalizedMentions.add(lower);
}
}

final tags = <List<String>>[
['h', channelId],
Expand Down Expand Up @@ -167,6 +190,21 @@ final sendMessageProvider = Provider<SendMessage>((ref) {
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec,
),
readChannel: (channelId) {
final cachedChannel = ref
.read(channelsProvider)
.value
?.where((channel) => channel.id == channelId)
.firstOrNull;
if (cachedChannel != null) return cachedChannel;
return ref
.read(channelsProvider.future)
.then(
(channels) => channels
.where((channel) => channel.id == channelId)
.firstOrNull,
);
},
fetchMembers: (channelId) =>
ref.read(channelMembersProvider(channelId).future),
readUserCache: () => ref.read(userCacheProvider),
Expand Down
230 changes: 230 additions & 0 deletions mobile/test/features/channels/send_message_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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/send_message_provider.dart';
import 'package:buzz/shared/relay/relay.dart';

Expand All @@ -18,6 +19,7 @@ void main() {
session: session,
nsec: nostr.Keys.generate().nsec,
),
readChannel: (_) => _channel(channelType: 'stream'),
fetchMembers: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
Expand Down Expand Up @@ -51,6 +53,7 @@ void main() {
session: session,
nsec: nostr.Keys.generate().nsec,
),
readChannel: (_) => _channel(channelType: 'stream'),
fetchMembers: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, event) => localMessages.add(event),
Expand All @@ -66,14 +69,241 @@ void main() {
expect(completedIds, isEmpty);
expect(removedIds, [localMessages.single.id]);
});

test('plain DM send p-tags every participant except the sender', () async {
final session = _PendingPublishRelaySession();
final keys = nostr.Keys.generate();
final send = _sendMessage(
session: session,
nsec: keys.nsec,
readChannel: (channelId) => _channel(
channelType: 'dm',
// Roster includes the sender's own pubkey — it must be excluded.
participantPubkeys: [keys.public, _humanPubkey, _agentPubkey],
),
);

final result = send(
channelId: _channelId,
content: 'hey, can you look at this?',
mentionPubkeys: const [],
);
await session.published;
session.accept();
await result;

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

test('non-DM channel carries explicit mentions only', () async {
final session = _PendingPublishRelaySession();
final send = _sendMessage(
session: session,
nsec: nostr.Keys.generate().nsec,
readChannel: (channelId) => _channel(
channelType: 'stream',
participantPubkeys: const [_humanPubkey, _agentPubkey],
),
);

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

expect(_pTagPubkeys(session.event), [_mentionPubkey]);
});

test(
'DM recipient tags are lowercase, non-empty, and deduplicated',
() async {
final session = _PendingPublishRelaySession();
final keys = nostr.Keys.generate();
final send = _sendMessage(
session: session,
nsec: keys.nsec,
readChannel: (channelId) => _channel(
channelType: 'dm',
participantPubkeys: [keys.public, _humanPubkey, _agentPubkey, ''],
),
);

final explicitAgent = _agentPubkey.toUpperCase();
final result = send(
channelId: _channelId,
content: 'hey @agent',
mentionPubkeys: [explicitAgent, ''],
);
await session.published;
session.accept();
await result;

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

test(
'DM thread reply carries fan-out p tags alongside reply e-tags',
() async {
final session = _PendingPublishRelaySession();
final keys = nostr.Keys.generate();
final send = _sendMessage(
session: session,
nsec: keys.nsec,
readChannel: (channelId) => _channel(
channelType: 'dm',
participantPubkeys: [keys.public, _humanPubkey, _agentPubkey],
),
);

// Nested thread reply, as the thread page sends it: parent + root.
final result = send(
channelId: _channelId,
content: 'replying in-thread',
parentEventId: _parentEventId,
rootEventId: _rootEventId,
mentionPubkeys: const [],
);
await session.published;
session.accept();
await result;

expect(
session.event.tags,
containsAll([
['e', _rootEventId, '', 'root'],
['e', _parentEventId, '', 'reply'],
]),
);
expect(_pTagPubkeys(session.event), [_humanPubkey, _agentPubkey]);
},
);

test('waits for channel loading before publishing a plain DM', () async {
final session = _PendingPublishRelaySession();
final channel = Completer<Channel?>();
final send = _sendMessage(
session: session,
nsec: nostr.Keys.generate().nsec,
readChannel: (_) => channel.future,
);

final result = send(
channelId: _channelId,
content: 'hello',
mentionPubkeys: const [],
);
await Future<void>.delayed(Duration.zero);

expect(session.hasPublished, isFalse);

channel.complete(
_channel(
channelType: 'dm',
participantPubkeys: const [_humanPubkey, _agentPubkey],
),
);
await session.published;
session.accept();
await result;

expect(_pTagPubkeys(session.event), [_humanPubkey, _agentPubkey]);
});
test('sends explicit mentions when the channel is missing', () async {
final session = _PendingPublishRelaySession();
final send = _sendMessage(
session: session,
nsec: nostr.Keys.generate().nsec,
readChannel: (_) => null,
);
unawaited(session.published.then((_) => session.accept()));

await send(
channelId: _channelId,
content: 'hey @someone',
mentionPubkeys: const [_mentionPubkey],
);

expect(_pTagPubkeys(session.event), [_mentionPubkey]);
});

test('sends explicit mentions when the channel lookup fails', () async {
final session = _PendingPublishRelaySession();
final send = _sendMessage(
session: session,
nsec: nostr.Keys.generate().nsec,
readChannel: (_) =>
Future<Channel?>.error(Exception('channel list unavailable')),
);
unawaited(session.published.then((_) => session.accept()));

await send(
channelId: _channelId,
content: 'hey @someone',
mentionPubkeys: const [_mentionPubkey],
);

expect(_pTagPubkeys(session.event), [_mentionPubkey]);
});
}

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

const _humanPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const _agentPubkey =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const _mentionPubkey =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';

const _rootEventId =
'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd';
const _parentEventId =
'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';

SendMessage _sendMessage({
required _PendingPublishRelaySession session,
required String nsec,
required FutureOr<Channel?> Function(String channelId) readChannel,
}) => SendMessage(
signedEventRelay: SignedEventRelay(session: session, nsec: nsec),
readChannel: readChannel,
fetchMembers: (_) async => const [],
readUserCache: () => const {},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

Channel _channel({
required String channelType,
List<String> participantPubkeys = const [],
}) => Channel(
id: _channelId,
name: 'chat',
channelType: channelType,
visibility: 'private',
description: '',
createdBy: _humanPubkey,
createdAt: DateTime.utc(2026),
memberCount: participantPubkeys.length,
participantPubkeys: participantPubkeys,
);

List<String> _pTagPubkeys(NostrEvent event) => [
for (final tag in event.tags)
if (tag.isNotEmpty && tag.first == 'p') tag[1],
];

class _PendingPublishRelaySession extends RelaySessionNotifier {
final Completer<NostrEvent> _result = Completer<NostrEvent>();
final Completer<void> _published = Completer<void>();
late NostrEvent event;
bool get hasPublished => _published.isCompleted;

Future<void> get published => _published.future;

Expand Down