From 1a39c1bb336753092860fc5eb4a5b6522fa814a6 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 17:55:20 +0200 Subject: [PATCH] feat(shared): parseMentionUsernames + insertMentions helpers --- packages/shared/src/chat/index.ts | 1 + packages/shared/src/chat/mentions.test.ts | 27 ++++++++++ packages/shared/src/chat/mentions.ts | 60 +++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 packages/shared/src/chat/mentions.test.ts create mode 100644 packages/shared/src/chat/mentions.ts diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 2172065..5ca0053 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -8,6 +8,7 @@ export * from './messages'; export * from './types'; export * from './userKeyMigration'; export * from './pinnedMessages'; +export * from './mentions'; // ----- RPC wrappers --------------------------------------------------------- diff --git a/packages/shared/src/chat/mentions.test.ts b/packages/shared/src/chat/mentions.test.ts new file mode 100644 index 0000000..5730a46 --- /dev/null +++ b/packages/shared/src/chat/mentions.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMentionUsernames } from './mentions'; + +describe('parseMentionUsernames', () => { + it('extracts a leading mention', () => { + expect(parseMentionUsernames('@anna hi')).toEqual(['anna']); + }); + it('extracts mid-sentence', () => { + expect(parseMentionUsernames('hey @ben_c what do you think')).toEqual(['ben_c']); + }); + it('lowercases usernames', () => { + expect(parseMentionUsernames('hi @Anna')).toEqual(['anna']); + }); + it('deduplicates', () => { + expect(parseMentionUsernames('@xx and @xx again')).toEqual(['xx']); + }); + it('ignores emails (no preceding boundary)', () => { + expect(parseMentionUsernames('mail me at foo@bar.com')).toEqual([]); + }); + it('rejects 1-char names', () => { + expect(parseMentionUsernames('@a')).toEqual([]); + }); + it('handles multiple in one message', () => { + expect(parseMentionUsernames('@anna, @ben and @cara')).toEqual(['anna', 'ben', 'cara']); + }); +}); diff --git a/packages/shared/src/chat/mentions.ts b/packages/shared/src/chat/mentions.ts new file mode 100644 index 0000000..5fb6aff --- /dev/null +++ b/packages/shared/src/chat/mentions.ts @@ -0,0 +1,60 @@ +import type { AppSupabaseClient } from '../supabase/client'; + +// `@anna_b` style — letters, digits, underscore, dot, dash, 2-32 chars. +// Conservative on purpose: false negatives (a real username we don't match) +// are recoverable (no notification fires); false positives (matching a +// non-username) just become an INSERT that the FK check rejects. +const MENTION_RE = /(?:^|[\s,;:!?(])@([a-zA-Z0-9_.-]{2,32})/g; + +export function parseMentionUsernames(plaintext: string): string[] { + const out = new Set(); + for (const m of plaintext.matchAll(MENTION_RE)) { + if (m[1]) out.add(m[1].toLowerCase()); + } + return [...out]; +} + +export interface MentionResolver { + // Resolves an array of @usernames in this conversation to user-ids. + // Returns only memberships that exist + are accepted. + resolveUsernames(conversationId: string, usernames: string[]): Promise>; +} + +export function makeMentionResolver(client: AppSupabaseClient): MentionResolver { + return { + async resolveUsernames(conversationId, usernames) { + if (usernames.length === 0) return new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (client as any) + .from('conversation_members') + .select('user_id, accepted, profiles!inner(username)') + .eq('conversation_id', conversationId) + .eq('accepted', true) + .in('profiles.username', usernames); + if (error) throw error; + const out = new Map(); + for (const row of (data ?? []) as Array<{ user_id: string; profiles: { username: string } }>) { + out.set(row.profiles.username.toLowerCase(), row.user_id); + } + return out; + }, + }; +} + +export async function insertMentions( + client: AppSupabaseClient, + messageId: string, + conversationId: string, + mentionedUserIds: string[], +): Promise { + if (mentionedUserIds.length === 0) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error } = await (client as any).from('message_mentions').insert( + mentionedUserIds.map((uid) => ({ + message_id: messageId, + mentioned_user_id: uid, + conversation_id: conversationId, + })), + ); + if (error) throw error; +}