@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.
Files changed (101) hide show
  1. package/app/(board)/[slug]/page.tsx +2 -0
  2. package/app/(board)/discover/[view]/page.tsx +2 -0
  3. package/app/(board)/notifications/preferences/page.tsx +15 -1
  4. package/app/(board)/online/page.tsx +3 -1
  5. package/app/(board)/page.tsx +5 -0
  6. package/app/(board)/subscriptions/page.tsx +7 -1
  7. package/app/(board)/thread/[slug]/page.tsx +11 -4
  8. package/app/(board)/thread/[slug]/reply/page.tsx +2 -0
  9. package/app/(board)/unsubscribe/page.tsx +5 -3
  10. package/app/admin/api/marketplace/screenshot/route.ts +0 -8
  11. package/app/admin/users/ban-filters/page.tsx +94 -0
  12. package/app/admin/users/mail/page.tsx +1 -1
  13. package/app/layout.tsx +1 -3
  14. package/app/manifest.ts +1 -3
  15. package/bin/forum-web.mjs +1 -182
  16. package/bin/forum-web.test.ts +0 -22
  17. package/next.config.mjs +15 -29
  18. package/package.json +50 -49
  19. package/proxy.ts +0 -14
  20. package/src/components/account/notification-forms.tsx +45 -0
  21. package/src/components/admin/admin-nav.tsx +0 -4
  22. package/src/components/admin/ban-filter-forms.tsx +104 -0
  23. package/src/components/admin/marketplace-listing.tsx +0 -9
  24. package/src/components/auth/passkey-client.ts +0 -5
  25. package/src/components/auth/register-form.tsx +18 -0
  26. package/src/components/board/section-page.tsx +2 -0
  27. package/src/components/content/composer-ids.ts +0 -1
  28. package/src/components/content/markdown-editor.tsx +0 -7
  29. package/src/components/shell/demo-banner.tsx +0 -8
  30. package/src/config.ts +0 -11
  31. package/src/server/admin-actions.ts +0 -5
  32. package/src/server/antispam.ts +27 -14
  33. package/src/server/api/search.ts +1 -1
  34. package/src/server/api/subscriptions.ts +5 -1
  35. package/src/server/auth-actions.ts +12 -1
  36. package/src/server/auth-config.ts +0 -11
  37. package/src/server/auth-events.ts +0 -5
  38. package/src/server/ban-filter-admin-actions.ts +112 -0
  39. package/src/server/ban-filter-admin.ts +25 -0
  40. package/src/server/brand-assets.ts +2 -6
  41. package/src/server/container.ts +13 -11
  42. package/src/server/cookies.ts +0 -24
  43. package/src/server/demo-uploads.ts +0 -11
  44. package/src/server/demo.ts +0 -21
  45. package/src/server/federation-actions.ts +0 -6
  46. package/src/server/federation.ts +0 -7
  47. package/src/server/feed-builder.ts +2 -2
  48. package/src/server/feed-routes.ts +0 -5
  49. package/src/server/i18n.ts +1 -3
  50. package/src/server/install.ts +6 -1
  51. package/src/server/location-header.ts +0 -30
  52. package/src/server/mail-send.ts +0 -6
  53. package/src/server/marketplace-actions.ts +0 -6
  54. package/src/server/marketplace-admin.ts +0 -33
  55. package/src/server/navigation.ts +0 -5
  56. package/src/server/notification-actions.ts +25 -0
  57. package/src/server/onboarding.ts +0 -5
  58. package/src/server/plugin-admin-actions.ts +0 -11
  59. package/src/server/plugin-admin.ts +0 -3
  60. package/src/server/plugin-host.ts +15 -6
  61. package/src/server/plugin-pages.ts +3 -68
  62. package/src/server/plugin-routes.ts +1 -1
  63. package/src/server/plugin-runtime.ts +78 -0
  64. package/src/server/plugin-view.tsx +2 -2
  65. package/src/server/presence.ts +3 -34
  66. package/src/server/relations.ts +1 -3
  67. package/src/server/reply-core.ts +1 -0
  68. package/src/server/request-fingerprint.ts +0 -1
  69. package/src/server/search-page.ts +0 -4
  70. package/src/server/session-actions.ts +0 -1
  71. package/src/server/settings.ts +0 -11
  72. package/src/server/subscription-actions.ts +12 -4
  73. package/src/server/syndication.ts +0 -4
  74. package/src/server/test-container.ts +10 -1
  75. package/src/server/theme-admin.ts +2 -6
  76. package/src/server/thread-core.ts +6 -1
  77. package/src/server/two-factor-actions.ts +0 -5
  78. package/src/server/two-factor.ts +0 -6
  79. package/src/server/upgrade-notice.ts +1 -1
  80. package/src/view/account-copy.ts +5 -0
  81. package/src/view/admin-ban-filter-copy.ts +34 -0
  82. package/src/view/admin-nav.ts +4 -0
  83. package/src/view/auth-copy.ts +2 -0
  84. package/src/view/board-index.ts +10 -2
  85. package/src/view/board-latest.ts +2 -2
  86. package/src/view/discovery-view.ts +4 -1
  87. package/src/view/editor-toolbar.ts +0 -1
  88. package/src/view/forum-display.ts +16 -4
  89. package/src/view/navigation.ts +0 -4
  90. package/src/view/notification-menu.ts +0 -2
  91. package/src/view/notifications.ts +1 -0
  92. package/src/view/pager.ts +0 -6
  93. package/src/view/panel-nav.ts +0 -1
  94. package/src/view/post-form.ts +4 -1
  95. package/src/view/presence.ts +6 -2
  96. package/src/view/search-results.ts +1 -1
  97. package/src/view/security-activity.ts +0 -5
  98. package/src/view/subscriptions.ts +12 -3
  99. package/src/view/thread-view.ts +1 -1
  100. package/src/view/two-factor.ts +0 -5
  101. 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({ actor, postCount: profile.postCount, settings }),
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
@@ -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.21.2'
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
@@ -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
+ }
@@ -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
  {
@@ -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,
@@ -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,
@@ -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(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,
@@ -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
@@ -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
  }
@@ -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,
@@ -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(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],
@@ -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
@@ -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,
@@ -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'
@@ -59,9 +59,7 @@ export function availableTimezones(): readonly string[] {
59
59
  if (typeof supported === 'function') {
60
60
  try {
61
61
  return supported('timeZone')
62
- } catch {
63
- /* ignore */
64
- }
62
+ } catch {}
65
63
  }
66
64
 
67
65
  return FALLBACK_TIMEZONES