feat(shared): parseMentionUsernames + insertMentions helpers
This commit is contained in:
@@ -8,6 +8,7 @@ export * from './messages';
|
||||
export * from './types';
|
||||
export * from './userKeyMigration';
|
||||
export * from './pinnedMessages';
|
||||
export * from './mentions';
|
||||
|
||||
// ----- RPC wrappers ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user