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
21 changes: 19 additions & 2 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ class SendMessage {
final name = match.group(1)?.toLowerCase();
if (name == null || name.isEmpty) continue;

// Collect every profile whose display name matches, rather than taking
// the first one and breaking out. Two members can legitimately share a
// display name — a stale identity left behind by a re-key is the common
// case — and `cache` is a Map, so "the first match" really means
// "whichever entry the map happened to yield first". That made mention
// delivery depend on map ordering: for some names the `p` tag landed on
// the live identity and the mention worked, for others it landed on a
// dead one and the mention silently reached nobody, with nothing in the
// UI to distinguish the two.
final candidates = <String>[];
for (final profile in cache.values) {
final displayName = profile.displayName?.toLowerCase();
if (displayName == null) continue;
Expand All @@ -132,9 +142,16 @@ class SendMessage {
continue;
}

pubkeys.add(profile.pubkey);
break;
candidates.add(profile.pubkey);
}

// An ambiguous @name tags every candidate. `buzz-cli` rejects the send
// outright here and tells the user to retry with an explicit
// `--mention <pubkey>`, but mobile has no way to supply one, so failing
// would leave a duplicated name permanently unmentionable. Tagging all
// candidates keeps the intended recipient reachable; the cost is a
// redundant `p` tag on the identities sharing that name.
pubkeys.addAll(candidates);
}

return pubkeys.toList();
Expand Down
55 changes: 55 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,7 +2,9 @@ import 'dart:async';

import 'package:flutter_test/flutter_test.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/features/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';

void main() {
Expand Down Expand Up @@ -41,6 +43,59 @@ void main() {
},
);

test(
'tags every candidate when a display name is ambiguous',
() async {
// Two channel members share the display name "Neuromancer": a stale
// identity left behind by a re-key, and the live one. The stale entry is
// deliberately first in the cache — resolution used to take the first
// match and stop, so the `p` tag landed on the dead identity and the
// real agent was never notified. Nothing surfaced to the sender.
const staleP = 'f1a06b925f6705ef530932138664e2dda35fd5d743991f49e96';
const liveP = '1667bcebe81f2e5af1e5561a385f1c8a91b84d8d19c5d9c3f1b';

final session = _PendingPublishRelaySession();
final send = SendMessage(
signedEventRelay: SignedEventRelay(
session: session,
nsec: nostr.Keys.generate().nsec,
),
fetchMembers: (_) async => [
ChannelMember(
pubkey: staleP,
role: 'bot',
joinedAt: DateTime.utc(2020),
),
ChannelMember(
pubkey: liveP,
role: 'bot',
joinedAt: DateTime.utc(2020),
),
],
readUserCache: () => const {
staleP: UserProfile(pubkey: staleP, displayName: 'Neuromancer'),
liveP: UserProfile(pubkey: liveP, displayName: 'Neuromancer'),
},
addLocalMessage: (_, _) {},
completeLocalMessage: (_, _) {},
removeLocalMessage: (_, _) {},
);

final result = send(channelId: _channelId, content: '@Neuromancer hi');
await session.published;
session.accept();
await result;

final tagged = session.event.tags
.where((t) => t.isNotEmpty && t.first == 'p')
.map((t) => t[1])
.toSet();

// Both must be tagged; tagging only one is a coin flip on map ordering.
expect(tagged, containsAll(<String>[staleP, liveP]));
},
);

test('rolls back the signed local message when publish fails', () async {
final session = _PendingPublishRelaySession();
final localMessages = <NostrEvent>[];
Expand Down