@meith/web 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ import { BOARD_MEASURE } from '@/components/shell/measure'
13
13
  import { ViewTabs } from '@/components/shell/view-tabs'
14
14
  import { liveAnnouncements } from '@/server/announcements'
15
15
  import { getContainer } from '@/server/container'
16
+ import { activeWordFilter } from '@/server/content-admin'
16
17
  import { getActor } from '@/server/context'
17
18
  import { identitiesFor } from '@/server/group-identity'
18
19
  import { getTranslator } from '@/server/i18n'
@@ -239,6 +240,7 @@ export default async function ForumPage({
239
240
  now: new Date(),
240
241
  t: await getTranslator(),
241
242
  identities,
243
+ wordFilter: await activeWordFilter(),
242
244
  })
243
245
 
244
246
  const { subscriptions } = getContainer()
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'
4
4
  import { isAppError } from '@meith/core'
5
5
  import { type DiscoveryViewModel, requireSlot, slotCopy } from '@meith/theme-kit'
6
6
 
7
+ import { activeWordFilter } from '@/server/content-admin'
7
8
  import { getActor } from '@/server/context'
8
9
  import { DISCOVER_PAGE, DISCOVERY_VIEWS, isDiscoveryView, runDiscovery } from '@/server/discovery'
9
10
  import { getTranslator } from '@/server/i18n'
@@ -65,6 +66,7 @@ export default async function DiscoverPage({
65
66
  isFirstPage: after === null,
66
67
  now,
67
68
  timeZone: preferences.timezone,
69
+ wordFilter: await activeWordFilter(),
68
70
  } as const
69
71
 
70
72
  let page: Awaited<ReturnType<typeof runDiscovery>>
@@ -3,6 +3,7 @@ import type { Metadata } from 'next'
3
3
  import { Card, CardFooter, CardRows, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
4
4
 
5
5
  import { PanelPage } from '@/components/shell/panel-page'
6
+ import { activeWordFilter } from '@/server/content-admin'
6
7
  import { getActor } from '@/server/context'
7
8
  import { identitiesFor } from '@/server/group-identity'
8
9
  import { getTranslator, tr } from '@/server/i18n'
@@ -37,6 +38,7 @@ export default async function OnlinePage() {
37
38
  }
38
39
 
39
40
  const identities = await identitiesFor(snapshot.members.map((member) => member.userId))
41
+ const wordFilter = await activeWordFilter()
40
42
 
41
43
  return (
42
44
  <PanelPage
@@ -63,7 +65,7 @@ export default async function OnlinePage() {
63
65
  ) : (
64
66
  <CardRows>
65
67
  {snapshot.members.map((member) => {
66
- const where = locationOf(member, translator)
68
+ const where = locationOf(member, translator, wordFilter)
67
69
  return (
68
70
  <li
69
71
  key={member.userId}
@@ -7,6 +7,7 @@ import { liveAnnouncements } from '@/server/announcements'
7
7
  import { LATEST_REFRESH_SECONDS, renderLatestPanels } from '@/server/board-latest'
8
8
  import { refreshLatestPanels } from '@/server/board-latest-actions'
9
9
  import { getContainer } from '@/server/container'
10
+ import { activeWordFilter } from '@/server/content-admin'
10
11
  import { getActor } from '@/server/context'
11
12
  import { identitiesFor } from '@/server/group-identity'
12
13
  import { getTranslator, tr } from '@/server/i18n'
@@ -54,6 +55,8 @@ export default async function BoardIndexPage() {
54
55
  ]),
55
56
  )
56
57
 
58
+ const wordFilter = await activeWordFilter()
59
+
57
60
  const view = buildBoardIndexView({
58
61
  rows,
59
62
  visibleForumIds: new Set(listing.visibleForumIds),
@@ -63,6 +66,7 @@ export default async function BoardIndexPage() {
63
66
  now,
64
67
  t: translator,
65
68
  identities,
69
+ wordFilter,
66
70
  })
67
71
 
68
72
  const announcements = await liveAnnouncements({
@@ -127,6 +131,7 @@ export default async function BoardIndexPage() {
127
131
  now,
128
132
  t: translator,
129
133
  identities,
134
+ wordFilter,
130
135
  }),
131
136
  pluginContext,
132
137
  )
@@ -7,6 +7,7 @@ import { SubscriptionRowForm } from '@/components/account/subscription-forms'
7
7
  import { BoardNotice } from '@/components/shell/board-notice'
8
8
  import { PanelPage } from '@/components/shell/panel-page'
9
9
  import { getContainer } from '@/server/container'
10
+ import { activeWordFilter } from '@/server/content-admin'
10
11
  import { getActor } from '@/server/context'
11
12
  import { getTranslator, tr } from '@/server/i18n'
12
13
  import { followFormCopy } from '@/view/account-copy'
@@ -37,7 +38,12 @@ export default async function SubscriptionsPage({
37
38
 
38
39
  const translator = await getTranslator()
39
40
 
40
- const view = buildSubscriptionsView({ rows, now: new Date(), t: translator })
41
+ const view = buildSubscriptionsView({
42
+ rows,
43
+ now: new Date(),
44
+ t: translator,
45
+ wordFilter: await activeWordFilter(),
46
+ })
41
47
  const notice = subscriptionNotice(query)
42
48
 
43
49
  return (
@@ -54,6 +54,7 @@ import { locatedHref } from '@/view/post-link'
54
54
  import { leadingId } from '@/view/slug-id'
55
55
  import { buildSubscriptionsView } from '@/view/subscriptions'
56
56
  import { buildThreadView, revealedFrom, threadToolsHeading } from '@/view/thread-view'
57
+ import { filterWords } from '@/view/word-filter'
57
58
 
58
59
  export async function generateMetadata({
59
60
  params,
@@ -102,7 +103,7 @@ export async function generateMetadata({
102
103
  })
103
104
 
104
105
  const meta = await pageMetadata('/thread/[slug]', {
105
- title: thread.title,
106
+ title: filterWords(thread.title, await activeWordFilter()),
106
107
  description: t.t('threadPage.discussion', { forum: forum.title }),
107
108
  canonical: links.canonical,
108
109
  imageUrl: null,
@@ -424,7 +425,7 @@ export default async function ThreadPage({
424
425
  opening === null
425
426
  ? null
426
427
  : threadJsonLd({
427
- title: thread.title,
428
+ title: filterWords(thread.title, wordFilter),
428
429
  url: `/thread/${thread.id}-${thread.slug}`,
429
430
  author: thread.authorUsername,
430
431
  published: opening.createdAt,
@@ -573,9 +574,15 @@ export default async function ThreadPage({
573
574
  toolRights.delete ||
574
575
  surgeryRights.merge ||
575
576
  surgeryRights.split
577
+ const threadHeader = await pluginRegion('thread.header', {
578
+ viewer: viewerRef(actor),
579
+ subjectId: thread.id,
580
+ authorId: thread.authorUserId,
581
+ })
576
582
  const tools =
577
- !anyTool && poll === null ? undefined : (
583
+ !anyTool && poll === null && threadHeader === null ? undefined : (
578
584
  <>
585
+ {threadHeader}
579
586
  {anyTool && (
580
587
  <ThreadToolsForm
581
588
  copy={moderationFormsCopy(await getTranslator())}
@@ -656,7 +663,7 @@ export default async function ThreadPage({
656
663
  forums: allForums,
657
664
  forumId: forum.id,
658
665
  visibleForumIds: new Set(visibleIds),
659
- leaf: thread.title,
666
+ leaf: filterWords(thread.title, wordFilter),
660
667
  homeLabel: (await getSettings()).get('board.name'),
661
668
  })
662
669
 
@@ -9,6 +9,7 @@ import { ReplyForm } from '@/components/content/reply-form'
9
9
  import { OnboardingBanner } from '@/components/shell/onboarding-banner'
10
10
  import { attachmentLimits, canAttach } from '@/server/attachments'
11
11
  import { getContainer } from '@/server/container'
12
+ import { activeWordFilter } from '@/server/content-admin'
12
13
  import { getActor } from '@/server/context'
13
14
  import { getTranslator, tr } from '@/server/i18n'
14
15
  import { firstPostGuidance } from '@/server/onboarding'
@@ -76,6 +77,7 @@ export default async function ReplyPage({
76
77
  const view = buildReplyView({
77
78
  t: await getTranslator(),
78
79
  thread: { id: target.threadId, title: target.title, slug: target.slug },
80
+ wordFilter: await activeWordFilter(),
79
81
  errorMessage: locked ? (await getTranslator()).t('board.reply.locked') : null,
80
82
  })
81
83
 
@@ -0,0 +1,94 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ import { BAN_FILTER_TYPES } from '@meith/accounts'
4
+
5
+ import { NewBanFilterForm, RemoveBanFilterForm } from '@/components/admin/ban-filter-forms'
6
+ import { PANEL_CARD, PANEL_NOTE } from '@/components/shell/panel-list'
7
+ import { PanelPage } from '@/components/shell/panel-page'
8
+ import { adminPageContext } from '@/server/admin'
9
+ import { banFilterAuthors, boardBanFilters } from '@/server/ban-filter-admin'
10
+ import { getTranslator, tr } from '@/server/i18n'
11
+ import { BAN_FILTER_TYPE_KEY, banFilterAdminCopy } from '@/view/admin-ban-filter-copy'
12
+ import { formatDate } from '@/view/time'
13
+
14
+ export async function generateMetadata(): Promise<Metadata> {
15
+ return { title: await tr('page.ban-filters') }
16
+ }
17
+
18
+ export default async function AdminBanFiltersPage() {
19
+ if ((await adminPageContext()) === null) return null
20
+
21
+ const filters = await boardBanFilters().listForAdmin()
22
+ const authors = await banFilterAuthors(filters)
23
+ const translator = await getTranslator()
24
+ const copy = banFilterAdminCopy(translator)
25
+
26
+ const types = BAN_FILTER_TYPES.map((type) => ({
27
+ value: type,
28
+ label: translator.t(BAN_FILTER_TYPE_KEY[type]),
29
+ }))
30
+
31
+ return (
32
+ <PanelPage
33
+ back={{ href: '/admin/users', label: translator.t('adminUsers.allMembers') }}
34
+ title={await tr('page.ban-filters')}
35
+ lede={translator.t('adminBanFilters.lede')}
36
+ gap="loose"
37
+ >
38
+ <section className={PANEL_CARD}>
39
+ <h2 className="font-heading text-lg font-semibold">
40
+ {translator.t('adminBanFilters.held')}
41
+ </h2>
42
+
43
+ {filters.length === 0 ? (
44
+ <p className="text-sm text-muted-foreground">{translator.t('adminBanFilters.none')}</p>
45
+ ) : (
46
+ <div className="flex flex-col divide-y divide-border">
47
+ {filters.map((filter) => (
48
+ <div
49
+ key={filter.id}
50
+ className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1 py-3"
51
+ >
52
+ <div className="flex flex-col gap-1">
53
+ <p className="text-sm">
54
+ <span className="font-medium">
55
+ {translator.t(BAN_FILTER_TYPE_KEY[filter.type])}
56
+ </span>{' '}
57
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
58
+ {filter.pattern}
59
+ </code>
60
+ </p>
61
+ {filter.note === null ? null : (
62
+ <p className="text-xs text-muted-foreground">{filter.note}</p>
63
+ )}
64
+ <p className="text-xs text-muted-foreground">
65
+ {filter.createdByUserId === null ||
66
+ authors.get(filter.createdByUserId) === undefined
67
+ ? translator.t('adminBanFilters.addedOn', {
68
+ date: formatDate(filter.createdAt, translator).label,
69
+ })
70
+ : translator.t('adminBanFilters.addedBy', {
71
+ author: authors.get(filter.createdByUserId) ?? '',
72
+ date: formatDate(filter.createdAt, translator).label,
73
+ })}
74
+ </p>
75
+ </div>
76
+
77
+ <RemoveBanFilterForm id={filter.id} pattern={filter.pattern} copy={copy} />
78
+ </div>
79
+ ))}
80
+ </div>
81
+ )}
82
+ </section>
83
+
84
+ <section className={PANEL_CARD}>
85
+ <h2 className="font-heading text-lg font-semibold">
86
+ {translator.t('adminBanFilters.add')}
87
+ </h2>
88
+ <NewBanFilterForm types={types} copy={copy} />
89
+ </section>
90
+
91
+ <p className={PANEL_NOTE}>{translator.t('adminBanFilters.globNote')}</p>
92
+ </PanelPage>
93
+ )
94
+ }
package/next.config.mjs CHANGED
@@ -77,6 +77,7 @@ const nextConfig = {
77
77
  '@meith/messages',
78
78
  '@meith/moderation',
79
79
  '@meith/notifications',
80
+ '@meith/plugin-calendar',
80
81
  '@meith/plugin-dues',
81
82
  '@meith/plugin-kit',
82
83
  '@meith/polls',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,60 +35,61 @@
35
35
  "dependencies": {
36
36
  "@swc/helpers": "0.5.23",
37
37
  "@tailwindcss/postcss": "^4.3.3",
38
+ "@types/react": "^19",
39
+ "@types/react-dom": "^19",
38
40
  "next": "16.3.1",
39
41
  "postcss": "^8.5",
40
42
  "react": "19.2.8",
41
43
  "react-dom": "19.2.8",
42
44
  "tailwindcss": "^4.3.3",
43
45
  "typescript": "7.0.2",
44
- "@types/react": "^19",
45
- "@types/react-dom": "^19",
46
- "@meith/admin": "0.22.0",
47
- "@meith/antispam": "0.22.0",
48
- "@meith/api": "0.22.0",
49
- "@meith/accounts": "0.22.0",
50
- "@meith/attachments": "0.22.0",
51
- "@meith/authorization": "0.22.0",
52
- "@meith/avatars": "0.22.0",
53
- "@meith/core": "0.22.0",
54
- "@meith/drafts": "0.22.0",
55
- "@meith/db": "0.22.0",
56
- "@meith/drivers": "0.22.0",
57
- "@meith/demo": "0.22.0",
58
- "@meith/groups": "0.22.0",
59
- "@meith/forums": "0.22.0",
60
- "@meith/import": "0.22.0",
61
- "@meith/events": "0.22.0",
62
- "@meith/i18n": "0.22.0",
63
- "@meith/install": "0.22.0",
64
- "@meith/markdown": "0.22.0",
65
- "@meith/marketplace": "0.22.0",
66
- "@meith/mail": "0.22.0",
67
- "@meith/messages": "0.22.0",
68
- "@meith/notifications": "0.22.0",
69
- "@meith/plugin-dues": "0.22.0",
70
- "@meith/moderation": "0.22.0",
71
- "@meith/plugin-kit": "0.22.0",
72
- "@meith/posts": "0.22.0",
73
- "@meith/polls": "0.22.0",
74
- "@meith/profile-fields": "0.22.0",
75
- "@meith/relations": "0.22.0",
76
- "@meith/reputation": "0.22.0",
77
- "@meith/runtime": "0.22.0",
78
- "@meith/search": "0.22.0",
79
- "@meith/settings": "0.22.0",
80
- "@meith/signatures": "0.22.0",
81
- "@meith/subscriptions": "0.22.0",
82
- "@meith/theme-kit": "0.22.0",
83
- "@meith/theme-clubhouse": "0.22.0",
84
- "@meith/theme-default": "0.22.0",
85
- "@meith/theme-phasebook": "0.22.0",
86
- "@meith/theme-midnight": "0.22.0",
87
- "@meith/theme-raidframe": "0.22.0",
88
- "@meith/threads": "0.22.0",
89
- "@meith/ui": "0.22.0",
90
- "@meith/tasks": "0.22.0",
91
- "@meith/upgrade": "0.22.0"
46
+ "@meith/accounts": "0.23.0",
47
+ "@meith/admin": "0.23.0",
48
+ "@meith/antispam": "0.23.0",
49
+ "@meith/api": "0.23.0",
50
+ "@meith/attachments": "0.23.0",
51
+ "@meith/authorization": "0.23.0",
52
+ "@meith/avatars": "0.23.0",
53
+ "@meith/core": "0.23.0",
54
+ "@meith/db": "0.23.0",
55
+ "@meith/demo": "0.23.0",
56
+ "@meith/drafts": "0.23.0",
57
+ "@meith/drivers": "0.23.0",
58
+ "@meith/events": "0.23.0",
59
+ "@meith/forums": "0.23.0",
60
+ "@meith/groups": "0.23.0",
61
+ "@meith/i18n": "0.23.0",
62
+ "@meith/import": "0.23.0",
63
+ "@meith/install": "0.23.0",
64
+ "@meith/mail": "0.23.0",
65
+ "@meith/markdown": "0.23.0",
66
+ "@meith/marketplace": "0.23.0",
67
+ "@meith/messages": "0.23.0",
68
+ "@meith/moderation": "0.23.0",
69
+ "@meith/notifications": "0.23.0",
70
+ "@meith/plugin-calendar": "0.23.0",
71
+ "@meith/plugin-dues": "0.23.0",
72
+ "@meith/plugin-kit": "0.23.0",
73
+ "@meith/polls": "0.23.0",
74
+ "@meith/posts": "0.23.0",
75
+ "@meith/profile-fields": "0.23.0",
76
+ "@meith/relations": "0.23.0",
77
+ "@meith/reputation": "0.23.0",
78
+ "@meith/search": "0.23.0",
79
+ "@meith/runtime": "0.23.0",
80
+ "@meith/settings": "0.23.0",
81
+ "@meith/signatures": "0.23.0",
82
+ "@meith/subscriptions": "0.23.0",
83
+ "@meith/tasks": "0.23.0",
84
+ "@meith/theme-clubhouse": "0.23.0",
85
+ "@meith/theme-default": "0.23.0",
86
+ "@meith/theme-kit": "0.23.0",
87
+ "@meith/theme-midnight": "0.23.0",
88
+ "@meith/theme-phasebook": "0.23.0",
89
+ "@meith/theme-raidframe": "0.23.0",
90
+ "@meith/threads": "0.23.0",
91
+ "@meith/ui": "0.23.0",
92
+ "@meith/upgrade": "0.23.0"
92
93
  },
93
94
  "scripts": {
94
95
  "dev": "next dev",
@@ -0,0 +1,104 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { EMPTY_STATE } from '@/server/auth-form-state'
6
+ import { addBanFilterAction, removeBanFilterAction } from '@/server/ban-filter-admin-actions'
7
+
8
+ import { FormError, SubmitButton } from '../auth/form-controls'
9
+ import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
10
+ import { INPUT, Saved } from './form-bits'
11
+
12
+ export interface BanFilterTypeOption {
13
+ readonly value: string
14
+ readonly label: string
15
+ }
16
+
17
+ export function NewBanFilterForm({
18
+ types,
19
+ copy,
20
+ }: {
21
+ types: readonly BanFilterTypeOption[]
22
+ copy: Copy
23
+ }) {
24
+ const [state, action] = useActionState(addBanFilterAction, EMPTY_STATE)
25
+
26
+ return (
27
+ <form action={action} className="flex flex-col gap-3" noValidate>
28
+ <FormError message={state.error} />
29
+ <Saved when={state.notice === 'created'}>{fromCopy(copy, 'adminBanFilter.added')}</Saved>
30
+
31
+ <div className="grid gap-3 sm:grid-cols-2">
32
+ <label className="flex flex-col gap-1 text-sm">
33
+ <span className="font-medium">{fromCopy(copy, 'adminBanFilter.type')}</span>
34
+ <select
35
+ name="type"
36
+ defaultValue={state.values?.type ?? types[0]?.value ?? ''}
37
+ className={INPUT}
38
+ >
39
+ {types.map((type) => (
40
+ <option key={type.value} value={type.value}>
41
+ {type.label}
42
+ </option>
43
+ ))}
44
+ </select>
45
+ <span className="text-xs text-muted-foreground">
46
+ {fromCopy(copy, 'adminBanFilter.typeHint')}
47
+ </span>
48
+ </label>
49
+
50
+ <label className="flex flex-col gap-1 text-sm">
51
+ <span className="font-medium">{fromCopy(copy, 'adminBanFilter.pattern')}</span>
52
+ <input
53
+ name="pattern"
54
+ defaultValue={state.values?.pattern ?? ''}
55
+ className={INPUT}
56
+ placeholder={fromCopy(copy, 'adminBanFilter.patternPlaceholder')}
57
+ required
58
+ />
59
+ <span className="text-xs text-muted-foreground">
60
+ {fromCopy(copy, 'adminBanFilter.patternHint')}
61
+ </span>
62
+ </label>
63
+ </div>
64
+
65
+ <label className="flex flex-col gap-1 text-sm">
66
+ <span className="font-medium">{fromCopy(copy, 'adminBanFilter.note')}</span>
67
+ <input name="note" defaultValue={state.values?.note ?? ''} className={INPUT} />
68
+ <span className="text-xs text-muted-foreground">
69
+ {fromCopy(copy, 'adminBanFilter.noteHint')}
70
+ </span>
71
+ </label>
72
+
73
+ <div>
74
+ <SubmitButton>{fromCopy(copy, 'adminBanFilter.addFilter')}</SubmitButton>
75
+ </div>
76
+ </form>
77
+ )
78
+ }
79
+
80
+ export function RemoveBanFilterForm({
81
+ id,
82
+ pattern,
83
+ copy,
84
+ }: {
85
+ id: number
86
+ pattern: string
87
+ copy: Copy
88
+ }) {
89
+ const [state, action] = useActionState(removeBanFilterAction, EMPTY_STATE)
90
+
91
+ return (
92
+ <form action={action} className="flex flex-col items-end gap-1">
93
+ <FormError message={state.error} />
94
+ <input type="hidden" name="id" value={id} />
95
+ <button
96
+ type="submit"
97
+ className="text-xs text-destructive hover:underline"
98
+ aria-label={formatFromCopy(copy, 'adminBanFilter.removeFilter', { pattern })}
99
+ >
100
+ {fromCopy(copy, 'admin.remove')}
101
+ </button>
102
+ </form>
103
+ )
104
+ }
@@ -4,6 +4,7 @@ import { currentRequestId } from '@meith/core/logger'
4
4
  import type { ForumListingRow } from '@meith/forums'
5
5
  import { requireSlot, slotCopy } from '@meith/theme-kit'
6
6
 
7
+ import { activeWordFilter } from '@/server/content-admin'
7
8
  import { getActor } from '@/server/context'
8
9
  import { identitiesFor } from '@/server/group-identity'
9
10
  import { getTranslator } from '@/server/i18n'
@@ -44,6 +45,7 @@ export async function SectionPage({
44
45
  now: new Date(),
45
46
  t: await getTranslator(),
46
47
  identities,
48
+ wordFilter: await activeWordFilter(),
47
49
  })
48
50
 
49
51
  const pluginContext = viewerRef(actor)
@@ -84,7 +84,7 @@ export const SEARCH_HANDLERS: ApiRoutes = [
84
84
  postId: hit.postId,
85
85
  threadId: hit.threadId,
86
86
  forumId: hit.forumId,
87
- threadTitle: hit.threadTitle,
87
+ threadTitle: filterWords(hit.threadTitle, wordFilter),
88
88
  authorUserId: hit.authorUserId,
89
89
  authorUsername: hit.authorUsername,
90
90
  postedAt: hit.postedAt.toISOString(),
@@ -6,7 +6,10 @@ import { ValidationError } from '@meith/core'
6
6
  import { msg } from '@meith/i18n'
7
7
  import { parseSubscriptionTarget, SubscriptionService } from '@meith/subscriptions'
8
8
 
9
+ import { filterWords } from '@/view/word-filter'
10
+
9
11
  import { getContainer } from '../container'
12
+ import { activeWordFilter } from '../content-admin'
10
13
  import { type ApiResult, type ApiRoutes, bodyId, bodyText, notFound, requireUserId } from './http'
11
14
 
12
15
  async function mayView(
@@ -48,6 +51,7 @@ export const SUBSCRIPTION_HANDLERS: ApiRoutes = [
48
51
  const userId = requireUserId(actor)
49
52
  const visibleForumIds = await getContainer().authorizer.visibleForumIds(actor)
50
53
  const rows = await requireSubscriptions().list(userId, visibleForumIds)
54
+ const wordFilter = await activeWordFilter()
51
55
 
52
56
  return {
53
57
  status: 200,
@@ -55,7 +59,7 @@ export const SUBSCRIPTION_HANDLERS: ApiRoutes = [
55
59
  data: rows.map((row) => ({
56
60
  target: row.target,
57
61
  targetId: row.targetId,
58
- title: row.title,
62
+ title: row.target === 'thread' ? filterWords(row.title, wordFilter) : row.title,
59
63
  href: row.href,
60
64
  mode: row.mode,
61
65
  createdAt: row.createdAt.toISOString(),
@@ -0,0 +1,112 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import {
6
+ assertUsableFilter,
7
+ BAN_FILTER_TYPES,
8
+ type BanFilterType,
9
+ matchBanFilter,
10
+ } from '@meith/accounts'
11
+ import { ValidationError } from '@meith/core'
12
+ import { msg } from '@meith/i18n'
13
+
14
+ import { recordAdminAction, requireAdmin } from './admin'
15
+ import type { FormState } from './auth-form-state'
16
+ import { boardBanFilters } from './ban-filter-admin'
17
+ import { getContainer } from './container'
18
+ import { formStateReporter } from './form-state-reporter'
19
+ import { trimmedText } from './form-values'
20
+ import { remoteAddress } from './request-fingerprint'
21
+
22
+ const BAN_FILTER_SCREEN = '/admin/users/ban-filters'
23
+
24
+ const toFormState = formStateReporter('ban-filter-admin', 'ban filter write failed')
25
+
26
+ function refreshBanFilterScreen(): void {
27
+ revalidatePath(BAN_FILTER_SCREEN)
28
+ }
29
+
30
+ function filterType(form: FormData): BanFilterType {
31
+ const value = trimmedText(form, 'type')
32
+ if (!(BAN_FILTER_TYPES as readonly string[]).includes(value)) {
33
+ throw new ValidationError(msg('error.app.kind-ban-filter'))
34
+ }
35
+ return value as BanFilterType
36
+ }
37
+
38
+ function filterId(form: FormData): number {
39
+ const id = Number(trimmedText(form, 'id'))
40
+ if (!Number.isSafeInteger(id) || id <= 0) {
41
+ throw new ValidationError(msg('error.app.such-ban-filter'))
42
+ }
43
+ return id
44
+ }
45
+
46
+ async function assertItWouldNotLockYouOut(
47
+ userId: number,
48
+ type: BanFilterType,
49
+ pattern: string,
50
+ ): Promise<void> {
51
+ const account = await getContainer().accountStore.accounts.findById(userId)
52
+ const ip = await remoteAddress()
53
+
54
+ const matched = matchBanFilter([{ id: 0, type, pattern }], {
55
+ ...(account === null ? {} : { username: account.username, email: account.email }),
56
+ ...(ip === null ? {} : { ip }),
57
+ })
58
+
59
+ if (matched !== null) throw new ValidationError(msg('error.app.filter-match-you-lock-out'))
60
+ }
61
+
62
+ export async function addBanFilterAction(_prev: FormState, form: FormData): Promise<FormState> {
63
+ const values = {
64
+ type: trimmedText(form, 'type'),
65
+ pattern: trimmedText(form, 'pattern'),
66
+ note: trimmedText(form, 'note'),
67
+ }
68
+
69
+ try {
70
+ const context = await requireAdmin()
71
+
72
+ const type = filterType(form)
73
+ const pattern = trimmedText(form, 'pattern')
74
+ const note = trimmedText(form, 'note')
75
+
76
+ assertUsableFilter(type, pattern)
77
+ await assertItWouldNotLockYouOut(context.userId, type, pattern)
78
+
79
+ const id = await boardBanFilters().create({
80
+ type,
81
+ pattern,
82
+ note: note === '' ? null : note,
83
+ createdByUserId: context.userId,
84
+ })
85
+
86
+ refreshBanFilterScreen()
87
+ await recordAdminAction({
88
+ action: 'user.ban_filter_added',
89
+ detail: { filterId: id, type, pattern },
90
+ })
91
+
92
+ return { notice: 'created' }
93
+ } catch (err) {
94
+ return toFormState(err, values)
95
+ }
96
+ }
97
+
98
+ export async function removeBanFilterAction(_prev: FormState, form: FormData): Promise<FormState> {
99
+ try {
100
+ await requireAdmin()
101
+ const id = filterId(form)
102
+
103
+ await boardBanFilters().remove(id)
104
+
105
+ refreshBanFilterScreen()
106
+ await recordAdminAction({ action: 'user.ban_filter_removed', detail: { filterId: id } })
107
+
108
+ return { notice: 'removed' }
109
+ } catch (err) {
110
+ return toFormState(err)
111
+ }
112
+ }
@@ -0,0 +1,25 @@
1
+ import 'server-only'
2
+
3
+ import type { BanFilterAdminRepository, BanFilterRecord } from '@meith/accounts'
4
+
5
+ import { getContainer } from './container'
6
+
7
+ export function boardBanFilters(): BanFilterAdminRepository {
8
+ return getContainer().banFilters
9
+ }
10
+
11
+ export async function banFilterAuthors(
12
+ filters: readonly BanFilterRecord[],
13
+ ): Promise<ReadonlyMap<number, string>> {
14
+ const { accountStore } = getContainer()
15
+ const names = new Map<number, string>()
16
+
17
+ for (const id of new Set(filters.map((row) => row.createdByUserId))) {
18
+ if (id === null || names.has(id)) continue
19
+
20
+ const account = await accountStore.accounts.findById(id)
21
+ if (account !== null) names.set(id, account.username)
22
+ }
23
+
24
+ return names
25
+ }
@@ -4,6 +4,7 @@ import forumConfig from '@board/config'
4
4
 
5
5
  import {
6
6
  type AccountStore,
7
+ type BanFilterAdminRepository,
7
8
  type BanLookup,
8
9
  BanService,
9
10
  createMemoryStore,
@@ -11,6 +12,7 @@ import {
11
12
  IdentityService,
12
13
  type MemberProfileRepository,
13
14
  type MemberSettingsRepository,
15
+ MemoryBanFilters,
14
16
  SessionService,
15
17
  } from '@meith/accounts'
16
18
  import type { AdminLogRepository, AdminSessionRepository } from '@meith/admin'
@@ -33,6 +35,7 @@ import {
33
35
  PostgresAttachmentRepository,
34
36
  PostgresAuthorizationSource,
35
37
  PostgresAvatarRepository,
38
+ PostgresBanFilterRepository,
36
39
  PostgresBanRepository,
37
40
  PostgresDraftRepository,
38
41
  PostgresForumRepository,
@@ -118,6 +121,7 @@ export interface Container {
118
121
  readonly identity: IdentityService
119
122
  readonly sessions: SessionService
120
123
  readonly banLookup: BanLookup | null
124
+ readonly banFilters: BanFilterAdminRepository
121
125
  readonly forums: ForumRepository
122
126
  readonly threads: ThreadRepository
123
127
  readonly threadWrites: (ThreadWriteRepository & ReplyWriteRepository) | null
@@ -204,7 +208,7 @@ function buildFixture(onBypass: (e: BypassEvent) => void): Container {
204
208
  posts: new FixturePostRepository(),
205
209
  memberProfiles: new FixtureMemberProfileRepository(),
206
210
  fixtureDataVersion: FIXTURE_DATA_VERSION,
207
- ...identityServices(store),
211
+ ...identityServices(store, new MemoryBanFilters()),
208
212
  accountStore: store,
209
213
  dataSource: 'fixture',
210
214
  }
@@ -216,16 +220,19 @@ function cached(inner: ForumRepository): ForumRepository {
216
220
 
217
221
  function identityServices(
218
222
  store: AccountStore,
223
+ banFilters: BanFilterAdminRepository,
219
224
  bans?: BanLookup,
220
225
  ): {
221
226
  identity: IdentityService
222
227
  sessions: SessionService
223
228
  banLookup: BanLookup | null
229
+ banFilters: BanFilterAdminRepository
224
230
  } {
225
231
  return {
226
232
  identity: new IdentityService({
227
233
  store,
228
234
  config: AUTH_CONFIG,
235
+ banFilters,
229
236
  secondFactor: enrolmentLookup(store.twoFactor),
230
237
  ...(bans === undefined ? {} : { bans }),
231
238
  }),
@@ -235,6 +242,7 @@ function identityServices(
235
242
  sessionLifetimeDays: SESSION_LIFETIME_DAYS,
236
243
  }),
237
244
  banLookup: bans ?? null,
245
+ banFilters,
238
246
  }
239
247
  }
240
248
 
@@ -287,7 +295,7 @@ function buildPostgres(onBypass: (e: BypassEvent) => void): Container {
287
295
  threadViews,
288
296
  fixtureDataVersion: null,
289
297
  accountStore: store,
290
- ...identityServices(store, new PostgresBanRepository(db)),
298
+ ...identityServices(store, new PostgresBanFilterRepository(db), new PostgresBanRepository(db)),
291
299
  scheduler: withDemoReset(
292
300
  buildSchedulerBundle({
293
301
  queue: drivers().queue,
@@ -359,6 +367,7 @@ export function getContainer(): Container {
359
367
  cached.adminLog === undefined ||
360
368
  cached.attachments === undefined ||
361
369
  cached.avatars === undefined ||
370
+ cached.banFilters === undefined ||
362
371
  typeof cached.memberProfiles?.findPublicById !== 'function' ||
363
372
  typeof cached.accountStore?.identities?.listForUser !== 'function' ||
364
373
  typeof cached.accountStore?.passkeys?.listForUser !== 'function' ||
@@ -375,10 +384,11 @@ export function getAuthorizer(): Authorizer {
375
384
  }
376
385
 
377
386
  export async function configuredIdentity(): Promise<IdentityService> {
378
- const { accountStore, banLookup } = getContainer()
387
+ const { accountStore, banLookup, banFilters } = getContainer()
379
388
  return new IdentityService({
380
389
  store: accountStore,
381
390
  config: await boardAuthConfig(),
391
+ banFilters,
382
392
  secondFactor: enrolmentLookup(accountStore.twoFactor),
383
393
  ...(banLookup === null ? {} : { bans: banLookup }),
384
394
  })
@@ -27,7 +27,7 @@ export function feedFor(
27
27
  function threadEntry(thread: FeedThread): FeedEntry {
28
28
  return {
29
29
  id: tagId('thread', thread.threadId),
30
- title: thread.title,
30
+ title: filterWords(thread.title, wordFilter),
31
31
  href: absolute(`/thread/${thread.threadId}-${thread.slug}`),
32
32
  author: thread.authorUsername,
33
33
  published: thread.createdAt,
@@ -39,7 +39,7 @@ export function feedFor(
39
39
  function postEntry(post: FeedPost): FeedEntry {
40
40
  return {
41
41
  id: tagId('post', post.postId),
42
- title: post.threadTitle,
42
+ title: filterWords(post.threadTitle, wordFilter),
43
43
  href: absolute(postLink(`/thread/${post.threadId}-${post.threadSlug}`, post.postId)),
44
44
  author: post.authorUsername,
45
45
  published: post.createdAt,
@@ -2,6 +2,7 @@ import 'server-only'
2
2
 
3
3
  import {
4
4
  type AuthConfig,
5
+ BAN_FILTERS_NOT_CONSULTED,
5
6
  DEFAULT_AUTH_POLICY,
6
7
  IdentityService,
7
8
  rejectedField,
@@ -247,7 +248,11 @@ async function performInstall(input: InstallInput): Promise<readonly StepOutcome
247
248
  defaultMemberGroupId: registered.id,
248
249
  }
249
250
 
250
- const identity = new IdentityService({ store: createPostgresAccountStore(db), config })
251
+ const identity = new IdentityService({
252
+ store: createPostgresAccountStore(db),
253
+ config,
254
+ banFilters: BAN_FILTERS_NOT_CONSULTED,
255
+ })
251
256
  const result = await identity.register({
252
257
  username: input.username,
253
258
  email: input.email,
@@ -37,11 +37,11 @@ export async function emitEvent<K extends HookName>(
37
37
 
38
38
  export async function pluginRegion(
39
39
  region: PluginRegion,
40
- context: Omit<PluginRegionContext, 'region'>,
40
+ context: Omit<PluginRegionContext, 'region' | 'runtime'>,
41
41
  ): Promise<React.ReactNode> {
42
42
  await syncPluginEnablement()
43
43
 
44
- const nodes = pluginHost.renderRegion(region, { ...context, region })
44
+ const nodes = await pluginHost.renderRegion(region, { ...context, region })
45
45
  if (nodes.length === 0) return null
46
46
 
47
47
  return (
@@ -1,4 +1,9 @@
1
- import { createMemoryStore, IdentityService, SessionService } from '@meith/accounts'
1
+ import {
2
+ createMemoryStore,
3
+ IdentityService,
4
+ MemoryBanFilters,
5
+ SessionService,
6
+ } from '@meith/accounts'
2
7
  import type { MemoryAppointment, MemoryBoard } from '@meith/authorization'
3
8
  import { Authorizer, InMemoryAuthorizationSource } from '@meith/authorization'
4
9
 
@@ -59,11 +64,15 @@ export function installTestContainer(options: TestContainerOptions = {}): Record
59
64
  }
60
65
 
61
66
  function identityOver(store: ReturnType<typeof createMemoryStore>) {
67
+ const banFilters = new MemoryBanFilters()
68
+
62
69
  return {
63
70
  accountStore: store,
64
71
  actorSource: new FixtureActorSource(store),
72
+ banFilters,
65
73
  identity: new IdentityService({
66
74
  store,
75
+ banFilters,
67
76
  config: {
68
77
  registrationEnabled: true,
69
78
  minPasswordLength: 8,
@@ -6,7 +6,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
6
6
 
7
7
  import { activeDefinitions } from './plugin-host'
8
8
 
9
- export const CODE_VERSION = '0.22.0'
9
+ export const CODE_VERSION = '0.23.0'
10
10
 
11
11
  export async function pendingUpgradeNotice(): Promise<string | null> {
12
12
  if (env.DATA_SOURCE !== 'postgres') return null
@@ -0,0 +1,34 @@
1
+ import type { Translator } from '@meith/i18n'
2
+
3
+ import { adminSharedCopy } from './admin-copy'
4
+ import { copyFor, patternCopy } from './copy'
5
+ import { untranslated } from './time'
6
+
7
+ export const BAN_FILTER_TYPE_KEY = {
8
+ username: 'adminBanFilters.type.username',
9
+ email: 'adminBanFilters.type.email',
10
+ ip: 'adminBanFilters.type.ip',
11
+ } as const
12
+
13
+ export function banFilterAdminCopy(
14
+ t: Translator = untranslated(),
15
+ ): Readonly<Record<string, string>> {
16
+ return {
17
+ ...adminSharedCopy(t),
18
+ ...copyFor(
19
+ [
20
+ 'adminBanFilter.type',
21
+ 'adminBanFilter.typeHint',
22
+ 'adminBanFilter.pattern',
23
+ 'adminBanFilter.patternHint',
24
+ 'adminBanFilter.patternPlaceholder',
25
+ 'adminBanFilter.note',
26
+ 'adminBanFilter.noteHint',
27
+ 'adminBanFilter.addFilter',
28
+ 'adminBanFilter.added',
29
+ ],
30
+ t,
31
+ ),
32
+ ...patternCopy(['adminBanFilter.removeFilter'], t),
33
+ }
34
+ }
@@ -57,6 +57,10 @@ export const ADMIN_SECTIONS: PanelNav = [
57
57
  children: [
58
58
  { href: '/admin/users/mail', titleKey: 'adminNav.admin-users-mail.title' },
59
59
  { href: '/admin/users/prune', titleKey: 'adminNav.admin-users-prune.title' },
60
+ {
61
+ href: '/admin/users/ban-filters',
62
+ titleKey: 'adminNav.admin-users-ban-filters.title',
63
+ },
60
64
  ],
61
65
  },
62
66
  {
@@ -1,5 +1,6 @@
1
1
  import { buildTree, type ForumListingRow, type ForumNode, keepVisibleSubtrees } from '@meith/forums'
2
2
  import type { Translator } from '@meith/i18n'
3
+ import type { CompiledWordFilter } from '@meith/markdown'
3
4
  import type {
4
5
  BoardIndexModel,
5
6
  CategoryBlockModel,
@@ -13,6 +14,7 @@ import { type MemberIdentity, nameClassOf } from './member-identity'
13
14
  import { memberHref } from './member-profile'
14
15
  import { postLink } from './post-link'
15
16
  import { formatTime } from './time'
17
+ import { filterWords } from './word-filter'
16
18
 
17
19
  export interface BoardIndexInput {
18
20
  readonly rows: readonly ForumListingRow[]
@@ -23,6 +25,7 @@ export interface BoardIndexInput {
23
25
  readonly now: Date
24
26
  readonly t?: Translator
25
27
  readonly identities?: ReadonlyMap<number, MemberIdentity>
28
+ readonly wordFilter?: CompiledWordFilter | undefined
26
29
  }
27
30
 
28
31
  export function forumHref(row: { id: number; slug: string }): string {
@@ -43,12 +46,13 @@ function toLastPost(
43
46
  now: Date,
44
47
  t: Translator | undefined,
45
48
  identities: ReadonlyMap<number, MemberIdentity> | undefined,
49
+ wordFilter: CompiledWordFilter | undefined,
46
50
  ): LastPostModel | null {
47
51
  const last = row.lastPost
48
52
  if (last === null) return null
49
53
 
50
54
  return {
51
- threadTitle: last.threadTitle,
55
+ threadTitle: filterWords(last.threadTitle, wordFilter),
52
56
  href: threadHref(last.threadId, last.postId),
53
57
  author: {
54
58
  userId: last.userId,
@@ -67,6 +71,7 @@ function toForumRow(
67
71
  unreadForumIds: ReadonlySet<number> | undefined,
68
72
  identities: ReadonlyMap<number, MemberIdentity> | undefined,
69
73
  ownThreadsOnlyForumIds: ReadonlySet<number> | undefined,
74
+ wordFilter: CompiledWordFilter | undefined,
70
75
  ): ForumRowModel {
71
76
  const ownThreadsOnly = ownThreadsOnlyForumIds?.has(node.id) ?? false
72
77
 
@@ -78,7 +83,7 @@ function toForumRow(
78
83
  type: node.type,
79
84
  threadCount: count(ownThreadsOnly ? 0 : node.threadCount, t),
80
85
  postCount: count(ownThreadsOnly ? 0 : node.postCount, t),
81
- lastPost: ownThreadsOnly ? null : toLastPost(node, now, t, identities),
86
+ lastPost: ownThreadsOnly ? null : toLastPost(node, now, t, identities, wordFilter),
82
87
  isUnread: !ownThreadsOnly && (unreadForumIds?.has(node.id) ?? false),
83
88
  subforums: node.children.map(
84
89
  (child): LinkModel => ({ label: child.title, href: hrefFor(child) }),
@@ -127,6 +132,7 @@ export function buildSectionView(input: SectionInput): BoardIndexBlock | null {
127
132
  input.unreadForumIds,
128
133
  input.identities,
129
134
  input.ownThreadsOnlyForumIds,
135
+ input.wordFilter,
130
136
  )
131
137
 
132
138
  return { block: { category: row(node) }, forums: node.children.map(row) }
@@ -150,6 +156,7 @@ export function buildBoardIndexView(input: BoardIndexInput): BoardIndexView {
150
156
  input.unreadForumIds,
151
157
  input.identities,
152
158
  input.ownThreadsOnlyForumIds,
159
+ input.wordFilter,
153
160
  ),
154
161
  },
155
162
  forums: node.children.map((child) =>
@@ -160,6 +167,7 @@ export function buildBoardIndexView(input: BoardIndexInput): BoardIndexView {
160
167
  input.unreadForumIds,
161
168
  input.identities,
162
169
  input.ownThreadsOnlyForumIds,
170
+ input.wordFilter,
163
171
  ),
164
172
  ),
165
173
  })),
@@ -70,7 +70,7 @@ function authorOf(
70
70
 
71
71
  export function buildLatestThreadsModel(input: LatestInput<LatestThreadRow>): LatestThreadsModel {
72
72
  const threads: LatestThreadModel[] = input.rows.map((row) => ({
73
- title: row.title,
73
+ title: filterWords(row.title, input.wordFilter),
74
74
  href: `/thread/${row.threadId}-${row.slug}`,
75
75
  forum: {
76
76
  label: row.forumTitle,
@@ -86,7 +86,7 @@ export function buildLatestThreadsModel(input: LatestInput<LatestThreadRow>): La
86
86
 
87
87
  export function buildLatestPostsModel(input: LatestInput<LatestPostRow>): LatestPostsModel {
88
88
  const posts: LatestPostModel[] = input.rows.map((row) => ({
89
- threadTitle: row.threadTitle,
89
+ threadTitle: filterWords(row.threadTitle, input.wordFilter),
90
90
  href: postHref(row),
91
91
  forum: {
92
92
  label: row.forumTitle,
@@ -1,8 +1,10 @@
1
1
  import type { Translator } from '@meith/i18n'
2
+ import type { CompiledWordFilter } from '@meith/markdown'
2
3
  import type { DiscoveryViewModel } from '@meith/theme-kit'
3
4
 
4
5
  import { count } from './count'
5
6
  import { formatTime, untranslated } from './time'
7
+ import { filterWords } from './word-filter'
6
8
 
7
9
  export const DISCOVERY_LABELS = {
8
10
  new: { labelKey: 'discovery.new.label', blurbKey: 'discovery.new.blurb' },
@@ -40,6 +42,7 @@ export function buildDiscoveryView(input: {
40
42
  readonly refusalMessage?: string | null | undefined
41
43
  readonly now: Date
42
44
  readonly t?: Translator | undefined
45
+ readonly wordFilter?: CompiledWordFilter | undefined
43
46
  }): DiscoveryViewModel {
44
47
  const refusal = input.refusalMessage ?? null
45
48
  const t = input.t ?? untranslated()
@@ -55,7 +58,7 @@ export function buildDiscoveryView(input: {
55
58
  })),
56
59
  rows: input.rows.map((row) => ({
57
60
  threadId: row.threadId,
58
- title: row.title,
61
+ title: filterWords(row.title, input.wordFilter),
59
62
  href: `/thread/${row.threadId}-${row.slug}`,
60
63
  forum: { label: row.forumTitle, href: `/${row.forumId}-${row.forumSlug}` },
61
64
  authorUsername: row.authorUsername,
@@ -1,5 +1,6 @@
1
1
  import type { ForumListingRow } from '@meith/forums'
2
2
  import type { Translator } from '@meith/i18n'
3
+ import type { CompiledWordFilter } from '@meith/markdown'
3
4
  import type {
4
5
  ForumDisplayModel,
5
6
  ForumRowModel,
@@ -16,6 +17,7 @@ import { type MemberIdentity, nameClassOf } from './member-identity'
16
17
  import { memberHref } from './member-profile'
17
18
  import { postLink } from './post-link'
18
19
  import { formatTime } from './time'
20
+ import { filterWords } from './word-filter'
19
21
 
20
22
  export function threadHref(row: ThreadListingRow): string {
21
23
  return `/thread/${row.id}-${row.slug}`
@@ -32,10 +34,11 @@ function lastPost(
32
34
  now: Date,
33
35
  t: Translator | undefined,
34
36
  identities: ReadonlyMap<number, MemberIdentity> | undefined,
37
+ wordFilter: CompiledWordFilter | undefined,
35
38
  ): LastPostModel | null {
36
39
  if (!post) return null
37
40
  return {
38
- threadTitle: thread.title,
41
+ threadTitle: filterWords(thread.title, wordFilter),
39
42
  href: postLink(threadHref(thread), post.postId),
40
43
  author: {
41
44
  userId: post.userId,
@@ -73,6 +76,7 @@ export function threadRowModel(
73
76
  readState: Pick<ReadState, 'forumReadAt' | 'threadLastPostId'> | null = null,
74
77
  t?: Translator,
75
78
  identities?: ReadonlyMap<number, MemberIdentity>,
79
+ wordFilter?: CompiledWordFilter | undefined,
76
80
  ): ThreadRowModel {
77
81
  const last = row.lastPost
78
82
  const isUnread =
@@ -82,7 +86,7 @@ export function threadRowModel(
82
86
  last.at > (readState.forumReadAt.get(row.forumId) ?? new Date(0))
83
87
  return {
84
88
  id: row.id,
85
- title: row.title,
89
+ title: filterWords(row.title, wordFilter),
86
90
  href: threadHref(row),
87
91
  prefix: row.prefix,
88
92
  author: {
@@ -97,7 +101,7 @@ export function threadRowModel(
97
101
  isLocked: row.isLocked,
98
102
  isUnread,
99
103
  isMoved: row.isMoved,
100
- lastPost: lastPost(row.lastPost, row, now, t, identities),
104
+ lastPost: lastPost(row.lastPost, row, now, t, identities, wordFilter),
101
105
  }
102
106
  }
103
107
 
@@ -115,6 +119,7 @@ export interface ForumDisplayInput {
115
119
  readonly now: Date
116
120
  readonly t?: Translator
117
121
  readonly identities?: ReadonlyMap<number, MemberIdentity>
122
+ readonly wordFilter?: CompiledWordFilter | undefined
118
123
  }
119
124
 
120
125
  export interface ForumDisplayView {
@@ -138,7 +143,14 @@ export function buildForumDisplayView(input: ForumDisplayInput): ForumDisplayVie
138
143
  forums: input.subforums.map((row) => forum(row, input.ownThreadsOnlyForumIds, input.t)),
139
144
  },
140
145
  threads: input.page.rows.map((row) =>
141
- threadRowModel(row, input.now, input.readState ?? null, input.t, input.identities),
146
+ threadRowModel(
147
+ row,
148
+ input.now,
149
+ input.readState ?? null,
150
+ input.t,
151
+ input.identities,
152
+ input.wordFilter,
153
+ ),
142
154
  ),
143
155
  pagination: input.pagination ?? {
144
156
  page: input.pageNumber,
@@ -1,8 +1,10 @@
1
1
  import type { Translator } from '@meith/i18n'
2
+ import type { CompiledWordFilter } from '@meith/markdown'
2
3
  import type { PostFormModel } from '@meith/theme-kit'
3
4
 
4
5
  import { postLink } from './post-link'
5
6
  import { untranslated } from './time'
7
+ import { filterWords } from './word-filter'
6
8
 
7
9
  export interface PostFormInput {
8
10
  readonly forum: { readonly id: number; readonly title: string; readonly slug: string }
@@ -26,6 +28,7 @@ export interface ReplyViewInput {
26
28
  readonly thread: { readonly id: number; readonly title: string; readonly slug: string }
27
29
  readonly errorMessage?: string | null
28
30
  readonly t?: Translator
31
+ readonly wordFilter?: CompiledWordFilter | undefined
29
32
  }
30
33
 
31
34
  export function buildReplyView(input: ReplyViewInput): Omit<PostFormModel, 'regions'> {
@@ -33,7 +36,7 @@ export function buildReplyView(input: ReplyViewInput): Omit<PostFormModel, 'regi
33
36
 
34
37
  return {
35
38
  mode: 'reply',
36
- heading: t.t('postForm.reply', { thread: input.thread.title }),
39
+ heading: t.t('postForm.reply', { thread: filterWords(input.thread.title, input.wordFilter) }),
37
40
  cancelHref: `/thread/${input.thread.id}-${input.thread.slug}`,
38
41
  cancelLabel: t.t('postForm.backToThread'),
39
42
  errorMessage: input.errorMessage ?? null,
@@ -1,10 +1,12 @@
1
1
  import type { Translator } from '@meith/i18n'
2
+ import type { CompiledWordFilter } from '@meith/markdown'
2
3
  import type { BoardStatsModel, OnlineMemberModel, WhoIsOnlineModel } from '@meith/theme-kit'
3
4
 
4
5
  import { count } from './count'
5
6
  import { type MemberIdentity, nameClassOf } from './member-identity'
6
7
  import { memberHref } from './member-profile'
7
8
  import { formatTime, untranslated } from './time'
9
+ import { filterWords } from './word-filter'
8
10
 
9
11
  export interface OnlineRow {
10
12
  readonly userId: number
@@ -26,15 +28,17 @@ export interface OnlineInput {
26
28
  readonly now: Date
27
29
  readonly t?: Translator | undefined
28
30
  readonly identities?: ReadonlyMap<number, MemberIdentity>
31
+ readonly wordFilter?: CompiledWordFilter | undefined
29
32
  }
30
33
 
31
34
  export function locationOf(
32
35
  row: OnlineRow,
33
36
  t: Translator = untranslated(),
37
+ wordFilter?: CompiledWordFilter | undefined,
34
38
  ): { label: string; href: string | null } {
35
39
  if (row.threadId !== null && row.threadTitle !== null) {
36
40
  return {
37
- label: t.t('presence.reading', { title: row.threadTitle }),
41
+ label: t.t('presence.reading', { title: filterWords(row.threadTitle, wordFilter) }),
38
42
  href: `/thread/${row.threadId}-${row.threadSlug ?? ''}`,
39
43
  }
40
44
  }
@@ -52,7 +56,7 @@ export function buildWhoIsOnlineModel(input: OnlineInput): WhoIsOnlineModel {
52
56
  username: row.username,
53
57
  profileHref: memberHref(row.userId),
54
58
  nameClass: nameClassOf(input.identities, row.userId),
55
- location: locationOf(row, input.t ?? untranslated()),
59
+ location: locationOf(row, input.t ?? untranslated(), input.wordFilter),
56
60
  isInvisible: row.invisible,
57
61
  lastSeen: formatTime(row.lastSeenAt, input.now, input.t),
58
62
  }))
@@ -78,7 +78,7 @@ export function buildSearchResultsView(input: SearchResultsInput): SearchResults
78
78
  searchedAt: formatTime(input.createdAt, input.now, input.t),
79
79
  hits: input.hits.map((hit) => ({
80
80
  postId: hit.postId,
81
- threadTitle: hit.threadTitle,
81
+ threadTitle: filterWords(hit.threadTitle, input.wordFilter),
82
82
  href: postLink(`/thread/${hit.threadId}-${hit.threadSlug}`, hit.postId),
83
83
  excerptHtml: filterWords(hit.excerpt, input.wordFilter),
84
84
  authorUsername: hit.authorUsername,
@@ -1,9 +1,11 @@
1
1
  import type { Translator } from '@meith/i18n'
2
+ import type { CompiledWordFilter } from '@meith/markdown'
2
3
  import type { SubscriptionMode, SubscriptionRow } from '@meith/subscriptions'
3
4
  import { MODE_LABELS, SUBSCRIPTION_MODES } from '@meith/subscriptions'
4
5
  import type { TimeModel } from '@meith/theme-kit'
5
6
 
6
7
  import { formatTime, untranslated } from './time'
8
+ import { filterWords } from './word-filter'
7
9
 
8
10
  export interface SubscriptionRowView {
9
11
  readonly key: string
@@ -28,8 +30,9 @@ export function buildSubscriptionsView(input: {
28
30
  readonly rows: readonly SubscriptionRow[]
29
31
  readonly now: Date
30
32
  readonly t?: Translator
33
+ readonly wordFilter?: CompiledWordFilter | undefined
31
34
  }): SubscriptionsView {
32
- const rows = input.rows.map((row) => toRow(row, input.now, input.t))
35
+ const rows = input.rows.map((row) => toRow(row, input.now, input.t, input.wordFilter))
33
36
 
34
37
  return {
35
38
  threads: rows.filter((row) => row.target === 'thread'),
@@ -39,12 +42,17 @@ export function buildSubscriptionsView(input: {
39
42
  }
40
43
  }
41
44
 
42
- function toRow(row: SubscriptionRow, now: Date, t: Translator | undefined): SubscriptionRowView {
45
+ function toRow(
46
+ row: SubscriptionRow,
47
+ now: Date,
48
+ t: Translator | undefined,
49
+ wordFilter: CompiledWordFilter | undefined,
50
+ ): SubscriptionRowView {
43
51
  return {
44
52
  key: `${row.target}:${row.targetId}`,
45
53
  target: row.target,
46
54
  targetId: row.targetId,
47
- title: row.title,
55
+ title: row.target === 'thread' ? filterWords(row.title, wordFilter) : row.title,
48
56
  href: row.href,
49
57
  mode: row.mode,
50
58
  modeLabel: MODE_LABELS[row.mode],
@@ -245,7 +245,7 @@ export interface ThreadView {
245
245
  export function buildThreadView(input: ThreadViewInput): ThreadView {
246
246
  return {
247
247
  view: {
248
- thread: threadRowModel(input.thread, input.now, null, input.t),
248
+ thread: threadRowModel(input.thread, input.now, null, input.t, undefined, input.wordFilter),
249
249
  forum: { label: input.forum.title, href: forumHref(input.forum) },
250
250
  replyHref: input.replyHref ?? null,
251
251
  markReadAction: input.markReadAction ?? null,