@meith/web 0.21.2 → 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)/notifications/preferences/page.tsx +15 -1
- 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/(board)/unsubscribe/page.tsx +5 -3
- package/app/admin/api/marketplace/screenshot/route.ts +0 -8
- package/app/admin/users/ban-filters/page.tsx +94 -0
- package/app/admin/users/mail/page.tsx +1 -1
- package/app/layout.tsx +1 -3
- package/app/manifest.ts +1 -3
- package/bin/forum-web.mjs +1 -182
- package/bin/forum-web.test.ts +0 -22
- package/next.config.mjs +15 -29
- package/package.json +50 -49
- package/proxy.ts +0 -14
- package/src/components/account/notification-forms.tsx +45 -0
- package/src/components/admin/admin-nav.tsx +0 -4
- package/src/components/admin/ban-filter-forms.tsx +104 -0
- package/src/components/admin/marketplace-listing.tsx +0 -9
- package/src/components/auth/passkey-client.ts +0 -5
- package/src/components/auth/register-form.tsx +18 -0
- package/src/components/board/section-page.tsx +2 -0
- package/src/components/content/composer-ids.ts +0 -1
- package/src/components/content/markdown-editor.tsx +0 -7
- package/src/components/shell/demo-banner.tsx +0 -8
- package/src/config.ts +0 -11
- package/src/server/admin-actions.ts +0 -5
- package/src/server/antispam.ts +27 -14
- package/src/server/api/search.ts +1 -1
- package/src/server/api/subscriptions.ts +5 -1
- package/src/server/auth-actions.ts +12 -1
- package/src/server/auth-config.ts +0 -11
- package/src/server/auth-events.ts +0 -5
- package/src/server/ban-filter-admin-actions.ts +112 -0
- package/src/server/ban-filter-admin.ts +25 -0
- package/src/server/brand-assets.ts +2 -6
- package/src/server/container.ts +13 -11
- package/src/server/cookies.ts +0 -24
- package/src/server/demo-uploads.ts +0 -11
- package/src/server/demo.ts +0 -21
- package/src/server/federation-actions.ts +0 -6
- package/src/server/federation.ts +0 -7
- package/src/server/feed-builder.ts +2 -2
- package/src/server/feed-routes.ts +0 -5
- package/src/server/i18n.ts +1 -3
- package/src/server/install.ts +6 -1
- package/src/server/location-header.ts +0 -30
- package/src/server/mail-send.ts +0 -6
- package/src/server/marketplace-actions.ts +0 -6
- package/src/server/marketplace-admin.ts +0 -33
- package/src/server/navigation.ts +0 -5
- package/src/server/notification-actions.ts +25 -0
- package/src/server/onboarding.ts +0 -5
- package/src/server/plugin-admin-actions.ts +0 -11
- package/src/server/plugin-admin.ts +0 -3
- package/src/server/plugin-host.ts +15 -6
- package/src/server/plugin-pages.ts +3 -68
- package/src/server/plugin-routes.ts +1 -1
- package/src/server/plugin-runtime.ts +78 -0
- package/src/server/plugin-view.tsx +2 -2
- package/src/server/presence.ts +3 -34
- package/src/server/relations.ts +1 -3
- package/src/server/reply-core.ts +1 -0
- package/src/server/request-fingerprint.ts +0 -1
- package/src/server/search-page.ts +0 -4
- package/src/server/session-actions.ts +0 -1
- package/src/server/settings.ts +0 -11
- package/src/server/subscription-actions.ts +12 -4
- package/src/server/syndication.ts +0 -4
- package/src/server/test-container.ts +10 -1
- package/src/server/theme-admin.ts +2 -6
- package/src/server/thread-core.ts +6 -1
- package/src/server/two-factor-actions.ts +0 -5
- package/src/server/two-factor.ts +0 -6
- package/src/server/upgrade-notice.ts +1 -1
- package/src/view/account-copy.ts +5 -0
- package/src/view/admin-ban-filter-copy.ts +34 -0
- package/src/view/admin-nav.ts +4 -0
- package/src/view/auth-copy.ts +2 -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/editor-toolbar.ts +0 -1
- package/src/view/forum-display.ts +16 -4
- package/src/view/navigation.ts +0 -4
- package/src/view/notification-menu.ts +0 -2
- package/src/view/notifications.ts +1 -0
- package/src/view/pager.ts +0 -6
- package/src/view/panel-nav.ts +0 -1
- 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/security-activity.ts +0 -5
- package/src/view/subscriptions.ts +12 -3
- package/src/view/thread-view.ts +1 -1
- package/src/view/two-factor.ts +0 -5
- package/src/view/usercp.ts +1 -3
|
@@ -126,7 +126,12 @@ export async function submitThread(
|
|
|
126
126
|
message: draft.body,
|
|
127
127
|
prefixId: draft.prefixId,
|
|
128
128
|
subscribe: input.subscribe ?? false,
|
|
129
|
-
heldAsNewMember: await holdsNewMember({
|
|
129
|
+
heldAsNewMember: await holdsNewMember({
|
|
130
|
+
actor,
|
|
131
|
+
postCount: profile.postCount,
|
|
132
|
+
target: scope,
|
|
133
|
+
settings,
|
|
134
|
+
}),
|
|
130
135
|
requiresApproval: scope.forum.requiresThreadApproval === true,
|
|
131
136
|
...(input.poll === undefined
|
|
132
137
|
? {}
|
|
@@ -146,11 +146,6 @@ export async function disableTwoFactorAction(_prev: FormState, form: FormData):
|
|
|
146
146
|
redirect('/usercp/security?factor=off')
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/**
|
|
150
|
-
* Weakening an account asks for the thing being weakened. An account with a
|
|
151
|
-
* password gives it; one without — signed in by a provider or a passkey —
|
|
152
|
-
* gives a current code instead, which proves the same possession.
|
|
153
|
-
*/
|
|
154
149
|
async function assertStillThem(input: {
|
|
155
150
|
readonly userId: number
|
|
156
151
|
readonly hasPassword: boolean
|
package/src/server/two-factor.ts
CHANGED
|
@@ -32,11 +32,6 @@ export function twoFactorService(): TwoFactorService | null {
|
|
|
32
32
|
})
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Offered when the board says so and the board has a key to seal a secret
|
|
37
|
-
* with. Without AUTH_SECRET there is nowhere safe to put the secret, so the
|
|
38
|
-
* surface is hidden rather than half-built.
|
|
39
|
-
*/
|
|
40
35
|
export async function twoFactorOffered(): Promise<boolean> {
|
|
41
36
|
if (getContainer().dataSource !== 'postgres') return false
|
|
42
37
|
if (twoFactorService() === null) return false
|
|
@@ -49,7 +44,6 @@ export async function twoFactorRequiredForStaff(): Promise<boolean> {
|
|
|
49
44
|
return (await getSettingsUncached()).get('security.two_factor_required_for_staff')
|
|
50
45
|
}
|
|
51
46
|
|
|
52
|
-
/** Whether this member must hold a second factor, and may not drop it. */
|
|
53
47
|
export async function secondFactorPosture(userId: number | null): Promise<SecondFactorPosture> {
|
|
54
48
|
const offered = await twoFactorOffered()
|
|
55
49
|
if (!offered || userId === null) return { offered, required: false }
|
|
@@ -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
|
package/src/view/account-copy.ts
CHANGED
|
@@ -297,6 +297,11 @@ export function notificationFormsCopy(
|
|
|
297
297
|
'accountForm.notifications.push',
|
|
298
298
|
'accountForm.notifications.blurb',
|
|
299
299
|
'accountForm.notifications.submit',
|
|
300
|
+
'accountForm.announcements.legend',
|
|
301
|
+
'accountForm.announcements.title',
|
|
302
|
+
'accountForm.announcements.description',
|
|
303
|
+
'accountForm.announcements.blurb',
|
|
304
|
+
'accountForm.announcements.submit',
|
|
300
305
|
'accountForm.push.heading',
|
|
301
306
|
'accountForm.push.subscribe',
|
|
302
307
|
'accountForm.push.unsubscribe',
|
|
@@ -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/auth-copy.ts
CHANGED
|
@@ -34,6 +34,8 @@ export function registerFormCopy(
|
|
|
34
34
|
'authForm.register.password',
|
|
35
35
|
'authForm.register.honeypot',
|
|
36
36
|
'authForm.register.challengeHint',
|
|
37
|
+
'authForm.register.announcements',
|
|
38
|
+
'authForm.register.announcementsHint',
|
|
37
39
|
'authForm.register.submit',
|
|
38
40
|
],
|
|
39
41
|
t,
|
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,
|
|
@@ -62,7 +62,6 @@ function button(spec: ToolSpec, t: Translator): EditorToolbarButtonModel {
|
|
|
62
62
|
|
|
63
63
|
export interface EditorToolbarInput {
|
|
64
64
|
readonly textareaId?: string
|
|
65
|
-
/** Whether this composer takes attachments. Defaults to `false`. */
|
|
66
65
|
readonly attachments?: boolean
|
|
67
66
|
readonly t?: Translator
|
|
68
67
|
}
|
|
@@ -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/navigation.ts
CHANGED
|
@@ -67,10 +67,6 @@ export function defaultNavigationItems(): readonly NavigationItemRow[] {
|
|
|
67
67
|
}))
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
* What a plugin called an item, for the rows the board holds on its behalf.
|
|
72
|
-
* Keyed by the same `plugin.<plugin>.<item>` the row carries.
|
|
73
|
-
*/
|
|
74
70
|
export interface NavigationName {
|
|
75
71
|
readonly label: string
|
|
76
72
|
readonly labelKey: string | null
|
|
@@ -13,14 +13,12 @@ export type NotificationMenuTabKind = 'notifications' | 'messages' | 'mod'
|
|
|
13
13
|
|
|
14
14
|
export interface NotificationMenuRow {
|
|
15
15
|
readonly key: string
|
|
16
|
-
/** The id a mark-seen form submits, or `null` for a row that is acted on elsewhere. */
|
|
17
16
|
readonly seenId: number | null
|
|
18
17
|
readonly subject: string
|
|
19
18
|
readonly meta: string | null
|
|
20
19
|
readonly href: string | null
|
|
21
20
|
readonly at: TimeModel
|
|
22
21
|
readonly isUnread: boolean
|
|
23
|
-
/** A short category label shown before the subject, or `null`. */
|
|
24
22
|
readonly tag: string | null
|
|
25
23
|
}
|
|
26
24
|
|
|
@@ -77,6 +77,7 @@ export function notificationNotice(
|
|
|
77
77
|
},
|
|
78
78
|
t: Translator = untranslated(),
|
|
79
79
|
): string | null {
|
|
80
|
+
if (query.saved === 'announcements') return t.t('notice.announcementsSaved')
|
|
80
81
|
if (query.saved !== undefined) return t.t('notice.preferencesSaved')
|
|
81
82
|
if (query.read === 'all') return t.t('notice.allRead')
|
|
82
83
|
if (query.read === 'one') return t.t('notice.markedRead')
|
package/src/view/pager.ts
CHANGED
|
@@ -15,12 +15,6 @@ export type Cursor = Readonly<Record<string, string>>
|
|
|
15
15
|
export interface PagerInput {
|
|
16
16
|
readonly path: string
|
|
17
17
|
readonly params: QueryParams
|
|
18
|
-
/**
|
|
19
|
-
* The query parameters the cursor owns. They are rewritten on every step and
|
|
20
|
-
* never carried forward from the current address, which is what keeps a
|
|
21
|
-
* two-part cursor — a rank and an id — from being half of one page and half
|
|
22
|
-
* of the next.
|
|
23
|
-
*/
|
|
24
18
|
readonly cursorParams: readonly string[]
|
|
25
19
|
readonly nextCursor: Cursor | null
|
|
26
20
|
readonly pageSize?: number
|
package/src/view/panel-nav.ts
CHANGED
|
@@ -12,7 +12,6 @@ import { untranslated } from './time'
|
|
|
12
12
|
export interface PanelSubsection {
|
|
13
13
|
readonly href: string
|
|
14
14
|
readonly titleKey?: string
|
|
15
|
-
/** Set instead of `titleKey` where the name is data, such as a plugin's. */
|
|
16
15
|
readonly titleText?: string
|
|
17
16
|
readonly record?: boolean
|
|
18
17
|
}
|
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,
|
|
@@ -30,11 +30,6 @@ export function authEventLabel(kind: string, t: Translator = untranslated()): st
|
|
|
30
30
|
return key === undefined ? kind.replace(/_/g, ' ') : t.t(key)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* Enough of a user-agent string for a member to tell one of their own devices
|
|
35
|
-
* from another, and no more: the full string is a fingerprint, and this is a
|
|
36
|
-
* page whose job is recognition, not identification.
|
|
37
|
-
*/
|
|
38
33
|
export function describeDevice(userAgent: string | null, t: Translator = untranslated()): string {
|
|
39
34
|
const unknown = t.t('device.unknown')
|
|
40
35
|
if (userAgent === null || userAgent.trim() === '') return unknown
|
|
@@ -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],
|
|
@@ -70,6 +78,7 @@ export function unsubscribeNotice(
|
|
|
70
78
|
done: string | undefined,
|
|
71
79
|
t: Translator = untranslated(),
|
|
72
80
|
): string | null {
|
|
81
|
+
if (done === 'announcements') return t.t('subscription.announcementsOff')
|
|
73
82
|
if (done === 'email') return t.t('subscription.emailsOff')
|
|
74
83
|
if (done === 'one') return t.t('subscription.doneOne')
|
|
75
84
|
return null
|
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,
|
package/src/view/two-factor.ts
CHANGED
|
@@ -1,6 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The form-state key the fresh recovery codes travel back under. It lives here
|
|
3
|
-
* rather than beside the action because a `'use server'` module may export
|
|
4
|
-
* nothing but async functions, and the client component needs to read it.
|
|
5
|
-
*/
|
|
6
1
|
export const RECOVERY_CODES_FIELD = 'recoveryCodes'
|
package/src/view/usercp.ts
CHANGED