feat(shared): parseMentionUsernames + insertMentions helpers

This commit is contained in:
byGalax
2026-05-16 17:55:20 +02:00
parent 70209be1de
commit 1a39c1bb33
3 changed files with 88 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ export * from './messages';
export * from './types';
export * from './userKeyMigration';
export * from './pinnedMessages';
export * from './mentions';
// ----- RPC wrappers ---------------------------------------------------------
+27
View File
@@ -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']);
});
});
+60
View File
@@ -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<string>();
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<Map<string, string>>;
}
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<string, string>();
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<void> {
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;
}