@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
@@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'
5
5
  import { ForbiddenError, ValidationError } from '@meith/core'
6
6
  import { currentRequestId } from '@meith/core/logger'
7
7
  import { msg } from '@meith/i18n'
8
- import { parseTargetKind, ReportService } from '@meith/moderation'
8
+ import { parseReportCategory, parseTargetKind, ReportService } from '@meith/moderation'
9
9
 
10
10
  import { limitMessage, spendLimit } from './antispam'
11
11
  import type { FormState } from './auth-form-state'
@@ -17,6 +17,7 @@ import { tr } from './i18n'
17
17
  import { reportNotifier } from './notifications'
18
18
  import { emitEvent } from './plugin-view'
19
19
  import { resolveReportScope } from './report-scope'
20
+ import { getSettings } from './settings'
20
21
 
21
22
  function field(form: FormData, name: string): string {
22
23
  const value = form.get(name)
@@ -29,12 +30,17 @@ export async function fileReportAction(_prev: FormState, form: FormData): Promis
29
30
  const kind = parseTargetKind(field(form, 'kind'))
30
31
  const targetId = positiveIntIn(field(form, 'targetId'))
31
32
  const reason = field(form, 'reason')
32
- const values = { reason }
33
+ const category = parseReportCategory(field(form, 'category'))
34
+ const values = { reason, ...(category === null ? {} : { category }) }
33
35
 
34
36
  if (kind === null || targetId === null) {
35
37
  return { error: await tr('notice.app.exist'), values }
36
38
  }
37
39
 
40
+ if (category === null) {
41
+ return { error: await tr('notice.app.choose-report-category'), values }
42
+ }
43
+
38
44
  const { authorizer, reports } = getContainer()
39
45
  if (reports === null) {
40
46
  return {
@@ -72,11 +78,16 @@ export async function fileReportAction(_prev: FormState, form: FormData): Promis
72
78
  return { error: limitMessage(limited), values }
73
79
  }
74
80
 
81
+ const settings = await getSettings()
82
+ const flagThreshold = Number(settings.get('moderation.flag_threshold') ?? 0)
83
+
75
84
  const outcome = await new ReportService({ reports }).file({
76
85
  kind,
77
86
  targetId,
87
+ category,
78
88
  reason,
79
89
  reporterUserId: actor.userId,
90
+ flagThreshold,
80
91
  })
81
92
 
82
93
  if (!outcome.duplicate) {
@@ -7,13 +7,19 @@ import { msg } from '@meith/i18n'
7
7
  import { parseRating } from '@meith/reputation'
8
8
 
9
9
  import { postLink } from '@/view/post-link'
10
+ import { isEnhancedSubmit } from '@/view/progressive-enhancement'
10
11
 
11
12
  import type { FormState } from './auth-form-state'
12
13
  import { getActor } from './context'
13
14
  import { formStateReporter } from './form-state-reporter'
14
15
  import { trimmedText } from './form-values'
15
16
  import { emitEvent, viewerRef } from './plugin-view'
16
- import { reputationService, reputationSettings, viewerRaterLimits } from './reputation'
17
+ import {
18
+ reputationService,
19
+ reputationSettings,
20
+ thanksForPosts,
21
+ viewerRaterLimits,
22
+ } from './reputation'
17
23
  import { requireRateablePost } from './reputation-target'
18
24
  import { isSafeLocalPath } from './safe-path'
19
25
 
@@ -138,6 +144,11 @@ export async function thankForPostAction(_prev: FormState, form: FormData): Prom
138
144
  })
139
145
  await announceReputation(service, userId, 1)
140
146
  }
147
+
148
+ if (isEnhancedSubmit(form)) {
149
+ const thanks = (await thanksForPosts([postId])).get(postId)
150
+ return { thanks: thanks ?? { thanked: false, count: 0 } }
151
+ }
141
152
  } catch (err) {
142
153
  return toFormState(err)
143
154
  }
@@ -30,7 +30,13 @@ import { REFINE_FIELDS } from '@/view/search-controls'
30
30
  import { limitMessage, spendLimit } from './antispam'
31
31
  import { getContainer } from './container'
32
32
  import { filterView, viewerRef } from './plugin-view'
33
- import { requireSearch, requireSearchEnabled, searchMinWordLength, searchScopeFor } from './search'
33
+ import {
34
+ requireSearch,
35
+ requireSearchEnabled,
36
+ searchLanguage,
37
+ searchMinWordLength,
38
+ searchScopeFor,
39
+ } from './search'
34
40
  import { getSettings } from './settings'
35
41
 
36
42
  export const SEARCH_PAGE = 20
@@ -149,7 +155,7 @@ export async function openSearch(input: {
149
155
  const refine = trimRefinement(input.refine, filters)
150
156
  const effective = narrowFilters(filters, refine)
151
157
  const scope = await searchScopeFor(input.actor)
152
- const provider = requireSearch()
158
+ const provider = requireSearch(await searchLanguage())
153
159
 
154
160
  const [results, summary] = await Promise.all([
155
161
  provider.search(
@@ -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
  }
@@ -6,8 +6,11 @@ import { env } from '@meith/core'
6
6
 
7
7
  import {
8
8
  ADMIN_COOKIE,
9
+ ADMIN_SECOND_FACTOR_COOKIE,
9
10
  adminCookie,
11
+ adminSecondFactorCookie,
10
12
  clearedAdminCookie,
13
+ clearedAdminSecondFactorCookie,
11
14
  clearedCookie,
12
15
  passkeyCookie,
13
16
  passkeyCookieName,
@@ -55,6 +58,21 @@ export async function readAdminToken(): Promise<string | null> {
55
58
  return jar.get(ADMIN_COOKIE)?.value ?? null
56
59
  }
57
60
 
61
+ export async function setAdminSecondFactorCookie(token: string, expiresAt: Date): Promise<void> {
62
+ const jar = await cookies()
63
+ jar.set(ADMIN_SECOND_FACTOR_COOKIE, token, adminSecondFactorCookie(expiresAt, secure()))
64
+ }
65
+
66
+ export async function readAdminSecondFactorToken(): Promise<string | null> {
67
+ const jar = await cookies()
68
+ return jar.get(ADMIN_SECOND_FACTOR_COOKIE)?.value ?? null
69
+ }
70
+
71
+ export async function clearAdminSecondFactorCookie(): Promise<void> {
72
+ const jar = await cookies()
73
+ jar.set(ADMIN_SECOND_FACTOR_COOKIE, '', clearedAdminSecondFactorCookie(secure()))
74
+ }
75
+
58
76
  export async function clearSessionCookies(): Promise<void> {
59
77
  const jar = await cookies()
60
78
  const isSecure = secure()
@@ -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.33.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
+ }
@@ -666,7 +666,7 @@
666
666
  /* `em`, so a quote inside a heading scales with the text it is in. */
667
667
  margin-block: 0.85em;
668
668
  padding: 0.55em 0.85em;
669
- border-inline-start: 3px solid var(--border);
669
+ border-inline-start: 3px solid color-mix(in srgb, var(--primary) 55%, transparent);
670
670
  background-color: var(--muted);
671
671
  border-start-end-radius: var(--radius);
672
672
  border-end-end-radius: var(--radius);