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> Function(String channelId) _fetchMembers; final Map Function() _readUserCache; SendMessage({ required SignedEventRelay signedEventRelay, required Future> Function(String channelId) fetchMembers, required Map 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 call({ required String channelId, required String content, String? parentEventId, String? rootEventId, List? mentionPubkeys, List> 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 = {?selfLower}; final normalizedMentions = [ for (final pk in resolvedMentions) if (seenMentions.add(pk.toLowerCase())) pk, ]; final tags = >[ ['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> _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? 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 = {}; 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> _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((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), ); });