@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.
- package/app/(board)/[slug]/page.tsx +2 -0
- package/app/(board)/discover/[view]/page.tsx +2 -0
- package/app/(board)/online/page.tsx +3 -1
- package/app/(board)/page.tsx +5 -0
- package/app/(board)/subscriptions/page.tsx +7 -1
- package/app/(board)/thread/[slug]/page.tsx +11 -4
- package/app/(board)/thread/[slug]/reply/page.tsx +2 -0
- package/app/admin/users/ban-filters/page.tsx +94 -0
- package/next.config.mjs +1 -0
- package/package.json +50 -49
- package/src/components/admin/ban-filter-forms.tsx +104 -0
- package/src/components/board/section-page.tsx +2 -0
- package/src/server/api/search.ts +1 -1
- package/src/server/api/subscriptions.ts +5 -1
- package/src/server/ban-filter-admin-actions.ts +112 -0
- package/src/server/ban-filter-admin.ts +25 -0
- package/src/server/container.ts +13 -3
- package/src/server/feed-builder.ts +2 -2
- package/src/server/install.ts +6 -1
- package/src/server/plugin-view.tsx +2 -2
- package/src/server/test-container.ts +10 -1
- package/src/server/upgrade-notice.ts +1 -1
- package/src/view/admin-ban-filter-copy.ts +34 -0
- package/src/view/admin-nav.ts +4 -0
- package/src/view/board-index.ts +10 -2
- package/src/view/board-latest.ts +2 -2
- package/src/view/discovery-view.ts +4 -1
- package/src/view/forum-display.ts +16 -4
- package/src/view/post-form.ts +4 -1
- package/src/view/presence.ts +6 -2
- package/src/view/search-results.ts +1 -1
- package/src/view/subscriptions.ts +11 -3
- package/src/view/thread-view.ts +1 -1
|
@@ -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}
|
package/app/(board)/page.tsx
CHANGED
|
@@ -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({
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "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
|
-
"@
|
|
45
|
-
"@
|
|
46
|
-
"@meith/
|
|
47
|
-
"@meith/
|
|
48
|
-
"@meith/
|
|
49
|
-
"@meith/
|
|
50
|
-
"@meith/
|
|
51
|
-
"@meith/
|
|
52
|
-
"@meith/
|
|
53
|
-
"@meith/
|
|
54
|
-
"@meith/drafts": "0.
|
|
55
|
-
"@meith/
|
|
56
|
-
"@meith/
|
|
57
|
-
"@meith/
|
|
58
|
-
"@meith/groups": "0.
|
|
59
|
-
"@meith/
|
|
60
|
-
"@meith/import": "0.
|
|
61
|
-
"@meith/
|
|
62
|
-
"@meith/
|
|
63
|
-
"@meith/
|
|
64
|
-
"@meith/
|
|
65
|
-
"@meith/
|
|
66
|
-
"@meith/
|
|
67
|
-
"@meith/
|
|
68
|
-
"@meith/
|
|
69
|
-
"@meith/plugin-dues": "0.
|
|
70
|
-
"@meith/
|
|
71
|
-
"@meith/
|
|
72
|
-
"@meith/posts": "0.
|
|
73
|
-
"@meith/
|
|
74
|
-
"@meith/
|
|
75
|
-
"@meith/
|
|
76
|
-
"@meith/
|
|
77
|
-
"@meith/runtime": "0.
|
|
78
|
-
"@meith/
|
|
79
|
-
"@meith/
|
|
80
|
-
"@meith/
|
|
81
|
-
"@meith/
|
|
82
|
-
"@meith/theme-
|
|
83
|
-
"@meith/theme-
|
|
84
|
-
"@meith/theme-
|
|
85
|
-
"@meith/theme-
|
|
86
|
-
"@meith/theme-
|
|
87
|
-
"@meith/theme-raidframe": "0.
|
|
88
|
-
"@meith/threads": "0.
|
|
89
|
-
"@meith/ui": "0.
|
|
90
|
-
"@meith/
|
|
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)
|
package/src/server/api/search.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/container.ts
CHANGED
|
@@ -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,
|
package/src/server/install.ts
CHANGED
|
@@ -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({
|
|
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 {
|
|
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.
|
|
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
|
+
}
|
package/src/view/admin-nav.ts
CHANGED
|
@@ -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
|
{
|
package/src/view/board-index.ts
CHANGED
|
@@ -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
|
})),
|
package/src/view/board-latest.ts
CHANGED
|
@@ -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(
|
|
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,
|
package/src/view/post-form.ts
CHANGED
|
@@ -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,
|
package/src/view/presence.ts
CHANGED
|
@@ -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(
|
|
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],
|
package/src/view/thread-view.ts
CHANGED
|
@@ -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,
|