Barazo AppView backend barazo.forum
at main 106 lines 3.2 kB view raw
1import { eq, and } from 'drizzle-orm' 2import type { Database } from '../db/index.js' 3import { userPreferences, userCommunityPreferences } from '../db/schema/user-preferences.js' 4 5// --------------------------------------------------------------------------- 6// Loader 7// --------------------------------------------------------------------------- 8 9/** 10 * Load the authenticated user's muted words list (global + per-community merged). 11 * 12 * When a communityDid is provided, per-community muted words are merged with 13 * global ones (union, deduplicated). A null per-community list means "use 14 * global only" (no override). 15 * 16 * Returns empty array when the user is not authenticated or has no preferences. 17 */ 18export async function loadMutedWords( 19 userDid: string | undefined, 20 communityDid: string | undefined, 21 db: Database 22): Promise<string[]> { 23 if (!userDid) { 24 return [] 25 } 26 27 // Fetch global muted words 28 const globalRows = await db 29 .select({ mutedWords: userPreferences.mutedWords }) 30 .from(userPreferences) 31 .where(eq(userPreferences.did, userDid)) 32 33 const globalWords: string[] = globalRows[0]?.mutedWords ?? [] 34 35 // If no community context, return global only 36 if (!communityDid) { 37 return globalWords 38 } 39 40 // Fetch per-community override 41 const communityRows = await db 42 .select({ mutedWords: userCommunityPreferences.mutedWords }) 43 .from(userCommunityPreferences) 44 .where( 45 and( 46 eq(userCommunityPreferences.did, userDid), 47 eq(userCommunityPreferences.communityDid, communityDid) 48 ) 49 ) 50 51 const communityWords: string[] | null = communityRows[0]?.mutedWords ?? null 52 53 // null = no override, use global only 54 if (communityWords === null) { 55 return globalWords 56 } 57 58 // Merge and deduplicate (union of global + community) 59 return [...new Set([...globalWords, ...communityWords])] 60} 61 62// --------------------------------------------------------------------------- 63// Matcher 64// --------------------------------------------------------------------------- 65 66/** 67 * Escape regex special characters in a string so it can be used as a literal 68 * match inside a RegExp. 69 */ 70function escapeRegex(str: string): string { 71 return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 72} 73 74/** 75 * Check whether content (and optionally a title) matches any muted word. 76 * 77 * Matching rules: 78 * - Case-insensitive 79 * - Word-boundary matching (whole words only, not partial) 80 * - Multi-word phrases supported 81 * - Regex special characters in muted words are escaped 82 * 83 * @param content - The content body text 84 * @param mutedWords - The user's merged muted words list 85 * @param title - Optional title to also check (for topics) 86 */ 87export function contentMatchesMutedWords( 88 content: string, 89 mutedWords: string[], 90 title?: string 91): boolean { 92 if (mutedWords.length === 0) return false 93 94 const text = title ? `${title} ${content}` : content 95 if (text.length === 0) return false 96 97 for (const word of mutedWords) { 98 const escaped = escapeRegex(word) 99 const pattern = new RegExp(`(?:^|\\b|(?<=\\W))${escaped}(?:$|\\b|(?=\\W))`, 'i') 100 if (pattern.test(text)) { 101 return true 102 } 103 } 104 105 return false 106}