@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
@@ -1,3 +1,6 @@
1
+ import type { Translator } from '@meith/i18n'
2
+
3
+ import type { PluginNavSection } from './admin-nav'
1
4
  import {
2
5
  currentProps,
3
6
  deepestHrefIn,
@@ -6,6 +9,7 @@ import {
6
9
  type PanelSection,
7
10
  sectionHrefIn,
8
11
  } from './panel-nav'
12
+ import { untranslated } from './time'
9
13
 
10
14
  export { currentProps, isUnder }
11
15
 
@@ -67,6 +71,34 @@ export function modCpNav(access: ModCpNavAccess): PanelNav {
67
71
  return [MODCP_OVERVIEW, ...modCpSections(access)]
68
72
  }
69
73
 
74
+ export const MODCP_PLUGINS_HREF = '/modcp/plugins'
75
+
76
+ const MODCP_PLUGIN_KEY_PATTERN = /^\/modcp\/plugins\/([a-z][a-z0-9-]*)(?:\/|$|\?)/
77
+
78
+ export function pluginKeyAtModCp(pathname: string): string | null {
79
+ return MODCP_PLUGIN_KEY_PATTERN.exec(pathname)?.[1] ?? null
80
+ }
81
+
82
+ export function modCpNavWithPlugin(
83
+ access: ModCpNavAccess,
84
+ plugin: PluginNavSection | null,
85
+ t: Translator = untranslated(),
86
+ ): PanelNav {
87
+ const base = modCpNav(access)
88
+ if (plugin === null || plugin.pages.length === 0) return base
89
+
90
+ const href = `${MODCP_PLUGINS_HREF}/${plugin.key}`
91
+ const section: PanelSection = {
92
+ href,
93
+ titleText: plugin.name,
94
+ icon: 'plugins',
95
+ blurbText: t.t('modCpNav.plugin.blurb', { name: plugin.name }),
96
+ children: plugin.pages.filter((page) => page.href !== href),
97
+ }
98
+
99
+ return [...base, section]
100
+ }
101
+
70
102
  export function activeSectionHref(access: ModCpNavAccess, pathname: string): string | null {
71
103
  return sectionHrefIn(modCpNav(access), pathname)
72
104
  }
@@ -1,8 +1,23 @@
1
1
  import type { Translator } from '@meith/i18n'
2
+ import type { ReportCategory } from '@meith/moderation'
2
3
 
3
4
  import { copyFor, patternCopy, splitAround } from './copy'
4
5
  import { untranslated } from './time'
5
6
 
7
+ export const REPORT_CATEGORY_ORDER = [
8
+ 'spam',
9
+ 'off_topic',
10
+ 'abuse',
11
+ 'other',
12
+ ] as const satisfies readonly ReportCategory[]
13
+
14
+ export const REPORT_CATEGORY_LABEL_KEYS: Readonly<Record<ReportCategory, string>> = {
15
+ spam: 'moderationForm.report.category.spam',
16
+ off_topic: 'moderationForm.report.category.off_topic',
17
+ abuse: 'moderationForm.report.category.abuse',
18
+ other: 'moderationForm.report.category.other',
19
+ }
20
+
6
21
  export function moderationFormsCopy(
7
22
  t: Translator = untranslated(),
8
23
  ): Readonly<Record<string, string>> {
@@ -15,6 +30,11 @@ export function moderationFormsCopy(
15
30
  'moderationForm.report.label',
16
31
  'moderationForm.report.hint',
17
32
  'moderationForm.report.submit',
33
+ 'moderationForm.report.categoryLegend',
34
+ 'moderationForm.report.category.spam',
35
+ 'moderationForm.report.category.off_topic',
36
+ 'moderationForm.report.category.abuse',
37
+ 'moderationForm.report.category.other',
18
38
  'moderationForm.report.putBack',
19
39
  'moderationForm.report.take',
20
40
  'moderationForm.report.note',
@@ -78,6 +78,7 @@ export function notificationNotice(
78
78
  t: Translator = untranslated(),
79
79
  ): string | null {
80
80
  if (query.saved === 'announcements') return t.t('notice.announcementsSaved')
81
+ if (query.saved === 'digest-cadence') return t.t('notice.boardDigestCadenceSaved')
81
82
  if (query.saved !== undefined) return t.t('notice.preferencesSaved')
82
83
  if (query.read === 'all') return t.t('notice.allRead')
83
84
  if (query.read === 'one') return t.t('notice.markedRead')
@@ -37,6 +37,19 @@ export function pluginPanelTabs(input: {
37
37
  ]
38
38
  }
39
39
 
40
+ export function pluginStaffPanelTabs(input: {
41
+ readonly pages: readonly PluginPage[]
42
+ readonly current: string | null
43
+ }): readonly PluginPanelTab[] {
44
+ if (input.pages.length < 2) return []
45
+
46
+ return input.pages.map((page) => ({
47
+ href: page.href,
48
+ label: page.title,
49
+ isCurrent: page.path === input.current,
50
+ }))
51
+ }
52
+
40
53
  export function pluginNavChildren(pages: readonly PluginPage[]): readonly PanelSubsection[] {
41
54
  return pages.map((page) => ({ href: page.href, titleText: page.title }))
42
55
  }
@@ -0,0 +1,46 @@
1
+ import { type Poll, pollOptionShares } from '@meith/polls'
2
+
3
+ export interface PollOptionVoteView {
4
+ readonly id: number
5
+ readonly label: string
6
+ readonly votes: number
7
+ readonly share: number
8
+ readonly checked: boolean
9
+ readonly voterNames: readonly string[]
10
+ readonly moreVoters: number
11
+ }
12
+
13
+ export interface PollVoteView {
14
+ readonly options: readonly PollOptionVoteView[]
15
+ readonly total: number
16
+ readonly multiple: boolean
17
+ readonly closed: boolean
18
+ readonly hasVoted: boolean
19
+ readonly mayCast: boolean
20
+ readonly publicVotes: boolean
21
+ }
22
+
23
+ export function pollVoteView(poll: Poll, mayVote: boolean, now: Date): PollVoteView {
24
+ const total = poll.options.reduce((sum, option) => sum + option.votes, 0)
25
+ const shares = pollOptionShares(poll.options.map((option) => option.votes))
26
+ const closed = poll.closesAt !== null && poll.closesAt <= now
27
+ const hasVoted = poll.votedOptionIds.length > 0
28
+
29
+ return {
30
+ total,
31
+ multiple: poll.maxOptions !== 1,
32
+ closed,
33
+ hasVoted,
34
+ mayCast: mayVote && !closed && (!hasVoted || poll.allowRevote),
35
+ publicVotes: poll.publicVotes,
36
+ options: poll.options.map((option, index) => ({
37
+ id: option.id,
38
+ label: option.label,
39
+ votes: option.votes,
40
+ share: shares[index] ?? 0,
41
+ checked: poll.votedOptionIds.includes(option.id),
42
+ voterNames: poll.publicVotes ? option.voters.map((voter) => voter.username) : [],
43
+ moreVoters: poll.publicVotes ? Math.max(0, option.votes - option.voters.length) : 0,
44
+ })),
45
+ }
46
+ }
@@ -16,7 +16,14 @@ export function locatedHref(
16
16
  ): string {
17
17
  const params = new URLSearchParams()
18
18
  for (const [key, value] of Object.entries(query)) {
19
- if (value === undefined || key === 'post' || key === 'after' || key === 'page') continue
19
+ if (
20
+ value === undefined ||
21
+ key === 'post' ||
22
+ key === 'after' ||
23
+ key === 'page' ||
24
+ key === 'goto'
25
+ )
26
+ continue
20
27
  for (const one of typeof value === 'string' ? [value] : value) params.append(key, one)
21
28
  }
22
29
  if (location.afterId !== null) {
@@ -0,0 +1,5 @@
1
+ export const PROGRESSIVE_FIELD = 'clientEnhanced'
2
+
3
+ export function isEnhancedSubmit(form: FormData): boolean {
4
+ return form.get(PROGRESSIVE_FIELD) === '1'
5
+ }
package/src/view/shell.ts CHANGED
@@ -9,12 +9,13 @@ import type {
9
9
  ViewerModel,
10
10
  } from '@meith/theme-kit'
11
11
 
12
+ import { BOARD_TITLE } from './board-title'
12
13
  import { count } from './count'
13
14
  import { memberHref } from './member-profile'
14
15
  import { buildNavigation, defaultNavigationItems } from './navigation'
15
16
  import { timezoneLabel, untranslated } from './time'
16
17
 
17
- export const BOARD_TITLE = 'Meith'
18
+ export { BOARD_TITLE }
18
19
 
19
20
  export const TIMEZONE_LABEL = 'UTC'
20
21
 
@@ -80,6 +80,7 @@ export function unsubscribeNotice(
80
80
  ): string | null {
81
81
  if (done === 'announcements') return t.t('subscription.announcementsOff')
82
82
  if (done === 'email') return t.t('subscription.emailsOff')
83
+ if (done === 'boardDigest') return t.t('subscription.boardDigestOff')
83
84
  if (done === 'one') return t.t('subscription.doneOne')
84
85
  return null
85
86
  }
@@ -2,12 +2,14 @@ import type { ForumRow } from '@meith/forums'
2
2
  import type { Translator } from '@meith/i18n'
3
3
  import { type BoardVocabulary, type CompiledWordFilter, postBodyHtml } from '@meith/markdown'
4
4
  import { editedNote, type PostListingRow, type PostPage } from '@meith/posts'
5
- import { suppress } from '@meith/relations'
5
+ import { isOnline, suppress } from '@meith/relations'
6
+ import type { SubscriptionMode } from '@meith/subscriptions'
6
7
  import type {
7
8
  PaginationModel,
8
9
  PostAttachmentModel,
9
10
  PostBitModel,
10
11
  ThreadViewModel,
12
+ ThreadWatchModel,
11
13
  } from '@meith/theme-kit'
12
14
  import type { ThreadListingRow } from '@meith/threads'
13
15
 
@@ -58,6 +60,11 @@ function withinEditWindow(
58
60
  return elapsed <= capabilities.editWindowMinutes
59
61
  }
60
62
 
63
+ export interface AuthorPresence {
64
+ readonly lastActiveAt: Date | null
65
+ readonly invisible: boolean
66
+ }
67
+
61
68
  interface PostContext {
62
69
  readonly now: Date
63
70
  readonly replyHref: string | null
@@ -67,6 +74,8 @@ interface PostContext {
67
74
  readonly signatures: ReadonlyMap<number, string>
68
75
  readonly avatars: ReadonlyMap<number, string>
69
76
  readonly identities: ReadonlyMap<number, MemberIdentity>
77
+ readonly presence: ReadonlyMap<number, AuthorPresence>
78
+ readonly viewerSeesInvisible: boolean
70
79
  readonly ignoredIds: ReadonlySet<number>
71
80
  readonly revealedPostIds: ReadonlySet<number>
72
81
  readonly revealHref: (postId: number, number: number) => string
@@ -92,6 +101,12 @@ function post(post: PostListingRow, thread: ThreadListingRow, context: PostConte
92
101
  const identity =
93
102
  post.authorUserId === null ? undefined : context.identities.get(post.authorUserId)
94
103
 
104
+ const presence = post.authorUserId === null ? undefined : context.presence.get(post.authorUserId)
105
+ const authorIsOnline =
106
+ presence !== undefined &&
107
+ (!presence.invisible || context.viewerSeesInvisible) &&
108
+ isOnline(presence.lastActiveAt, now)
109
+
95
110
  const mayEdit =
96
111
  post.visibility !== 'deleted' &&
97
112
  (isOwn
@@ -123,7 +138,7 @@ function post(post: PostListingRow, thread: ThreadListingRow, context: PostConte
123
138
  joinedAt: post.authorJoinedAt === null ? null : formatDate(post.authorJoinedAt, t),
124
139
  signatureHtml:
125
140
  hidden || post.authorUserId === null ? null : (signatures.get(post.authorUserId) ?? null),
126
- isOnline: false,
141
+ isOnline: authorIsOnline,
127
142
  fields: hidden || post.authorUserId === null ? [] : (fields.get(post.authorUserId) ?? []),
128
143
  },
129
144
  bodyHtml: hidden
@@ -191,6 +206,8 @@ export interface ThreadViewInput {
191
206
  readonly pagination?: PaginationModel
192
207
  readonly nextHref: string | null
193
208
  readonly markReadAction?: string | null
209
+ readonly watchOffered?: boolean
210
+ readonly watchMode?: SubscriptionMode | null
194
211
  readonly replyHref?: string | null
195
212
  readonly capabilities?: PostCapabilities
196
213
  readonly now: Date
@@ -199,6 +216,8 @@ export interface ThreadViewInput {
199
216
  readonly signatures?: ReadonlyMap<number, string>
200
217
  readonly avatars?: ReadonlyMap<number, string>
201
218
  readonly identities?: ReadonlyMap<number, MemberIdentity>
219
+ readonly presence?: ReadonlyMap<number, AuthorPresence>
220
+ readonly viewerSeesInvisible?: boolean
202
221
  readonly attachments?: ReadonlyMap<number, readonly PostAttachmentModel[]>
203
222
  readonly ignoredIds?: ReadonlySet<number>
204
223
  readonly revealedPostIds?: ReadonlySet<number>
@@ -212,6 +231,7 @@ const EMPTY_SIGNATURES: ReadonlyMap<number, string> = new Map()
212
231
  const EMPTY_ATTACHMENTS: ReadonlyMap<number, readonly PostAttachmentModel[]> = new Map()
213
232
  const EMPTY_AVATARS: ReadonlyMap<number, string> = new Map()
214
233
  const EMPTY_IDENTITIES: ReadonlyMap<number, MemberIdentity> = new Map()
234
+ const EMPTY_PRESENCE: ReadonlyMap<number, AuthorPresence> = new Map()
215
235
 
216
236
  export function revealHref(currentHref: string, postId: number, number: number): string {
217
237
  const [path = '', hash] = currentHref.split('#')
@@ -227,6 +247,18 @@ export function threadToolsHeading(
227
247
  return t.t(isForumModerator ? 'thread.moderatorTools' : 'thread.threadTools')
228
248
  }
229
249
 
250
+ export function threadWatch(input: {
251
+ readonly offered: boolean
252
+ readonly mode: SubscriptionMode | null
253
+ readonly threadId: number
254
+ }): ThreadWatchModel | null {
255
+ if (!input.offered) return null
256
+
257
+ return input.mode === null
258
+ ? { subscribed: false, action: `/api/subscribe/thread/${input.threadId}` }
259
+ : { subscribed: true, action: `/api/unsubscribe/thread/${input.threadId}` }
260
+ }
261
+
230
262
  export function revealedFrom(raw: string | readonly string[] | undefined): ReadonlySet<number> {
231
263
  if (raw === undefined) return EMPTY_IDS
232
264
  const values = typeof raw === 'string' ? [raw] : raw
@@ -253,6 +285,11 @@ export function buildThreadView(input: ThreadViewInput): ThreadView {
253
285
  forum: { label: input.forum.title, href: forumHref(input.forum) },
254
286
  replyHref: input.replyHref ?? null,
255
287
  markReadAction: input.markReadAction ?? null,
288
+ watch: threadWatch({
289
+ offered: input.watchOffered ?? false,
290
+ mode: input.watchMode ?? null,
291
+ threadId: input.thread.id,
292
+ }),
256
293
  },
257
294
  posts: input.page.rows.map((entry) =>
258
295
  post(entry, input.thread, {
@@ -265,6 +302,8 @@ export function buildThreadView(input: ThreadViewInput): ThreadView {
265
302
  attachments: input.attachments ?? EMPTY_ATTACHMENTS,
266
303
  avatars: input.avatars ?? EMPTY_AVATARS,
267
304
  identities: input.identities ?? EMPTY_IDENTITIES,
305
+ presence: input.presence ?? EMPTY_PRESENCE,
306
+ viewerSeesInvisible: input.viewerSeesInvisible ?? false,
268
307
  ignoredIds: input.ignoredIds ?? EMPTY_IDS,
269
308
  revealedPostIds: input.revealedPostIds ?? EMPTY_IDS,
270
309
  revealHref: (postId, number) => revealHref(input.currentHref ?? '', postId, number),
@@ -61,6 +61,12 @@ export const USERCP_SECTIONS: PanelNav = [
61
61
  icon: 'signature',
62
62
  blurbKey: 'userCpNav.usercp-signature.blurb',
63
63
  },
64
+ {
65
+ href: '/usercp/drafts',
66
+ titleKey: 'userCpNav.usercp-drafts.title',
67
+ icon: 'content',
68
+ blurbKey: 'userCpNav.usercp-drafts.blurb',
69
+ },
64
70
  {
65
71
  href: '/usercp/contacts',
66
72
  titleKey: 'userCpNav.usercp-contacts.title',
@@ -6,6 +6,7 @@ import {
6
6
  } from '@meith/accounts'
7
7
  import { SOURCE_LOCALE, type Translator } from '@meith/i18n'
8
8
  import { maxLengthFor, type ResolvedProfileField } from '@meith/profile-fields'
9
+ import { MODE_LABELS, SUBSCRIPTION_MODES, type SubscriptionMode } from '@meith/subscriptions'
9
10
 
10
11
  import { untranslated } from './time'
11
12
 
@@ -129,6 +130,8 @@ export function optionsFormValues(settings: MemberSettings): {
129
130
  postsPerPage: string
130
131
  threadsPerPage: string
131
132
  invisible: boolean
133
+ autoWatchOwnThreads: SubscriptionMode
134
+ autoWatchRepliedThreads: SubscriptionMode
132
135
  } {
133
136
  return {
134
137
  timezone: settings.timezone,
@@ -136,9 +139,20 @@ export function optionsFormValues(settings: MemberSettings): {
136
139
  postsPerPage: settings.postsPerPage === null ? '' : String(settings.postsPerPage),
137
140
  threadsPerPage: settings.threadsPerPage === null ? '' : String(settings.threadsPerPage),
138
141
  invisible: settings.invisible,
142
+ autoWatchOwnThreads: settings.autoWatchOwnThreads,
143
+ autoWatchRepliedThreads: settings.autoWatchRepliedThreads,
139
144
  }
140
145
  }
141
146
 
147
+ export interface AutoWatchChoice {
148
+ readonly value: SubscriptionMode
149
+ readonly label: string
150
+ }
151
+
152
+ export function autoWatchChoices(): readonly AutoWatchChoice[] {
153
+ return SUBSCRIPTION_MODES.map((mode) => ({ value: mode, label: MODE_LABELS[mode] }))
154
+ }
155
+
142
156
  export function customFieldInputs(resolved: readonly ResolvedProfileField[]): readonly {
143
157
  key: string
144
158
  label: string