mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
🤖 ## Summary The mobile app's @mention chooser now follows the same rules as the desktop app (buzz-app). The same typed text finds the same people in the same order on both apps, and sending works the same way. - **Same matching and order.** Mobile uses the shared mention rules and runs their shared test cases. A multi-word search such as `@Mary J` still searches the community directory, so it can find "Mary Jane" even when she is not in the channel. - **DMs can mention people outside the DM.** The chooser marks them "not in DM". Nobody can be added to a DM, so the message sends without a prompt. Outside people go out as references, so they are not notified. This matches buzz-app #510. - **Channels still ask.** In a stream or forum, mentioning someone outside the channel asks "Invite" or "Do nothing" before the message sends. A channel with no type tag counts as a stream. - **Sessions do not search the directory.** Only session members appear. - **A failed directory search stays visible.** The chooser shows the error and a Retry button. Before, it closed. - **Search results stay in their community.** If you switch communities while a search runs, its people and its error do not show in the new community. The search starts again there. - **A failed send check keeps the draft.** Before a message with a mention sends, the app checks who is in the channel. If that check fails, the message stays in the compose bar and a notice offers Retry. Before, nothing was sent and nothing was shown. - **Invite stays in its community.** If you switch communities while the "Invite" question is open, Invite adds nobody. ### Screenshots The real compose bar widget, rendered by Flutter's test renderer with the app's fonts and theme. The data is sample data. These are not device captures. | DM: outside person is "not in DM" | Stream: outside person is "not in channel" | |---|---| | <img width="320" alt="DM chooser" src="https://github.com/user-attachments/assets/0e884518-2386-4cd1-9f19-16661aaa194e" /> | <img width="320" alt="Stream chooser" src="https://github.com/user-attachments/assets/5395e4e1-032e-459b-98c6-0126c060c4d9" /> | | Stream: send asks first | Session: members only | Search failed: Retry | |---|---|---| | <img width="260" alt="Stream ask on send" src="https://github.com/user-attachments/assets/2b988257-3c6a-445d-acfa-fca47b9752fe" /> | <img width="260" alt="Session chooser" src="https://github.com/user-attachments/assets/e193551f-f5bf-41d9-aa29-bc8665c643f9" /> | <img width="260" alt="Search failed with Retry" src="https://github.com/user-attachments/assets/e8c4b9bb-067e-4fe9-9fdd-4e5691bc66be" /> | ### Details - `mobile/lib/shared/mentions/mention_rules.dart` holds the rules: query syntax, match tiers, sort order, Space selection of an exact unique name, stable rows while results arrive, and `p` and `mention` tags. A `p` tag notifies the person. A `mention` tag is a reference and does not notify. - `mobile/test/shared/mentions/mention-rules.fixtures.json` is an exact copy of the buzz-app fixture file. `mention_rules_test.dart` runs every case. - Known difference: desktop gives the directory to a channel with an unknown explicit type (not stream, forum, DM or session). Mobile does not. No such type exists today. ### Related issue None found. Desktop counterpart: block/buzz-app#510. #7513 also edits `send_message_provider.dart` and may conflict. ### Testing `just mobile-install mobile-check mobile-test` passes: analyze clean, 2998 tests pass on `49c44653` (before rebase). The pre-push hook ran `just mobile-check && just mobile-test` again on the rebased head `de1ca080` and passed. Each review fix was mutation-checked: removing the fix makes its new test fail. --------- Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
288 lines
10 KiB
Dart
288 lines
10 KiB
Dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/mentions/mention_tags.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../channels/channel_management_provider.dart';
|
|
import '../../shared/profile/user_cache_provider.dart';
|
|
import '../../shared/profile/user_profile.dart';
|
|
import 'channel.dart';
|
|
import 'channel_messages_provider.dart';
|
|
import 'message_mention_pubkeys.dart';
|
|
import 'local_message_send_animation_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 Map<String, UserProfile> Function() _readUserCache;
|
|
final void Function(String channelId, NostrEvent event) _addLocalMessage;
|
|
final void Function(String channelId, String eventId)
|
|
_markLocalMessageForAnimation;
|
|
final void Function(String channelId, String eventId) _completeLocalMessage;
|
|
final void Function(String channelId, String eventId) _removeLocalMessage;
|
|
final bool Function()? _isDeliveryValid;
|
|
|
|
SendMessage({
|
|
required SignedEventRelay signedEventRelay,
|
|
required Future<List<ChannelMember>> Function(String channelId)
|
|
fetchMembers,
|
|
required Map<String, UserProfile> Function() readUserCache,
|
|
required void Function(String channelId, NostrEvent event) addLocalMessage,
|
|
void Function(String channelId, String eventId)?
|
|
markLocalMessageForAnimation,
|
|
required void Function(String channelId, String eventId)
|
|
completeLocalMessage,
|
|
required void Function(String channelId, String eventId) removeLocalMessage,
|
|
bool Function()? isDeliveryValid,
|
|
}) : _signedEventRelay = signedEventRelay,
|
|
_fetchMembers = fetchMembers,
|
|
_readUserCache = readUserCache,
|
|
_addLocalMessage = addLocalMessage,
|
|
_markLocalMessageForAnimation =
|
|
markLocalMessageForAnimation ?? ((_, _) {}),
|
|
_completeLocalMessage = completeLocalMessage,
|
|
_removeLocalMessage = removeLocalMessage,
|
|
_isDeliveryValid = isDeliveryValid;
|
|
|
|
/// Send a text message to a channel.
|
|
///
|
|
/// For thread replies, pass [parentEventId] and optionally [rootEventId].
|
|
/// If [rootEventId] is null it defaults to [parentEventId] (direct reply to
|
|
/// thread head). Tags are built to match the desktop's `buildReplyTags`
|
|
/// convention with `root` / `reply` markers. Pass [mediaTags] to append
|
|
/// relay-validated `imeta` tags and NIP-30 `emoji` tags.
|
|
Future<void> call({
|
|
required String channelId,
|
|
required String content,
|
|
String? parentEventId,
|
|
String? rootEventId,
|
|
List<String>? mentionPubkeys,
|
|
Channel? channel,
|
|
List<List<String>> mediaTags = const [],
|
|
}) async {
|
|
_ensureDeliveryValid();
|
|
// Use explicitly passed pubkeys, or resolve @mentions against
|
|
// channel members to avoid matching the wrong user.
|
|
final explicitMentions =
|
|
mentionPubkeys ?? await _resolveMentions(content, channelId);
|
|
final authorPubkey = _signedEventRelay.pubkey;
|
|
final dmRecipientPubkeys = channel?.isDm == true
|
|
? await _fetchDmRecipientPubkeys(channelId, channel!, authorPubkey)
|
|
: null;
|
|
final resolvedMentions = dmRecipientPubkeys != null
|
|
? messageMentionPubkeys(
|
|
channel: channel!,
|
|
senderPubkey: authorPubkey,
|
|
explicitMentions: explicitMentions,
|
|
dmRecipientPubkeys: dmRecipientPubkeys,
|
|
)
|
|
: explicitMentions;
|
|
|
|
// 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,
|
|
];
|
|
|
|
// Two-field `mention` tags are references; they go through the same
|
|
// writer as recipients so a bad key rejects the message.
|
|
bool isReference(List<String> tag) =>
|
|
tag.length == 2 && tag[0] == 'mention';
|
|
final references = [
|
|
for (final tag in mediaTags)
|
|
if (isReference(tag)) tag[1],
|
|
];
|
|
final mentionTags = writeMentionTags(
|
|
recipients: normalizedMentions,
|
|
references: references,
|
|
members: normalizedMentions.isEmpty
|
|
? const {}
|
|
: await _recipientMembers(channelId, dmRecipientPubkeys, selfLower),
|
|
);
|
|
|
|
final tags = <List<String>>[
|
|
['h', channelId],
|
|
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
|
|
...mentionTags,
|
|
for (final tag in mediaTags)
|
|
if (!isReference(tag)) tag,
|
|
];
|
|
|
|
_ensureDeliveryValid();
|
|
NostrEvent? localMessage;
|
|
try {
|
|
await _signedEventRelay.submit(
|
|
kind: EventKind.streamMessage,
|
|
content: content,
|
|
tags: tags,
|
|
onSigned: (event) {
|
|
localMessage = event;
|
|
_markLocalMessageForAnimation(channelId, event.id);
|
|
_addLocalMessage(channelId, event);
|
|
},
|
|
);
|
|
final event = localMessage;
|
|
if (event != null) _completeLocalMessage(channelId, event.id);
|
|
} catch (_) {
|
|
final event = localMessage;
|
|
if (event != null) _removeLocalMessage(channelId, event.id);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Resolve every identity that is actually a current member of this DM.
|
|
///
|
|
/// Membership is authoritative for delivery. The channel metadata's `p`
|
|
/// tags can lag membership changes, so they are only used when the membership
|
|
/// snapshot is unavailable.
|
|
Future<Set<String>> _fetchDmRecipientPubkeys(
|
|
String channelId,
|
|
Channel channel,
|
|
String? authorPubkey,
|
|
) async {
|
|
List<ChannelMember>? members;
|
|
try {
|
|
members = await _fetchMembers(channelId);
|
|
} catch (_) {
|
|
// Fall back to metadata below so an unavailable membership query does
|
|
// not block ordinary DM sends.
|
|
}
|
|
|
|
final author = authorPubkey?.toLowerCase();
|
|
return {
|
|
for (final participant in dmParticipantPubkeys(channel, members))
|
|
if (participant.trim().isNotEmpty &&
|
|
participant.toLowerCase() != author)
|
|
participant.toLowerCase(),
|
|
};
|
|
}
|
|
|
|
/// Keys that can be recipients: DM participants, or the channel's current
|
|
/// members. The sender always counts as a member.
|
|
Future<Set<String>> _recipientMembers(
|
|
String channelId,
|
|
Set<String>? dmRecipientPubkeys,
|
|
String? self,
|
|
) async {
|
|
final members =
|
|
dmRecipientPubkeys ??
|
|
{
|
|
for (final member in await _fetchMembers(channelId))
|
|
member.pubkey.toLowerCase(),
|
|
};
|
|
return {...members, ?self};
|
|
}
|
|
|
|
void _ensureDeliveryValid() {
|
|
if (_isDeliveryValid?.call() == false) {
|
|
throw StateError(
|
|
'Message delivery cancelled because the active community changed',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Resolve @mentions to pubkeys, scoped to channel members.
|
|
///
|
|
/// Fetches channel members from the relay and matches @names only
|
|
/// against members of that channel. Falls back to the full user cache
|
|
/// if the member fetch fails.
|
|
Future<List<String>> _resolveMentions(
|
|
String content,
|
|
String channelId,
|
|
) async {
|
|
final mentionPattern = RegExp(r'@(\w+)');
|
|
final matches = mentionPattern.allMatches(content);
|
|
if (matches.isEmpty) return const [];
|
|
|
|
// Try to get channel member pubkeys for scoped resolution.
|
|
Set<String>? memberPubkeys;
|
|
try {
|
|
final members = await _fetchMembers(channelId);
|
|
memberPubkeys = {for (final m in members) m.pubkey.toLowerCase()};
|
|
} catch (_) {
|
|
// Non-fatal — fall through to unscoped cache lookup.
|
|
}
|
|
|
|
final cache = _readUserCache();
|
|
final pubkeys = <String>{};
|
|
|
|
for (final match in matches) {
|
|
final name = match.group(1)?.toLowerCase();
|
|
if (name == null || name.isEmpty) continue;
|
|
|
|
for (final profile in cache.values) {
|
|
final displayName = profile.displayName?.toLowerCase();
|
|
if (displayName == null) continue;
|
|
|
|
// Match against full display name or first word.
|
|
final firstName = displayName.split(RegExp(r'\s+')).first;
|
|
if (displayName != name && firstName != name) continue;
|
|
|
|
// If we have channel members, only match members of this channel.
|
|
if (memberPubkeys != null &&
|
|
!memberPubkeys.contains(profile.pubkey.toLowerCase())) {
|
|
continue;
|
|
}
|
|
|
|
pubkeys.add(profile.pubkey);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return pubkeys.toList();
|
|
}
|
|
|
|
/// Build `e`-tags for a thread reply, matching the desktop convention:
|
|
/// - Direct reply to thread head: `["e", id, "", "reply"]`
|
|
/// - Nested reply: `["e", rootId, "", "root"]` + `["e", parentId, "", "reply"]`
|
|
static List<List<String>> _buildReplyTags(
|
|
String parentEventId,
|
|
String? rootEventId,
|
|
) {
|
|
final root = rootEventId ?? parentEventId;
|
|
if (parentEventId == root) {
|
|
return [
|
|
['e', root, '', 'reply'],
|
|
];
|
|
}
|
|
return [
|
|
['e', root, '', 'root'],
|
|
['e', parentEventId, '', 'reply'],
|
|
];
|
|
}
|
|
}
|
|
|
|
final sendMessageProvider = Provider<SendMessage>((ref) {
|
|
final config = ref.watch(relayConfigProvider);
|
|
return SendMessage(
|
|
signedEventRelay: SignedEventRelay(
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
nsec: config.nsec,
|
|
),
|
|
fetchMembers: (channelId) =>
|
|
ref.read(channelMembersProvider(channelId).future),
|
|
readUserCache: () => ref.read(userCacheProvider),
|
|
addLocalMessage: (channelId, event) => ref
|
|
.read(channelMessagesProvider(channelId).notifier)
|
|
.addLocalMessage(event),
|
|
markLocalMessageForAnimation: (channelId, eventId) => ref
|
|
.read(localMessageSendAnimationProvider(channelId).notifier)
|
|
.mark(eventId),
|
|
completeLocalMessage: (channelId, eventId) => ref
|
|
.read(channelMessagesProvider(channelId).notifier)
|
|
.completeLocalMessage(eventId),
|
|
removeLocalMessage: (channelId, eventId) => ref
|
|
.read(channelMessagesProvider(channelId).notifier)
|
|
.removeLocalMessage(eventId),
|
|
isDeliveryValid: () {
|
|
final currentConfig = ref.read(relayConfigProvider);
|
|
return currentConfig.baseUrl == config.baseUrl &&
|
|
currentConfig.nsec == config.nsec;
|
|
},
|
|
);
|
|
});
|