Barazo AppView backend
barazo.forum
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}