@meith/web 0.31.0 → 0.32.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 (165) 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/page.tsx +12 -6
  35. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  36. package/app/admin/plugins/page.tsx +3 -6
  37. package/app/admin/users/[id]/page.tsx +5 -11
  38. package/app/admin/users/prune/page.tsx +4 -12
  39. package/app/admin/webhooks/page.tsx +144 -0
  40. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  41. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  42. package/app/atom.xml/route.ts +2 -2
  43. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  44. package/app/feed.xml/route.ts +2 -2
  45. package/app/offline/route.ts +98 -0
  46. package/next.config.mjs +1 -0
  47. package/package.json +49 -48
  48. package/public/sw.js +42 -2
  49. package/src/components/account/draft-forms.tsx +39 -0
  50. package/src/components/account/feed-token.tsx +95 -0
  51. package/src/components/account/notification-forms.tsx +37 -2
  52. package/src/components/account/relation-forms.tsx +3 -2
  53. package/src/components/account/subscription-forms.tsx +20 -6
  54. package/src/components/account/usercp-forms.tsx +44 -0
  55. package/src/components/admin/api-token-forms.tsx +11 -7
  56. package/src/components/admin/ban-filter-forms.tsx +15 -11
  57. package/src/components/admin/content-forms.tsx +31 -14
  58. package/src/components/admin/forum-forms.tsx +241 -129
  59. package/src/components/admin/forum-tree.tsx +5 -7
  60. package/src/components/admin/navigation-forms.tsx +43 -26
  61. package/src/components/admin/navigation-tree.tsx +8 -13
  62. package/src/components/admin/oklch-picker.tsx +2 -1
  63. package/src/components/admin/theme-changes.tsx +3 -2
  64. package/src/components/admin/theme-forms.tsx +3 -2
  65. package/src/components/admin/webhook-forms.tsx +137 -0
  66. package/src/components/auth/register-form.tsx +4 -7
  67. package/src/components/board/live-region.tsx +3 -1
  68. package/src/components/content/attachment-field.tsx +93 -22
  69. package/src/components/content/combobox-keys.ts +38 -0
  70. package/src/components/content/edit-post-form.tsx +75 -1
  71. package/src/components/content/markdown-editor.tsx +29 -86
  72. package/src/components/content/mention-combobox.tsx +137 -0
  73. package/src/components/content/multiquote-button.tsx +15 -12
  74. package/src/components/content/new-thread-form.tsx +95 -17
  75. package/src/components/content/poll-edit-form.tsx +61 -39
  76. package/src/components/content/poll-vote-form.tsx +91 -0
  77. package/src/components/content/poll.tsx +12 -48
  78. package/src/components/content/progressive-marker.tsx +13 -0
  79. package/src/components/content/recipient-segment.ts +39 -0
  80. package/src/components/content/reply-form.tsx +9 -1
  81. package/src/components/content/thanks-button.tsx +12 -7
  82. package/src/components/messages/message-forms.tsx +53 -43
  83. package/src/components/messages/recipient-field.tsx +88 -0
  84. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  85. package/src/components/moderation/modcp-shell.tsx +17 -3
  86. package/src/components/moderation/queue-form.tsx +3 -6
  87. package/src/components/moderation/report-forms.tsx +24 -5
  88. package/src/components/moderation/thread-tools-form.tsx +2 -0
  89. package/src/components/shell/board-notice.tsx +22 -1
  90. package/src/components/shell/confirm-dialog.tsx +93 -0
  91. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  92. package/src/components/shell/notice-toast.tsx +40 -0
  93. package/src/components/shell/notification-menu.tsx +5 -3
  94. package/src/components/shell/page-shell.tsx +3 -0
  95. package/src/components/shell/scheme-toggle.tsx +72 -0
  96. package/src/components/shell/theme-switcher.tsx +16 -36
  97. package/src/server/admin-settings-actions.ts +5 -0
  98. package/src/server/api/search.ts +8 -2
  99. package/src/server/api-token-actions.ts +4 -0
  100. package/src/server/appearance-actions.ts +21 -0
  101. package/src/server/attachments.ts +35 -0
  102. package/src/server/auth-actions.ts +2 -0
  103. package/src/server/auth-form-state.ts +28 -0
  104. package/src/server/auto-watch.ts +31 -0
  105. package/src/server/ban-filter-admin-actions.ts +5 -0
  106. package/src/server/confirm.ts +19 -0
  107. package/src/server/content-actions.ts +129 -19
  108. package/src/server/content-admin-actions.ts +31 -0
  109. package/src/server/discovery.ts +11 -1
  110. package/src/server/feed-routes.ts +13 -4
  111. package/src/server/feed-token-actions.ts +61 -0
  112. package/src/server/feed-token.ts +78 -0
  113. package/src/server/fixture-post-repo.ts +18 -0
  114. package/src/server/forum-admin-actions.ts +23 -16
  115. package/src/server/forum-admin.ts +10 -1
  116. package/src/server/inline-moderation-actions.ts +6 -0
  117. package/src/server/message-actions.ts +9 -0
  118. package/src/server/navigation-admin-actions.ts +6 -0
  119. package/src/server/plugin-admin.ts +7 -2
  120. package/src/server/plugin-pages.ts +50 -0
  121. package/src/server/plugin-panel.ts +34 -1
  122. package/src/server/plugin-routes.ts +8 -0
  123. package/src/server/plugin-view.tsx +46 -7
  124. package/src/server/poll-actions.ts +13 -1
  125. package/src/server/presence.ts +23 -0
  126. package/src/server/reply-core.ts +8 -1
  127. package/src/server/report-actions.ts +13 -2
  128. package/src/server/reputation-actions.ts +12 -1
  129. package/src/server/search-page.ts +8 -2
  130. package/src/server/search.ts +18 -5
  131. package/src/server/subscription-actions.ts +9 -0
  132. package/src/server/thread-core.ts +10 -2
  133. package/src/server/thread-tool-actions.ts +6 -0
  134. package/src/server/unread-goto.ts +28 -0
  135. package/src/server/upgrade-notice.ts +1 -1
  136. package/src/server/usercp-actions.ts +31 -1
  137. package/src/server/viewer-preferences.ts +5 -0
  138. package/src/server/webhook-actions.ts +98 -0
  139. package/src/server/webhooks-admin.ts +122 -0
  140. package/src/theme/contract.fixture.ts +36 -5
  141. package/src/view/account-copy.ts +39 -0
  142. package/src/view/admin-copy.ts +4 -9
  143. package/src/view/admin-nav.ts +6 -0
  144. package/src/view/admin-panel-copy.ts +23 -0
  145. package/src/view/attachments.ts +33 -1
  146. package/src/view/board-index.ts +31 -2
  147. package/src/view/board-title.ts +1 -0
  148. package/src/view/content-copy.ts +22 -12
  149. package/src/view/copy.ts +17 -0
  150. package/src/view/discovery-view.ts +2 -1
  151. package/src/view/drafts.ts +45 -0
  152. package/src/view/editor-toolbar.ts +14 -0
  153. package/src/view/forum-display.ts +39 -7
  154. package/src/view/modcp-nav.ts +32 -0
  155. package/src/view/moderation-copy.ts +20 -0
  156. package/src/view/notifications.ts +1 -0
  157. package/src/view/plugin-panel.ts +13 -0
  158. package/src/view/poll-vote.ts +46 -0
  159. package/src/view/post-link.ts +8 -1
  160. package/src/view/progressive-enhancement.ts +5 -0
  161. package/src/view/shell.ts +2 -1
  162. package/src/view/subscriptions.ts +1 -0
  163. package/src/view/thread-view.ts +41 -2
  164. package/src/view/usercp-nav.ts +6 -0
  165. package/src/view/usercp.ts +14 -0
@@ -3,7 +3,12 @@ import 'server-only'
3
3
 
4
4
  import type { Actor } from '@meith/authorization'
5
5
  import { contentScopeFrom, ForbiddenError } from '@meith/core'
6
- import { getDb, PostgresSearchRepository } from '@meith/db'
6
+ import {
7
+ DEFAULT_SEARCH_CONFIG,
8
+ getDb,
9
+ PostgresSearchRepository,
10
+ resolveSearchConfig,
11
+ } from '@meith/db'
7
12
  import type { SearchScope } from '@meith/search'
8
13
 
9
14
  import { getContainer } from './container'
@@ -21,12 +26,20 @@ export async function searchMinWordLength(): Promise<number> {
21
26
  return (await getSettings()).get('search.min_word_length')
22
27
  }
23
28
 
24
- export function searchProvider(): PostgresSearchRepository | null {
25
- return getContainer().dataSource === 'postgres' ? new PostgresSearchRepository(getDb()) : null
29
+ export async function searchLanguage(): Promise<string> {
30
+ return resolveSearchConfig((await getSettings()).get('search.language'))
26
31
  }
27
32
 
28
- export function requireSearch(): PostgresSearchRepository {
29
- const provider = searchProvider()
33
+ export function searchProvider(
34
+ config: string = DEFAULT_SEARCH_CONFIG,
35
+ ): PostgresSearchRepository | null {
36
+ return getContainer().dataSource === 'postgres'
37
+ ? new PostgresSearchRepository(getDb(), config)
38
+ : null
39
+ }
40
+
41
+ export function requireSearch(config: string = DEFAULT_SEARCH_CONFIG): PostgresSearchRepository {
42
+ const provider = searchProvider(config)
30
43
  if (provider === null) {
31
44
  throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-11'))
32
45
  }
@@ -12,6 +12,8 @@ import {
12
12
  type UnsubscribeScope,
13
13
  } from '@meith/subscriptions'
14
14
 
15
+ import { isEnhancedSubmit } from '@/view/progressive-enhancement'
16
+
15
17
  import type { FormState } from './auth-form-state'
16
18
  import { getContainer } from './container'
17
19
  import { getActor } from './context'
@@ -72,6 +74,8 @@ export async function subscribeAction(_prev: FormState, form: FormData): Promise
72
74
  { userId: actor.userId, target, targetId, subscribed: true },
73
75
  { requestId: currentRequestId() ?? null },
74
76
  )
77
+
78
+ if (isEnhancedSubmit(form)) return { subscribed: true }
75
79
  } catch (err) {
76
80
  return toFormState(err)
77
81
  }
@@ -106,6 +110,8 @@ export async function unsubscribeAction(_prev: FormState, form: FormData): Promi
106
110
  { userId: actor.userId, target, targetId, subscribed: false },
107
111
  { requestId: currentRequestId() ?? null },
108
112
  )
113
+
114
+ if (isEnhancedSubmit(form)) return { subscribed: false }
109
115
  } catch (err) {
110
116
  return toFormState(err)
111
117
  }
@@ -144,6 +150,8 @@ export async function unsubscribeByTokenAction(
144
150
  ['subscription.digest', false],
145
151
  ]),
146
152
  )
153
+ } else if (claim.scope === 'board-digest') {
154
+ await notifications.savePreferences(claim.userId, 'email', new Map([['board.digest', false]]))
147
155
  } else {
148
156
  await new SubscriptionService({ subscriptions }).unsubscribe({
149
157
  userId: claim.userId,
@@ -160,6 +168,7 @@ export async function unsubscribeByTokenAction(
160
168
 
161
169
  function doneFor(scope: UnsubscribeScope): string {
162
170
  if (scope === 'mass-mail') return 'announcements'
171
+ if (scope === 'board-digest') return 'boardDigest'
163
172
  return scope === 'email' ? 'email' : 'one'
164
173
  }
165
174
 
@@ -4,7 +4,12 @@ import type { Actor } from '@meith/authorization'
4
4
  import { ForbiddenError, type ForumPermissions, ValidationError } from '@meith/core'
5
5
  import { msg } from '@meith/i18n'
6
6
  import { restrictsPosting } from '@meith/moderation'
7
- import { type AuthorRestriction, type ForumPostingTarget, ThreadComposer } from '@meith/threads'
7
+ import {
8
+ type AuthorRestriction,
9
+ type ForumPostingTarget,
10
+ type SubscriptionCadence,
11
+ ThreadComposer,
12
+ } from '@meith/threads'
8
13
 
9
14
  import {
10
15
  dailyLimitMessage,
@@ -58,10 +63,12 @@ export interface SubmitThreadInput {
58
63
  readonly message: string
59
64
  readonly prefixId?: number | null
60
65
  readonly subscribe?: boolean
66
+ readonly subscribeMode?: SubscriptionCadence
61
67
  readonly poll?:
62
68
  | {
63
69
  readonly question: string
64
70
  readonly options: readonly string[]
71
+ readonly closesAt?: Date | null
65
72
  readonly maxOptions?: number
66
73
  readonly allowRevote?: boolean
67
74
  readonly publicVotes?: boolean
@@ -126,6 +133,7 @@ export async function submitThread(
126
133
  message: draft.body,
127
134
  prefixId: draft.prefixId,
128
135
  subscribe: input.subscribe ?? false,
136
+ ...(input.subscribeMode === undefined ? {} : { subscribeMode: input.subscribeMode }),
129
137
  heldAsNewMember: await holdsNewMember({
130
138
  actor,
131
139
  postCount: profile.postCount,
@@ -139,7 +147,7 @@ export async function submitThread(
139
147
  poll: {
140
148
  question: input.poll.question,
141
149
  options: input.poll.options,
142
- closesAt: null,
150
+ closesAt: input.poll.closesAt ?? null,
143
151
  ...(input.poll.maxOptions === undefined ? {} : { maxOptions: input.poll.maxOptions }),
144
152
  ...(input.poll.allowRevote === undefined
145
153
  ? {}
@@ -8,6 +8,7 @@ import { msg } from '@meith/i18n'
8
8
  import { parseThreadTool, type ThreadToolRights, ThreadTools } from '@meith/moderation'
9
9
 
10
10
  import type { FormState } from './auth-form-state'
11
+ import { requireConfirmation } from './confirm'
11
12
  import { getContainer } from './container'
12
13
  import { getActor } from './context'
13
14
  import { formStateReporter } from './form-state-reporter'
@@ -63,6 +64,11 @@ export async function threadToolAction(_prev: FormState, form: FormData): Promis
63
64
  ? await resolveRights(toForumId, threadAuthorId)
64
65
  : undefined
65
66
 
67
+ if (tool === 'delete' && rights.delete) {
68
+ const confirm = requireConfirmation(form, await tr('moderationForm.confirm.deleteThread'))
69
+ if (confirm !== null) return confirm
70
+ }
71
+
66
72
  outcome = await new ThreadTools({ threads: threadTools }).apply({
67
73
  threadId,
68
74
  tool,
@@ -0,0 +1,28 @@
1
+ import 'server-only'
2
+
3
+ import type { ContentScope } from '@meith/core'
4
+ import type { PostLocation, PostRepository } from '@meith/posts'
5
+ import type { ReadStateRepository } from '@meith/threads'
6
+
7
+ export interface UnreadGotoThread {
8
+ readonly id: number
9
+ readonly forumId: number
10
+ readonly lastPostId: number | null
11
+ }
12
+
13
+ export async function resolveUnreadGoto(
14
+ posts: PostRepository,
15
+ readState: ReadStateRepository,
16
+ userId: number,
17
+ thread: UnreadGotoThread,
18
+ options: { readonly scope: ContentScope; readonly pageSize: number },
19
+ ): Promise<PostLocation | null> {
20
+ const marker = await readState.markerFor(userId, thread.id, thread.forumId)
21
+ const located = await posts.locateFirstUnread(
22
+ thread.id,
23
+ { postId: marker.lastReadPostId ?? 0, since: marker.forumReadAt },
24
+ options,
25
+ )
26
+ if (located !== null) return located
27
+ return thread.lastPostId === null ? null : posts.locate(thread.id, thread.lastPostId, options)
28
+ }
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
16
16
 
17
17
  import { activeDefinitions } from './plugin-host'
18
18
 
19
- export const CODE_VERSION = '0.31.0'
19
+ export const CODE_VERSION = '0.32.0'
20
20
 
21
21
  export interface UpgradeApplied {
22
22
  readonly plugins: readonly string[]
@@ -18,6 +18,7 @@ import { AVATAR_FIELD, canUploadAvatar, requireAvatarService } from './avatars'
18
18
  import { configuredSessions, getContainer } from './container'
19
19
  import { getActor } from './context'
20
20
  import { assertDemoAccountChangeable } from './demo'
21
+ import { revokeFeedToken } from './feed-token'
21
22
  import { formStateReporter } from './form-state-reporter'
22
23
  import { text } from './form-values'
23
24
  import { getTranslator, tr } from './i18n'
@@ -103,11 +104,24 @@ export async function saveOptionsAction(_prev: FormState, form: FormData): Promi
103
104
  postsPerPage: text(form, 'postsPerPage'),
104
105
  threadsPerPage: text(form, 'threadsPerPage'),
105
106
  invisible: form.get('invisible') !== null,
107
+ autoWatchOwnThreads: text(form, 'autoWatchOwnThreads'),
108
+ autoWatchRepliedThreads: text(form, 'autoWatchRepliedThreads'),
106
109
  })
107
110
 
108
111
  await emitEvent(
109
112
  'user.profile.updated',
110
- { userId, fields: ['timezone', 'locale', 'postsPerPage', 'threadsPerPage', 'invisible'] },
113
+ {
114
+ userId,
115
+ fields: [
116
+ 'timezone',
117
+ 'locale',
118
+ 'postsPerPage',
119
+ 'threadsPerPage',
120
+ 'invisible',
121
+ 'autoWatchOwnThreads',
122
+ 'autoWatchRepliedThreads',
123
+ ],
124
+ },
111
125
  { requestId: currentRequestId() ?? null },
112
126
  )
113
127
  } catch (err) {
@@ -117,6 +131,20 @@ export async function saveOptionsAction(_prev: FormState, form: FormData): Promi
117
131
  redirect('/usercp/options?saved=1')
118
132
  }
119
133
 
134
+ export async function saveBoardDigestCadenceAction(
135
+ _prev: FormState,
136
+ form: FormData,
137
+ ): Promise<FormState> {
138
+ try {
139
+ const { service, userId } = await requireOwnSettings()
140
+ await service.saveBoardDigestCadence({ userId, cadence: text(form, 'cadence') })
141
+ } catch (err) {
142
+ return toFormState(err)
143
+ }
144
+
145
+ redirect('/notifications/preferences?saved=digest-cadence')
146
+ }
147
+
120
148
  export async function changePasswordAction(_prev: FormState, form: FormData): Promise<FormState> {
121
149
  try {
122
150
  const { service, userId } = await requireOwnSettings()
@@ -141,6 +169,8 @@ export async function changePasswordAction(_prev: FormState, form: FormData): Pr
141
169
  const admin = adminService()
142
170
  if (admin !== null) await admin.endAllFor(userId)
143
171
 
172
+ await revokeFeedToken(userId)
173
+
144
174
  await recordAuthEvent({ userId, kind: 'password_changed' })
145
175
  } catch (err) {
146
176
  return toFormState(err)
@@ -3,6 +3,8 @@ import 'server-only'
3
3
  import { cookies } from 'next/headers'
4
4
  import { cache } from 'react'
5
5
 
6
+ import type { SubscriptionMode } from '@meith/subscriptions'
7
+
6
8
  import { resolveTimezone, TIMEZONE_COOKIE } from '@/view/timezone'
7
9
 
8
10
  import { getContainer } from './container'
@@ -15,6 +17,7 @@ export interface ViewerPreferences {
15
17
  readonly locale: string | null
16
18
  readonly postsPerPage: number
17
19
  readonly threadsPerPage: number
20
+ readonly autoWatchRepliedThreads: SubscriptionMode
18
21
  }
19
22
 
20
23
  const detectedZone = cache(async (): Promise<string | null> => {
@@ -43,6 +46,7 @@ export const getViewerPreferences = cache(async (): Promise<ViewerPreferences> =
43
46
  locale: stored?.locale ?? null,
44
47
  postsPerPage: stored?.postsPerPage ?? defaults.postsPerPage,
45
48
  threadsPerPage: stored?.threadsPerPage ?? defaults.threadsPerPage,
49
+ autoWatchRepliedThreads: stored?.autoWatchRepliedThreads ?? 'none',
46
50
  }
47
51
  })
48
52
 
@@ -51,6 +55,7 @@ async function storedSettings(): Promise<{
51
55
  readonly locale: string
52
56
  readonly postsPerPage: number | null
53
57
  readonly threadsPerPage: number | null
58
+ readonly autoWatchRepliedThreads: SubscriptionMode
54
59
  } | null> {
55
60
  const actor = await getActor()
56
61
  const { memberSettings } = getContainer()
@@ -0,0 +1,98 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import { isAppError, logger, publicMessageOf } from '@meith/core'
6
+
7
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
8
+ import type { FormState } from './auth-form-state'
9
+ import { getMessageResolver, tr } from './i18n'
10
+ import { createWebhook, webhookStore } from './webhooks-admin'
11
+
12
+ function field(form: FormData, name: string): string {
13
+ const value = form.get(name)
14
+ return typeof value === 'string' ? value.trim() : ''
15
+ }
16
+
17
+ function refreshWebhookList(): void {
18
+ revalidatePath('/admin/webhooks')
19
+ }
20
+
21
+ async function toState(err: unknown): Promise<FormState> {
22
+ if (isAppError(err)) return { error: publicMessageOf(err, await getMessageResolver()) }
23
+ logger({ module: 'webhook-actions' }).error({ err }, 'webhook action failed')
24
+ return { error: await tr('notice.app.something-went-wrong-please-try') }
25
+ }
26
+
27
+ export async function createWebhookAction(_prev: FormState, form: FormData): Promise<FormState> {
28
+ try {
29
+ const admin = await requireFreshAdmin()
30
+
31
+ const secret = await createWebhook({
32
+ url: field(form, 'url'),
33
+ topics: form.getAll('topics').filter((value): value is string => typeof value === 'string'),
34
+ format: field(form, 'format'),
35
+ active: field(form, 'active') !== 'false',
36
+ createdBy: admin.userId,
37
+ })
38
+
39
+ await recordAdminAction({
40
+ action: 'system.webhook_created',
41
+ detail: { url: field(form, 'url') },
42
+ })
43
+
44
+ refreshWebhookList()
45
+
46
+ return { notice: 'created', values: { secret } }
47
+ } catch (err) {
48
+ return toState(err)
49
+ }
50
+ }
51
+
52
+ export async function deleteWebhookAction(_prev: FormState, form: FormData): Promise<FormState> {
53
+ try {
54
+ await requireAdmin()
55
+
56
+ const id = Number(field(form, 'webhookId'))
57
+ if (!Number.isSafeInteger(id) || id <= 0) return { error: await tr('notice.app.such-webhook') }
58
+
59
+ const store = webhookStore()
60
+ if (store === null) return { error: await tr('notice.app.board-database-webhooks') }
61
+
62
+ const removed = await store.remove(id)
63
+ if (removed) {
64
+ await recordAdminAction({ action: 'system.webhook_deleted', detail: { webhookId: id } })
65
+ }
66
+
67
+ refreshWebhookList()
68
+ return { notice: 'deleted' }
69
+ } catch (err) {
70
+ return toState(err)
71
+ }
72
+ }
73
+
74
+ export async function toggleWebhookAction(_prev: FormState, form: FormData): Promise<FormState> {
75
+ try {
76
+ await requireAdmin()
77
+
78
+ const id = Number(field(form, 'webhookId'))
79
+ if (!Number.isSafeInteger(id) || id <= 0) return { error: await tr('notice.app.such-webhook') }
80
+
81
+ const store = webhookStore()
82
+ if (store === null) return { error: await tr('notice.app.board-database-webhooks') }
83
+
84
+ const active = field(form, 'active') === 'true'
85
+ const changed = await store.setActive(id, active)
86
+ if (changed) {
87
+ await recordAdminAction({
88
+ action: 'system.webhook_updated',
89
+ detail: { webhookId: id, active },
90
+ })
91
+ }
92
+
93
+ refreshWebhookList()
94
+ return { notice: 'updated' }
95
+ } catch (err) {
96
+ return toState(err)
97
+ }
98
+ }
@@ -0,0 +1,122 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import {
5
+ generateWebhookSecret,
6
+ isWebhookFormat,
7
+ isWebhookTopic,
8
+ WEBHOOK_FORMATS,
9
+ WEBHOOK_TOPICS,
10
+ type WebhookFormat,
11
+ type WebhookTopic,
12
+ } from '@meith/api'
13
+ import { ValidationError } from '@meith/core'
14
+ import { getDb, PostgresWebhookRepository } from '@meith/db'
15
+
16
+ import { getContainer } from './container'
17
+
18
+ export function webhookStore(): PostgresWebhookRepository | null {
19
+ return getContainer().dataSource === 'postgres' ? new PostgresWebhookRepository(getDb()) : null
20
+ }
21
+
22
+ export interface WebhookDeliveryLogEntry {
23
+ readonly id: number
24
+ readonly deliveryId: string
25
+ readonly topic: string
26
+ readonly status: string
27
+ readonly attempts: number
28
+ readonly lastStatusCode: number | null
29
+ readonly lastError: string | null
30
+ readonly createdAt: Date
31
+ readonly completedAt: Date | null
32
+ }
33
+
34
+ export interface WebhookRow {
35
+ readonly id: number
36
+ readonly url: string
37
+ readonly topics: readonly WebhookTopic[]
38
+ readonly active: boolean
39
+ readonly format: WebhookFormat
40
+ readonly createdAt: Date
41
+ readonly delivered: number
42
+ readonly pending: number
43
+ readonly dead: number
44
+ readonly recent: readonly WebhookDeliveryLogEntry[]
45
+ }
46
+
47
+ export interface WebhookView {
48
+ readonly subscriptions: readonly WebhookRow[]
49
+ readonly topics: readonly WebhookTopic[]
50
+ readonly formats: readonly WebhookFormat[]
51
+ }
52
+
53
+ export async function buildWebhookView(): Promise<WebhookView | null> {
54
+ const store = webhookStore()
55
+ if (store === null) return null
56
+
57
+ const summaries = await store.listAll()
58
+ const subscriptions = await Promise.all(
59
+ summaries.map(async (summary) => ({
60
+ id: summary.id,
61
+ url: summary.url,
62
+ topics: summary.topics,
63
+ active: summary.active,
64
+ format: summary.format,
65
+ createdAt: summary.createdAt,
66
+ delivered: summary.delivered,
67
+ pending: summary.pending,
68
+ dead: summary.dead,
69
+ recent: await store.recentDeliveries(summary.id, 10),
70
+ })),
71
+ )
72
+
73
+ return { subscriptions, topics: WEBHOOK_TOPICS, formats: WEBHOOK_FORMATS }
74
+ }
75
+
76
+ export interface CreateWebhookInput {
77
+ readonly url: string
78
+ readonly topics: readonly string[]
79
+ readonly format: string
80
+ readonly active: boolean
81
+ readonly createdBy: number | null
82
+ }
83
+
84
+ function validUrl(raw: string): string {
85
+ const url = raw.trim()
86
+ let parsed: URL
87
+ try {
88
+ parsed = new URL(url)
89
+ } catch {
90
+ throw new ValidationError(msg('error.app.webhook-url-https'))
91
+ }
92
+ if (parsed.protocol !== 'https:') {
93
+ throw new ValidationError(msg('error.app.webhook-url-https'))
94
+ }
95
+ return url
96
+ }
97
+
98
+ export async function createWebhook(input: CreateWebhookInput): Promise<string> {
99
+ const store = webhookStore()
100
+ if (store === null) throw new ValidationError(msg('error.app.board-database-webhooks'))
101
+
102
+ const url = validUrl(input.url)
103
+
104
+ const topics = input.topics.filter((topic): topic is WebhookTopic => isWebhookTopic(topic))
105
+ if (topics.length === 0) {
106
+ throw new ValidationError(msg('error.app.webhook-needs-at-least-one-topic'))
107
+ }
108
+
109
+ const format: WebhookFormat = isWebhookFormat(input.format) ? input.format : 'json'
110
+
111
+ const secret = generateWebhookSecret()
112
+ await store.create({
113
+ url,
114
+ secret,
115
+ topics,
116
+ active: input.active,
117
+ format,
118
+ createdBy: input.createdBy,
119
+ })
120
+
121
+ return secret
122
+ }
@@ -147,10 +147,23 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
147
147
  boardTitle: 'The Bike Shed',
148
148
  homeHref: '/',
149
149
  viewer: VIEWER,
150
- navigation: [{ label: 'Unanswered', href: '/discover/unanswered' }],
150
+ navigation: [
151
+ { label: 'Unanswered', href: '/discover/unanswered' },
152
+ {
153
+ label: 'Community',
154
+ href: '/community',
155
+ submenu: [{ label: 'Rules', href: '/community/rules' }],
156
+ },
157
+ ],
151
158
  children: region('user-panel'),
152
159
  },
153
- requires: ['The Bike Shed', region('user-panel'), '/discover/unanswered'],
160
+ requires: [
161
+ 'The Bike Shed',
162
+ region('user-panel'),
163
+ '/discover/unanswered',
164
+ '/community/rules',
165
+ 'Rules',
166
+ ],
154
167
  },
155
168
 
156
169
  UserPanel: {
@@ -331,8 +344,18 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
331
344
  },
332
345
 
333
346
  ThreadRow: {
334
- model: { thread: { ...THREAD, visibility: 'deleted' }, select: null },
335
- requires: ['Bikeshedding the bike shed', '/thread/91-bikeshedding', '27', 'data-visibility'],
347
+ model: {
348
+ thread: { ...THREAD, visibility: 'deleted' },
349
+ select: null,
350
+ regions: { pluginBadges: pluginRegion('threadrow-badges') },
351
+ },
352
+ requires: [
353
+ 'Bikeshedding the bike shed',
354
+ '/thread/91-bikeshedding',
355
+ '27',
356
+ 'data-visibility',
357
+ pluginRegion('threadrow-badges'),
358
+ ],
336
359
  },
337
360
 
338
361
  SubforumList: {
@@ -351,13 +374,19 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
351
374
  forum: { label: 'General discussion', href: '/f/3-general' },
352
375
  replyHref: '/thread/91-bikeshedding/reply',
353
376
  markReadAction: '/thread/91-bikeshedding/mark-read',
377
+ watch: { subscribed: true, action: '/thread/91-bikeshedding/unwatch' },
354
378
  regions: {
355
379
  posts: region('posts'),
356
380
  pagination: region('pagination'),
357
381
  quickReply: region('quick-reply'),
358
382
  },
359
383
  },
360
- requires: ['Bikeshedding the bike shed', region('posts'), region('pagination')],
384
+ requires: [
385
+ 'Bikeshedding the bike shed',
386
+ '/thread/91-bikeshedding/unwatch',
387
+ region('posts'),
388
+ region('pagination'),
389
+ ],
361
390
  },
362
391
 
363
392
  PostBit: {
@@ -417,6 +446,7 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
417
446
  buttons: [
418
447
  {
419
448
  tag: 'bold',
449
+ insertion: null,
420
450
  label: 'Bold',
421
451
  title: 'Bold (Ctrl+B)',
422
452
  keyShortcut: 'Control+b',
@@ -425,6 +455,7 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
425
455
  },
426
456
  {
427
457
  tag: 'link',
458
+ insertion: null,
428
459
  label: 'Link',
429
460
  title: 'Link',
430
461
  keyShortcut: null,
@@ -48,6 +48,10 @@ export function optionsFormCopy(
48
48
  'accountForm.options.pageSizeHint',
49
49
  'accountForm.options.invisible',
50
50
  'accountForm.options.invisibleHint',
51
+ 'accountForm.options.autoWatchOwnThreads',
52
+ 'accountForm.options.autoWatchOwnThreadsHint',
53
+ 'accountForm.options.autoWatchRepliedThreads',
54
+ 'accountForm.options.autoWatchRepliedThreadsHint',
51
55
  'accountForm.options.submit',
52
56
  ],
53
57
  t,
@@ -286,6 +290,36 @@ export function activityDetailLabel(
286
290
  return t.t('accountForm.activity.detail', { at, device, address })
287
291
  }
288
292
 
293
+ export function feedTokenCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
294
+ return copyFor(
295
+ [
296
+ 'accountForm.feed.title',
297
+ 'accountForm.feed.blurb',
298
+ 'accountForm.feed.none',
299
+ 'accountForm.feed.active',
300
+ 'accountForm.feed.generate',
301
+ 'accountForm.feed.regenerate',
302
+ 'accountForm.feed.revoke',
303
+ 'accountForm.feed.copyNow',
304
+ 'accountForm.feed.revealHint',
305
+ 'accountForm.feed.rssLabel',
306
+ 'accountForm.feed.atomLabel',
307
+ 'accountForm.feed.warning',
308
+ 'form.working',
309
+ ],
310
+ t,
311
+ )
312
+ }
313
+
314
+ export function feedTokenDetailLabel(
315
+ created: string,
316
+ lastUsed: string | null,
317
+ t: Translator = untranslated(),
318
+ ): string {
319
+ const used = lastUsed === null ? t.t('accountForm.feed.neverUsed') : lastUsed
320
+ return t.t('accountForm.feed.detail', { created, used })
321
+ }
322
+
289
323
  export function notificationFormsCopy(
290
324
  t: Translator = untranslated(),
291
325
  ): Readonly<Record<string, string>> {
@@ -304,6 +338,11 @@ export function notificationFormsCopy(
304
338
  'accountForm.announcements.description',
305
339
  'accountForm.announcements.blurb',
306
340
  'accountForm.announcements.submit',
341
+ 'accountForm.boardDigest.legend',
342
+ 'accountForm.boardDigest.description',
343
+ 'accountForm.boardDigest.weekly',
344
+ 'accountForm.boardDigest.monthly',
345
+ 'accountForm.boardDigest.submit',
307
346
  'accountForm.push.heading',
308
347
  'accountForm.push.subscribe',
309
348
  'accountForm.push.unsubscribe',
@@ -61,8 +61,10 @@ export function forumAdminCopy(t: Translator = untranslated()): Readonly<Record<
61
61
  'adminForum.permGroup.moderation',
62
62
  'adminForum.permGroup.attachments',
63
63
  'adminForum.permGroup.approvals',
64
+ 'adminForum.permGroup.numeric',
64
65
  'adminForum.permGroup.other',
65
- 'adminForum.inheritsAll',
66
+ 'adminForum.permission',
67
+ 'adminForum.saveMatrix',
66
68
  'adminForum.unsavedChanges',
67
69
  'adminForum.copied',
68
70
  'adminForum.copyNote',
@@ -102,14 +104,7 @@ export function forumAdminCopy(t: Translator = untranslated()): Readonly<Record<
102
104
  t,
103
105
  ),
104
106
  ...patternCopy(
105
- [
106
- 'adminForum.perm.setHere',
107
- 'adminForum.perm.inheritedDefault',
108
- 'adminForum.perm.inheritedFrom',
109
- 'adminForum.saveGroup',
110
- 'adminForum.copyTo',
111
- 'adminForum.overridesSet',
112
- ],
107
+ ['adminForum.perm.inheritedDefault', 'adminForum.perm.inheritedFrom', 'adminForum.copyTo'],
113
108
  t,
114
109
  ),
115
110
  }
@@ -104,6 +104,12 @@ export const ADMIN_SECTIONS: PanelNav = [
104
104
  icon: 'tokens',
105
105
  blurbKey: 'adminNav.admin-api-tokens.blurb',
106
106
  },
107
+ {
108
+ href: '/admin/webhooks',
109
+ titleKey: 'adminNav.admin-webhooks.title',
110
+ icon: 'subscriptions',
111
+ blurbKey: 'adminNav.admin-webhooks.blurb',
112
+ },
107
113
  {
108
114
  href: '/admin/system',
109
115
  titleKey: 'adminNav.admin-system.title',