@meith/web 0.31.0 → 0.33.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 (174) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/layout.tsx +23 -8
  35. package/app/admin/page.tsx +12 -6
  36. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  37. package/app/admin/plugins/page.tsx +3 -6
  38. package/app/admin/users/[id]/page.tsx +5 -11
  39. package/app/admin/users/prune/page.tsx +4 -12
  40. package/app/admin/webhooks/page.tsx +144 -0
  41. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  42. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  43. package/app/atom.xml/route.ts +2 -2
  44. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  45. package/app/feed.xml/route.ts +2 -2
  46. package/app/offline/route.ts +98 -0
  47. package/next.config.mjs +1 -0
  48. package/package.json +49 -48
  49. package/public/sw.js +42 -2
  50. package/src/components/account/draft-forms.tsx +39 -0
  51. package/src/components/account/feed-token.tsx +95 -0
  52. package/src/components/account/notification-forms.tsx +37 -2
  53. package/src/components/account/relation-forms.tsx +3 -2
  54. package/src/components/account/subscription-forms.tsx +20 -6
  55. package/src/components/account/usercp-forms.tsx +44 -0
  56. package/src/components/admin/admin-forms.tsx +57 -9
  57. package/src/components/admin/api-token-forms.tsx +11 -7
  58. package/src/components/admin/ban-filter-forms.tsx +15 -11
  59. package/src/components/admin/content-forms.tsx +31 -14
  60. package/src/components/admin/forum-forms.tsx +241 -129
  61. package/src/components/admin/forum-tree.tsx +5 -7
  62. package/src/components/admin/navigation-forms.tsx +43 -26
  63. package/src/components/admin/navigation-tree.tsx +8 -13
  64. package/src/components/admin/oklch-picker.tsx +2 -1
  65. package/src/components/admin/theme-changes.tsx +3 -2
  66. package/src/components/admin/theme-forms.tsx +3 -2
  67. package/src/components/admin/webhook-forms.tsx +137 -0
  68. package/src/components/auth/register-form.tsx +4 -7
  69. package/src/components/board/live-region.tsx +3 -1
  70. package/src/components/content/attachment-field.tsx +93 -22
  71. package/src/components/content/combobox-keys.ts +38 -0
  72. package/src/components/content/edit-post-form.tsx +75 -1
  73. package/src/components/content/markdown-editor.tsx +29 -86
  74. package/src/components/content/mention-combobox.tsx +137 -0
  75. package/src/components/content/multiquote-button.tsx +15 -12
  76. package/src/components/content/new-thread-form.tsx +95 -17
  77. package/src/components/content/poll-edit-form.tsx +61 -39
  78. package/src/components/content/poll-vote-form.tsx +91 -0
  79. package/src/components/content/poll.tsx +12 -48
  80. package/src/components/content/progressive-marker.tsx +13 -0
  81. package/src/components/content/recipient-segment.ts +39 -0
  82. package/src/components/content/reply-form.tsx +9 -1
  83. package/src/components/content/thanks-button.tsx +12 -7
  84. package/src/components/messages/message-forms.tsx +53 -43
  85. package/src/components/messages/recipient-field.tsx +88 -0
  86. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  87. package/src/components/moderation/modcp-shell.tsx +17 -3
  88. package/src/components/moderation/queue-form.tsx +3 -6
  89. package/src/components/moderation/report-forms.tsx +24 -5
  90. package/src/components/moderation/thread-tools-form.tsx +2 -0
  91. package/src/components/shell/board-notice.tsx +22 -1
  92. package/src/components/shell/confirm-dialog.tsx +93 -0
  93. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  94. package/src/components/shell/notice-toast.tsx +40 -0
  95. package/src/components/shell/notification-menu.tsx +5 -3
  96. package/src/components/shell/page-shell.tsx +3 -0
  97. package/src/components/shell/scheme-toggle.tsx +72 -0
  98. package/src/components/shell/theme-switcher.tsx +16 -36
  99. package/src/components/shell/view-tabs.tsx +1 -1
  100. package/src/server/admin-actions.ts +118 -49
  101. package/src/server/admin-settings-actions.ts +5 -0
  102. package/src/server/admin.ts +70 -2
  103. package/src/server/api/search.ts +8 -2
  104. package/src/server/api-token-actions.ts +4 -0
  105. package/src/server/appearance-actions.ts +21 -0
  106. package/src/server/attachments.ts +35 -0
  107. package/src/server/auth-actions.ts +2 -0
  108. package/src/server/auth-form-state.ts +28 -0
  109. package/src/server/auto-watch.ts +31 -0
  110. package/src/server/ban-filter-admin-actions.ts +5 -0
  111. package/src/server/confirm.ts +19 -0
  112. package/src/server/content-actions.ts +129 -19
  113. package/src/server/content-admin-actions.ts +31 -0
  114. package/src/server/cookies.ts +18 -0
  115. package/src/server/discovery.ts +11 -1
  116. package/src/server/feed-routes.ts +13 -4
  117. package/src/server/feed-token-actions.ts +61 -0
  118. package/src/server/feed-token.ts +78 -0
  119. package/src/server/fixture-post-repo.ts +18 -0
  120. package/src/server/forum-admin-actions.ts +23 -16
  121. package/src/server/forum-admin.ts +10 -1
  122. package/src/server/inline-moderation-actions.ts +6 -0
  123. package/src/server/message-actions.ts +9 -0
  124. package/src/server/navigation-admin-actions.ts +6 -0
  125. package/src/server/plugin-admin.ts +7 -2
  126. package/src/server/plugin-pages.ts +50 -0
  127. package/src/server/plugin-panel.ts +34 -1
  128. package/src/server/plugin-routes.ts +8 -0
  129. package/src/server/plugin-view.tsx +46 -7
  130. package/src/server/poll-actions.ts +13 -1
  131. package/src/server/presence.ts +23 -0
  132. package/src/server/push-actions.ts +9 -0
  133. package/src/server/reply-core.ts +8 -1
  134. package/src/server/report-actions.ts +13 -2
  135. package/src/server/reputation-actions.ts +12 -1
  136. package/src/server/search-page.ts +8 -2
  137. package/src/server/search.ts +18 -5
  138. package/src/server/session-cookies.ts +18 -0
  139. package/src/server/subscription-actions.ts +9 -0
  140. package/src/server/thread-core.ts +10 -2
  141. package/src/server/thread-tool-actions.ts +6 -0
  142. package/src/server/unread-goto.ts +28 -0
  143. package/src/server/upgrade-notice.ts +1 -1
  144. package/src/server/usercp-actions.ts +31 -1
  145. package/src/server/viewer-preferences.ts +5 -0
  146. package/src/server/webhook-actions.ts +98 -0
  147. package/src/server/webhooks-admin.ts +122 -0
  148. package/src/styles/globals.css +1 -1
  149. package/src/theme/contract.fixture.ts +36 -5
  150. package/src/view/account-copy.ts +39 -0
  151. package/src/view/admin-copy.ts +4 -9
  152. package/src/view/admin-nav.ts +6 -0
  153. package/src/view/admin-panel-copy.ts +29 -1
  154. package/src/view/attachments.ts +33 -1
  155. package/src/view/board-index.ts +31 -2
  156. package/src/view/board-title.ts +1 -0
  157. package/src/view/content-copy.ts +22 -12
  158. package/src/view/copy.ts +17 -0
  159. package/src/view/discovery-view.ts +2 -1
  160. package/src/view/drafts.ts +45 -0
  161. package/src/view/editor-toolbar.ts +14 -0
  162. package/src/view/forum-display.ts +39 -7
  163. package/src/view/modcp-nav.ts +32 -0
  164. package/src/view/moderation-copy.ts +20 -0
  165. package/src/view/notifications.ts +1 -0
  166. package/src/view/plugin-panel.ts +13 -0
  167. package/src/view/poll-vote.ts +46 -0
  168. package/src/view/post-link.ts +8 -1
  169. package/src/view/progressive-enhancement.ts +5 -0
  170. package/src/view/shell.ts +2 -1
  171. package/src/view/subscriptions.ts +1 -0
  172. package/src/view/thread-view.ts +41 -2
  173. package/src/view/usercp-nav.ts +6 -0
  174. package/src/view/usercp.ts +14 -0
@@ -0,0 +1,78 @@
1
+ import 'server-only'
2
+
3
+ import { authenticateFeedToken, FEED_TOKEN_PREFIX, issueToken } from '@meith/api'
4
+ import { PUBLIC_CONTENT, ValidationError } from '@meith/core'
5
+ import {
6
+ type FeedScope,
7
+ type FeedTokenSummary,
8
+ getDb,
9
+ PostgresFeedTokenRepository,
10
+ } from '@meith/db'
11
+ import { msg } from '@meith/i18n'
12
+
13
+ import { getContainer } from './container'
14
+ import { publicScope } from './syndication'
15
+
16
+ export type { FeedTokenSummary }
17
+
18
+ export function feedTokenStore(): PostgresFeedTokenRepository | null {
19
+ return getContainer().dataSource === 'postgres' ? new PostgresFeedTokenRepository(getDb()) : null
20
+ }
21
+
22
+ export async function resolveFeedToken(presented: string): Promise<number | null> {
23
+ const store = feedTokenStore()
24
+ if (store === null) return null
25
+
26
+ const outcome = await authenticateFeedToken(presented, store)
27
+ if (!outcome.ok) return null
28
+
29
+ await store.touch(outcome.record.id, new Date()).catch(() => {})
30
+ return outcome.record.userId
31
+ }
32
+
33
+ export async function memberFeedScope(userId: number): Promise<FeedScope | null> {
34
+ const { authorizer, actorSource } = getContainer()
35
+ const actor = await actorSource.buildForUser(userId)
36
+ if (actor === null) return null
37
+
38
+ return { ...(await authorizer.threadAudience(actor)), content: PUBLIC_CONTENT }
39
+ }
40
+
41
+ export interface ResolvedFeedScope {
42
+ readonly scope: FeedScope
43
+ readonly tokened: boolean
44
+ }
45
+
46
+ export async function feedScopeForRequest(request: Request): Promise<ResolvedFeedScope> {
47
+ const presented = new URL(request.url).searchParams.get('token')
48
+ if (presented === null || presented === '') {
49
+ return { scope: await publicScope(), tokened: false }
50
+ }
51
+
52
+ const userId = await resolveFeedToken(presented)
53
+ if (userId === null) return { scope: await publicScope(), tokened: true }
54
+
55
+ const member = await memberFeedScope(userId)
56
+ return { scope: member ?? (await publicScope()), tokened: true }
57
+ }
58
+
59
+ export async function issueFeedToken(userId: number): Promise<string> {
60
+ const store = feedTokenStore()
61
+ if (store === null) throw new ValidationError(msg('error.app.feed-token-needs-database'))
62
+
63
+ const issued = issueToken(FEED_TOKEN_PREFIX)
64
+ await store.regenerate({ userId, lookup: issued.lookup, secretHash: issued.secretHash })
65
+ return issued.token
66
+ }
67
+
68
+ export async function revokeFeedToken(userId: number): Promise<void> {
69
+ const store = feedTokenStore()
70
+ if (store === null) return
71
+ await store.revokeForUser(userId)
72
+ }
73
+
74
+ export async function feedTokenSummary(userId: number): Promise<FeedTokenSummary | null> {
75
+ const store = feedTokenStore()
76
+ if (store === null) return null
77
+ return store.summaryForUser(userId)
78
+ }
@@ -82,6 +82,24 @@ export class FixturePostRepository implements PostRepository {
82
82
  }
83
83
  }
84
84
 
85
+ async locateFirstUnread(
86
+ threadId: number,
87
+ after: { readonly postId: number; readonly since: Date | null },
88
+ options: { readonly scope: ContentScope; readonly pageSize: number },
89
+ ): Promise<PostLocation | null> {
90
+ const first = this.rows
91
+ .filter(
92
+ (row) =>
93
+ row.threadId === threadId &&
94
+ options.scope.states.includes(row.visibility) &&
95
+ row.id > after.postId &&
96
+ (after.since === null || row.createdAt > after.since),
97
+ )
98
+ .sort((a, b) => a.id - b.id)[0]
99
+
100
+ return first === undefined ? null : this.locate(threadId, first.id, options)
101
+ }
102
+
85
103
  async listThread(
86
104
  threadId: number,
87
105
  options: {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { revalidatePath } from 'next/cache'
4
4
 
5
- import { readMatrixCell } from '@meith/authorization'
5
+ import { diffMatrixSubmission, matrixCellName, readMatrixCell } from '@meith/authorization'
6
6
  import { CacheTags, FORUM_PERMISSION_FIELDS, ValidationError } from '@meith/core'
7
7
  import { MODERATOR_RIGHTS } from '@meith/db'
8
8
  import { drivers } from '@meith/drivers'
@@ -86,31 +86,38 @@ export async function saveForumOptionsAction(_prev: FormState, form: FormData):
86
86
  }
87
87
  }
88
88
 
89
- export async function saveForumPermissionsAction(
89
+ export async function saveForumPermissionMatrixAction(
90
90
  _prev: FormState,
91
91
  form: FormData,
92
92
  ): Promise<FormState> {
93
93
  try {
94
94
  await requireAdmin()
95
95
  const id = forumId(form)
96
- const groupId = Number(trimmedText(form, 'groupId'))
97
- if (!Number.isSafeInteger(groupId) || groupId <= 0) {
98
- throw new ValidationError(msg('error.app.such-group'))
99
- }
96
+ const repository = requireForumAdmin()
100
97
 
101
- const values: Record<string, boolean | number | null> = {}
102
- for (const field of FORUM_PERMISSION_FIELDS) {
103
- const raw = form.get(field.key)
104
- values[field.key] = readMatrixCell(field, typeof raw === 'string' ? raw : undefined)
105
- }
98
+ const groups = await repository.listGroups()
99
+ const current = await repository.readOverrides([id])
100
+
101
+ const submissions = groups.map((group) => ({
102
+ groupId: group.id,
103
+ values: Object.fromEntries(
104
+ FORUM_PERMISSION_FIELDS.map((field) => {
105
+ const raw = form.get(matrixCellName(group.id, field.key))
106
+ return [field.key, readMatrixCell(field, typeof raw === 'string' ? raw : undefined)]
107
+ }),
108
+ ),
109
+ }))
106
110
 
107
- await requireForumAdmin().saveOverrides(id, groupId, values)
111
+ const changes = diffMatrixSubmission(current, id, submissions)
112
+ await repository.saveOverridesForGroups(id, changes)
108
113
 
109
114
  refreshForumPermissionScreens()
110
- await recordAdminAction({
111
- action: 'forum.permissions_changed',
112
- detail: { forumId: id, groupId },
113
- })
115
+ for (const change of changes) {
116
+ await recordAdminAction({
117
+ action: 'forum.permissions_changed',
118
+ detail: { forumId: id, groupId: change.groupId },
119
+ })
120
+ }
114
121
 
115
122
  return { notice: 'saved' }
116
123
  } catch (err) {
@@ -2,10 +2,14 @@ import { msg } from '@meith/i18n'
2
2
  import 'server-only'
3
3
 
4
4
  import {
5
+ buildFieldMatrix,
5
6
  buildPermissionMatrix,
6
7
  type CopyPlan,
8
+ type FieldMatrixRow,
7
9
  type ForumOverride,
10
+ type MatrixColumn,
8
11
  type MatrixRow,
12
+ matrixColumns,
9
13
  planCopyToDescendants,
10
14
  } from '@meith/authorization'
11
15
  import { ForbiddenError, type PermissionSet } from '@meith/core'
@@ -37,6 +41,8 @@ export function ancestorChain(forum: ForumRow): readonly number[] {
37
41
  export interface ForumMatrixView {
38
42
  readonly forum: ForumRow
39
43
  readonly rows: readonly MatrixRow[]
44
+ readonly columns: readonly MatrixColumn[]
45
+ readonly fields: readonly FieldMatrixRow[]
40
46
  readonly chain: readonly number[]
41
47
  readonly overrides: readonly ForumOverride[]
42
48
  readonly groups: readonly { readonly groupId: number; readonly title: string }[]
@@ -77,6 +83,7 @@ export async function buildForumMatrixView(forumId: number): Promise<ForumMatrix
77
83
  const descendants = all.filter((row) => row.path.startsWith(`${forum.path}.`))
78
84
 
79
85
  const overrides = await repository.readOverrides([...chain, ...descendants.map((row) => row.id)])
86
+ const rows = buildPermissionMatrix({ chain, groups, overrides })
80
87
 
81
88
  return {
82
89
  forum,
@@ -84,7 +91,9 @@ export async function buildForumMatrixView(forumId: number): Promise<ForumMatrix
84
91
  overrides,
85
92
  descendants,
86
93
  groups: groups.map(({ groupId, title }) => ({ groupId, title })),
87
- rows: buildPermissionMatrix({ chain, groups, overrides }),
94
+ rows,
95
+ columns: matrixColumns(rows),
96
+ fields: buildFieldMatrix(rows),
88
97
  }
89
98
  }
90
99
 
@@ -16,6 +16,7 @@ import {
16
16
  } from '@meith/moderation'
17
17
 
18
18
  import type { FormState } from './auth-form-state'
19
+ import { requireConfirmation } from './confirm'
19
20
  import { getContainer } from './container'
20
21
  import { getActor } from './context'
21
22
  import { formStateReporter } from './form-state-reporter'
@@ -71,6 +72,11 @@ export async function inlineModerateAction(_prev: FormState, form: FormData): Pr
71
72
  throw new ForbiddenError(msg('error.app.moderate-anything-here'))
72
73
  }
73
74
 
75
+ if (tool === 'delete') {
76
+ const confirm = requireConfirmation(form, await tr('moderationForm.confirm.delete'))
77
+ if (confirm !== null) return confirm
78
+ }
79
+
74
80
  outcome = await new InlineModeration({ inline: inlineModeration }).apply({
75
81
  selection,
76
82
  tool,
@@ -9,9 +9,11 @@ import { parseFolder } from '@meith/messages'
9
9
  import { folderHref } from '../view/messages'
10
10
  import { dailyLimitMessage, limitMessage, spendDailyLimit, spendLimit } from './antispam'
11
11
  import type { FormState } from './auth-form-state'
12
+ import { requireConfirmation } from './confirm'
12
13
  import { getActor } from './context'
13
14
  import { formStateReporter } from './form-state-reporter'
14
15
  import { text } from './form-values'
16
+ import { tr } from './i18n'
15
17
  import { requireMessaging } from './messages'
16
18
 
17
19
  const toFormState = formStateReporter('message-actions', 'unexpected error in a message action')
@@ -65,12 +67,19 @@ export async function messageBulkAction(_prev: FormState, form: FormData): Promi
65
67
  const { service, userId } = await requireMessaging(await getActor())
66
68
 
67
69
  if (command === 'empty') {
70
+ const confirm = requireConfirmation(form, await tr('messageForm.confirm.emptyTrash'))
71
+ if (confirm !== null) return confirm
68
72
  query = `emptied=${await service.emptyTrash(userId)}`
69
73
  } else {
70
74
  const copyIds = selectedIds(form)
71
75
  if (copyIds.length === 0)
72
76
  throw new ValidationError(msg('error.app.select-at-least-one-message'))
73
77
 
78
+ if (command === 'delete') {
79
+ const confirm = requireConfirmation(form, await tr('messageForm.confirm.deleteForever'))
80
+ if (confirm !== null) return confirm
81
+ }
82
+
74
83
  switch (command) {
75
84
  case 'read':
76
85
  query = `marked=${await service.markRead(userId, copyIds)}`
@@ -16,8 +16,10 @@ import { NAVIGATION_AUDIENCE_VALUES, outlineOf } from '../view/navigation'
16
16
  import { isNavigationNudge, isWhereItIs, nudgeTarget } from '../view/navigation-arrange'
17
17
  import { recordAdminAction, requireAdmin } from './admin'
18
18
  import type { FormState } from './auth-form-state'
19
+ import { requireConfirmation } from './confirm'
19
20
  import { formStateReporter } from './form-state-reporter'
20
21
  import { checkbox, trimmedText } from './form-values'
22
+ import { tr } from './i18n'
21
23
  import { navigationRepository } from './navigation'
22
24
  import { isSafeLocalPath } from './safe-path'
23
25
 
@@ -150,6 +152,10 @@ export async function deleteNavigationItemAction(
150
152
  await requireAdmin()
151
153
 
152
154
  const navigationItemId = itemId(form)
155
+
156
+ const confirm = requireConfirmation(form, await tr('adminNavigation.confirm.delete'))
157
+ if (confirm !== null) return confirm
158
+
153
159
  await requireNavigation().delete(navigationItemId)
154
160
 
155
161
  await refresh()
@@ -2,7 +2,7 @@ import 'server-only'
2
2
 
3
3
  import forumConfig from '@board/config'
4
4
 
5
- import { env, logger, readPluginEnv } from '@meith/core'
5
+ import { cronCadenceSeconds, env, logger, parseCron, readPluginEnv } from '@meith/core'
6
6
  import {
7
7
  appliedPluginMigrations,
8
8
  getDb,
@@ -58,6 +58,7 @@ export interface PluginTaskRow {
58
58
  readonly id: string
59
59
  readonly registeredId: string
60
60
  readonly intervalSeconds: number
61
+ readonly schedule: string | null
61
62
  }
62
63
 
63
64
  export interface PluginPageRow {
@@ -228,7 +229,11 @@ export async function pluginInventory(t?: Translator): Promise<PluginInventory>
228
229
  (task): PluginTaskRow => ({
229
230
  id: task.id,
230
231
  registeredId: pluginTaskId(entry.key, task.id),
231
- intervalSeconds: task.intervalSeconds,
232
+ intervalSeconds:
233
+ task.schedule === undefined
234
+ ? task.intervalSeconds
235
+ : cronCadenceSeconds(parseCron(task.schedule)),
236
+ schedule: task.schedule ?? null,
232
237
  }),
233
238
  ),
234
239
 
@@ -7,6 +7,7 @@ import { logger } from '@meith/core'
7
7
  import type { PluginDefinition } from '@meith/plugin-kit'
8
8
 
9
9
  import { getTranslator } from './i18n'
10
+ import { resolveModCpAccess } from './modcp'
10
11
  import { runtimeContextFor } from './plugin-runtime'
11
12
  import { getSettingOverrides } from './settings'
12
13
 
@@ -26,6 +27,11 @@ export type BoardPageResult =
26
27
  | { readonly outcome: 'missing' }
27
28
  | { readonly outcome: 'sign-in-first'; readonly title: string }
28
29
 
30
+ export type StaffPageResult =
31
+ | { readonly outcome: 'rendered'; readonly title: string; readonly node: ReactNode | null }
32
+ | { readonly outcome: 'missing' }
33
+ | { readonly outcome: 'forbidden' }
34
+
29
35
  function translated(
30
36
  t: Awaited<ReturnType<typeof getTranslator>> | undefined,
31
37
  key: string | undefined,
@@ -59,6 +65,7 @@ export async function renderPluginBoardPage(
59
65
 
60
66
  const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
61
67
  if (page === undefined) return { outcome: 'missing' }
68
+ if (page.access === 'staff') return { outcome: 'missing' }
62
69
 
63
70
  const overrides = await getSettingOverrides()
64
71
  if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
@@ -90,6 +97,49 @@ export async function renderPluginBoardPage(
90
97
  }
91
98
  }
92
99
 
100
+ export async function renderPluginStaffPage(
101
+ pluginKey: string,
102
+ path: string,
103
+ request: BoardPageRequest,
104
+ ): Promise<StaffPageResult> {
105
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
106
+ const definition = entry?.plugin as PluginDefinition | undefined
107
+
108
+ if (entry === undefined || definition === undefined) return { outcome: 'missing' }
109
+ if (entry.enabled === false) return { outcome: 'missing' }
110
+
111
+ const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
112
+ if (page === undefined || page.access !== 'staff') return { outcome: 'missing' }
113
+
114
+ const overrides = await getSettingOverrides()
115
+ if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
116
+
117
+ const access = await resolveModCpAccess()
118
+ if (access === null || !access.hasGroupAccess) return { outcome: 'forbidden' }
119
+
120
+ const [log, t] = [logger({ component: 'plugin-page', plugin: pluginKey }), await getTranslator()]
121
+ const title = translated(t, page.titleKey, page.title, page.titleArgs)
122
+
123
+ try {
124
+ return {
125
+ outcome: 'rendered',
126
+ title,
127
+ node: await page.render({
128
+ ...runtimeContextFor(pluginKey, definition, overrides, log),
129
+ viewer: request.viewer,
130
+ path,
131
+ query: request.query,
132
+ boardUrl: request.boardUrl,
133
+ locale: t.locale,
134
+ t,
135
+ }),
136
+ }
137
+ } catch (error) {
138
+ log.error({ err: error, path }, 'plugin staff page failed to render')
139
+ return { outcome: 'rendered', title, node: null }
140
+ }
141
+ }
142
+
93
143
  export async function renderPluginAdminPage(
94
144
  pluginKey: string,
95
145
  path: string,
@@ -3,7 +3,7 @@ import 'server-only'
3
3
  import forumConfig from '@board/config'
4
4
 
5
5
  import type { Translator } from '@meith/i18n'
6
- import { type PluginDefinition, pluginAdminPath } from '@meith/plugin-kit'
6
+ import { type PluginDefinition, pluginAdminPath, pluginStaffPagePath } from '@meith/plugin-kit'
7
7
 
8
8
  import type { PluginPage } from '@/view/plugin-panel'
9
9
 
@@ -47,3 +47,36 @@ export async function pluginPanelSection(
47
47
  })),
48
48
  }
49
49
  }
50
+
51
+ export async function pluginStaffPanelSection(
52
+ pluginKey: string,
53
+ t?: Translator,
54
+ ): Promise<PluginPanelSection | null> {
55
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
56
+ const definition = entry?.plugin as PluginDefinition | undefined
57
+
58
+ if (entry === undefined || definition === undefined) return null
59
+ if (entry.enabled === false) return null
60
+
61
+ const pages = (definition.pages ?? []).filter((page) => page.access === 'staff')
62
+ if (pages.length === 0) return null
63
+
64
+ const overrides = await getSettingOverrides()
65
+ if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return null
66
+
67
+ return {
68
+ key: pluginKey,
69
+ name:
70
+ t !== undefined && definition.nameKey !== undefined && t.has(definition.nameKey)
71
+ ? t.t(definition.nameKey)
72
+ : definition.name,
73
+ pages: pages.map((page) => ({
74
+ path: page.path,
75
+ title:
76
+ t !== undefined && page.titleKey !== undefined && t.has(page.titleKey)
77
+ ? t.t(page.titleKey, page.titleArgs)
78
+ : page.title,
79
+ href: pluginStaffPagePath(pluginKey, page.path),
80
+ })),
81
+ }
82
+ }
@@ -16,6 +16,7 @@ import {
16
16
  import { recordAdminAction, requireAdmin } from './admin'
17
17
  import { boardUrl } from './board-url'
18
18
  import { getActor } from './context'
19
+ import { resolveModCpAccess } from './modcp'
19
20
  import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
20
21
  import { runtimeContextFor } from './plugin-runtime'
21
22
  import { viewerRef } from './plugin-view'
@@ -213,6 +214,13 @@ export async function dispatchPluginRoute(
213
214
  return fail(401, 'unauthenticated', 'Sign in to use this.')
214
215
  }
215
216
 
217
+ if (route.access === 'staff') {
218
+ const access = await resolveModCpAccess()
219
+ if (access === null || !access.hasGroupAccess) {
220
+ return fail(403, 'staff_only', await tr('notice.app.moderators-only-endpoint'))
221
+ }
222
+ }
223
+
216
224
  if (route.access === 'admin') {
217
225
  try {
218
226
  await requireAdmin()
@@ -9,8 +9,10 @@ import type {
9
9
  HookValue,
10
10
  PluginRegion,
11
11
  PluginRegionContext,
12
+ ThreadRowBadgeSubject,
12
13
  } from '@meith/plugin-kit'
13
14
 
15
+ import { getTranslator } from './i18n'
14
16
  import { pluginHost, syncPluginEnablement } from './plugin-host'
15
17
 
16
18
  export function viewerRef(actor: Actor): { userId: number | null; isGuest: boolean } {
@@ -37,22 +39,59 @@ export async function emitEvent<K extends HookName>(
37
39
 
38
40
  export async function pluginRegion(
39
41
  region: PluginRegion,
40
- context: Omit<PluginRegionContext, 'region' | 'runtime'>,
42
+ context: Omit<PluginRegionContext, 'region' | 'runtime' | 'locale' | 't'>,
43
+ card?: string,
41
44
  ): Promise<React.ReactNode> {
42
45
  await syncPluginEnablement()
43
46
 
44
- const nodes = await pluginHost.renderRegion(region, { ...context, region })
47
+ const t = await getTranslator()
48
+ const nodes = await pluginHost.renderRegion(region, { ...context, region, locale: t.locale, t })
45
49
  if (nodes.length === 0) return null
46
50
 
47
51
  return (
48
52
  <>
49
- {nodes.map((entry) => (
50
- <Fragment key={entry.key}>{entry.node}</Fragment>
51
- ))}
53
+ {nodes.map((entry) =>
54
+ card === undefined ? (
55
+ <Fragment key={entry.key}>{entry.node}</Fragment>
56
+ ) : (
57
+ <section key={entry.key} className={card}>
58
+ {entry.node}
59
+ </section>
60
+ ),
61
+ )}
52
62
  </>
53
63
  )
54
64
  }
55
65
 
56
- export async function boardRegion(region: PluginRegion, actor: Actor): Promise<React.ReactNode> {
57
- return pluginRegion(region, { viewer: viewerRef(actor), subjectId: null, authorId: null })
66
+ export async function boardRegion(
67
+ region: PluginRegion,
68
+ actor: Actor,
69
+ card?: string,
70
+ ): Promise<React.ReactNode> {
71
+ return pluginRegion(region, { viewer: viewerRef(actor), subjectId: null, authorId: null }, card)
72
+ }
73
+
74
+ export async function threadRowBadges(
75
+ actor: Actor,
76
+ threads: readonly ThreadRowBadgeSubject[],
77
+ ): Promise<ReadonlyMap<number, React.ReactNode>> {
78
+ await syncPluginEnablement()
79
+ if (threads.length === 0) return new Map()
80
+
81
+ const t = await getTranslator()
82
+ const byThread = await pluginHost.renderThreadRowBadges({
83
+ viewer: viewerRef(actor),
84
+ threads,
85
+ locale: t.locale,
86
+ t,
87
+ })
88
+
89
+ const out = new Map<number, React.ReactNode>()
90
+ for (const [threadId, nodes] of byThread) {
91
+ out.set(
92
+ threadId,
93
+ nodes.map((entry) => <Fragment key={entry.key}>{entry.node}</Fragment>),
94
+ )
95
+ }
96
+ return out
58
97
  }
@@ -6,6 +6,9 @@ import { ForbiddenError, ValidationError } from '@meith/core'
6
6
  import { msg } from '@meith/i18n'
7
7
  import { type PollEditOption, PollService } from '@meith/polls'
8
8
 
9
+ import { type PollVoteView, pollVoteView } from '@/view/poll-vote'
10
+ import { isEnhancedSubmit } from '@/view/progressive-enhancement'
11
+
9
12
  import { getContainer } from './container'
10
13
  import { getActor } from './context'
11
14
  import { emitEvent, viewerRef } from './plugin-view'
@@ -50,7 +53,10 @@ function editedOptions(form: FormData): readonly PollEditOption[] {
50
53
  })
51
54
  }
52
55
 
53
- export async function votePollAction(form: FormData): Promise<void> {
56
+ export async function votePollAction(
57
+ _prev: PollVoteView | null,
58
+ form: FormData,
59
+ ): Promise<PollVoteView | null> {
54
60
  const threadId = id(form, 'threadId')
55
61
  const pollId = id(form, 'pollId')
56
62
  const actor = await getActor()
@@ -72,6 +78,12 @@ export async function votePollAction(form: FormData): Promise<void> {
72
78
  for (const optionId of chosen) {
73
79
  await emitEvent('poll.voted', { pollId, optionId }, viewerRef(actor))
74
80
  }
81
+
82
+ if (isEnhancedSubmit(form)) {
83
+ const fresh = await polls.find(threadId, actor.userId)
84
+ return fresh === null ? null : pollVoteView(fresh, scope.mayVote, new Date())
85
+ }
86
+
75
87
  redirect(`/thread/${threadId}`)
76
88
  }
77
89
 
@@ -134,6 +134,29 @@ export async function onlineScopeFor(actor: Actor): Promise<OnlineScope> {
134
134
  }
135
135
  }
136
136
 
137
+ export interface AuthorPresence {
138
+ readonly lastActiveAt: Date | null
139
+ readonly invisible: boolean
140
+ }
141
+
142
+ export async function presenceFor(
143
+ userIds: readonly number[],
144
+ ): Promise<ReadonlyMap<number, AuthorPresence>> {
145
+ const repo = presenceRepository()
146
+ if (repo === null || userIds.length === 0) return new Map()
147
+
148
+ try {
149
+ const rows = await repo.lastActiveFor(userIds)
150
+ const out = new Map<number, AuthorPresence>()
151
+ for (const row of rows) {
152
+ out.set(row.userId, { lastActiveAt: row.lastActiveAt, invisible: row.invisible })
153
+ }
154
+ return out
155
+ } catch {
156
+ return new Map()
157
+ }
158
+ }
159
+
137
160
  export async function readOnline(actor: Actor, now: Date): Promise<OnlineSnapshot | null> {
138
161
  const repo = presenceRepository()
139
162
  if (repo === null) return null
@@ -15,6 +15,8 @@ const toFormState = formStateReporter('push-actions', 'unexpected error in web p
15
15
 
16
16
  const ENDPOINT_MAX = 1000
17
17
 
18
+ const SUBSCRIPTIONS_PER_USER_MAX = 20
19
+
18
20
  const BASE64URL = /^[A-Za-z0-9_-]+$/
19
21
 
20
22
  const IP_LITERAL = /^\[|^\d+\.\d+\.\d+\.\d+$/
@@ -77,6 +79,13 @@ export async function subscribeToPushAction(input: PushSubscriptionInput): Promi
77
79
  }
78
80
 
79
81
  const { service, userId } = await requirePushCentre()
82
+
83
+ const existing = await service.pushSubscriptions(userId)
84
+ const known = existing.some((subscription) => subscription.endpoint === input.endpoint)
85
+ if (!known && existing.length >= SUBSCRIPTIONS_PER_USER_MAX) {
86
+ return { error: await tr('notice.app.push-subscription-limit') }
87
+ }
88
+
80
89
  await service.subscribeToPush({
81
90
  userId,
82
91
  endpoint: input.endpoint,
@@ -4,7 +4,12 @@ import 'server-only'
4
4
  import type { Actor } from '@meith/authorization'
5
5
  import { ForbiddenError, ValidationError } from '@meith/core'
6
6
  import { restrictsPosting } from '@meith/moderation'
7
- import { type AuthorRestriction, ReplyComposer, type ReplyTarget } from '@meith/threads'
7
+ import {
8
+ type AuthorRestriction,
9
+ ReplyComposer,
10
+ type ReplyTarget,
11
+ type SubscriptionCadence,
12
+ } from '@meith/threads'
8
13
 
9
14
  import {
10
15
  dailyLimitMessage,
@@ -65,6 +70,7 @@ export async function resolveReplyTarget(
65
70
  export interface SubmitReplyInput {
66
71
  readonly message: string
67
72
  readonly subscribe?: boolean
73
+ readonly subscribeMode?: SubscriptionCadence
68
74
  readonly seenLastPostId?: number | null
69
75
  }
70
76
 
@@ -125,6 +131,7 @@ export async function submitReply(
125
131
  {
126
132
  message: draft.body,
127
133
  subscribe: input.subscribe ?? false,
134
+ ...(input.subscribeMode === undefined ? {} : { subscribeMode: input.subscribeMode }),
128
135
  seenLastPostId: input.seenLastPostId ?? null,
129
136
  bypassesModeration: authorizer.can(actor, 'content.viewUnapproved', scope),
130
137
  bypassesFlood: authorizer.can(actor, 'flood.bypass'),