mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
151 lines
5.1 KiB
Dart
151 lines
5.1 KiB
Dart
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';
|
|
|
|
/// 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;
|
|
|
|
SendMessage({
|
|
required SignedEventRelay signedEventRelay,
|
|
required Future<List<ChannelMember>> Function(String channelId)
|
|
fetchMembers,
|
|
required Map<String, UserProfile> Function() readUserCache,
|
|
}) : _signedEventRelay = signedEventRelay,
|
|
_fetchMembers = fetchMembers,
|
|
_readUserCache = readUserCache;
|
|
|
|
/// 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,
|
|
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);
|
|
final authorPubkey = _signedEventRelay.pubkey;
|
|
|
|
// 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 tags = <List<String>>[
|
|
['h', channelId],
|
|
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
|
|
for (final pk in normalizedMentions) ['p', pk],
|
|
...mediaTags,
|
|
];
|
|
|
|
await _signedEventRelay.submit(
|
|
kind: EventKind.streamMessage,
|
|
content: content,
|
|
tags: tags,
|
|
);
|
|
}
|
|
|
|
/// 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),
|
|
);
|
|
});
|