@meith/web 0.30.1 → 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.
- package/app/(board)/[slug]/feed.xml/route.ts +5 -3
- package/app/(board)/[slug]/new/page.tsx +5 -0
- package/app/(board)/[slug]/page.tsx +7 -1
- package/app/(board)/discover/[view]/page.tsx +6 -1
- package/app/(board)/member/[id]/page.tsx +3 -5
- package/app/(board)/member/[id]/reputation/page.tsx +8 -15
- package/app/(board)/members/page.tsx +3 -8
- package/app/(board)/messages/[id]/page.tsx +3 -7
- package/app/(board)/messages/page.tsx +3 -5
- package/app/(board)/modcp/ip/page.tsx +3 -5
- package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
- package/app/(board)/moderation/reports/page.tsx +53 -15
- package/app/(board)/moderation/warn/page.tsx +3 -6
- package/app/(board)/notifications/page.tsx +5 -10
- package/app/(board)/notifications/preferences/page.tsx +8 -0
- package/app/(board)/online/page.tsx +12 -6
- package/app/(board)/staff/page.tsx +4 -5
- package/app/(board)/stats/page.tsx +10 -3
- package/app/(board)/subscriptions/page.tsx +5 -10
- package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
- package/app/(board)/thread/[slug]/page.tsx +49 -11
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
- package/app/(board)/unsubscribe/page.tsx +8 -11
- package/app/(board)/usercp/contacts/page.tsx +3 -5
- package/app/(board)/usercp/drafts/page.tsx +70 -0
- package/app/(board)/usercp/options/page.tsx +8 -1
- package/app/(board)/usercp/security/page.tsx +16 -0
- package/app/admin/antispam/page.tsx +4 -12
- package/app/admin/content/attachments/page.tsx +3 -6
- package/app/admin/content/page.tsx +3 -13
- package/app/admin/forums/[id]/permissions/page.tsx +9 -12
- package/app/admin/groups/page.tsx +7 -9
- package/app/admin/page.tsx +12 -6
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
- package/app/admin/plugins/page.tsx +3 -6
- package/app/admin/users/[id]/page.tsx +5 -11
- package/app/admin/users/prune/page.tsx +4 -12
- package/app/admin/webhooks/page.tsx +144 -0
- package/app/api/subscribe/thread/[id]/route.ts +53 -0
- package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
- package/app/atom.xml/route.ts +2 -2
- package/app/auth/sso/[provider]/callback/route.ts +10 -0
- package/app/feed.xml/route.ts +2 -2
- package/app/offline/route.ts +98 -0
- package/next.config.mjs +1 -0
- package/package.json +49 -48
- package/public/sw.js +42 -2
- package/src/components/account/draft-forms.tsx +39 -0
- package/src/components/account/feed-token.tsx +95 -0
- package/src/components/account/notification-forms.tsx +37 -2
- package/src/components/account/relation-forms.tsx +3 -2
- package/src/components/account/subscription-forms.tsx +20 -6
- package/src/components/account/usercp-forms.tsx +44 -0
- package/src/components/admin/api-token-forms.tsx +11 -7
- package/src/components/admin/ban-filter-forms.tsx +15 -11
- package/src/components/admin/content-forms.tsx +31 -14
- package/src/components/admin/forum-forms.tsx +241 -129
- package/src/components/admin/forum-tree.tsx +5 -7
- package/src/components/admin/navigation-forms.tsx +43 -26
- package/src/components/admin/navigation-tree.tsx +8 -13
- package/src/components/admin/oklch-picker.tsx +2 -1
- package/src/components/admin/theme-changes.tsx +3 -2
- package/src/components/admin/theme-forms.tsx +3 -2
- package/src/components/admin/webhook-forms.tsx +137 -0
- package/src/components/auth/register-form.tsx +4 -7
- package/src/components/board/live-region.tsx +3 -1
- package/src/components/content/attachment-field.tsx +93 -22
- package/src/components/content/combobox-keys.ts +38 -0
- package/src/components/content/edit-post-form.tsx +75 -1
- package/src/components/content/markdown-editor.tsx +29 -86
- package/src/components/content/mention-combobox.tsx +137 -0
- package/src/components/content/multiquote-button.tsx +15 -12
- package/src/components/content/new-thread-form.tsx +95 -17
- package/src/components/content/poll-edit-form.tsx +61 -39
- package/src/components/content/poll-vote-form.tsx +91 -0
- package/src/components/content/poll.tsx +12 -48
- package/src/components/content/progressive-marker.tsx +13 -0
- package/src/components/content/recipient-segment.ts +39 -0
- package/src/components/content/reply-form.tsx +9 -1
- package/src/components/content/thanks-button.tsx +12 -7
- package/src/components/messages/message-forms.tsx +53 -43
- package/src/components/messages/recipient-field.tsx +88 -0
- package/src/components/moderation/inline-moderation-form.tsx +2 -0
- package/src/components/moderation/modcp-shell.tsx +17 -3
- package/src/components/moderation/queue-form.tsx +3 -6
- package/src/components/moderation/report-forms.tsx +24 -5
- package/src/components/moderation/thread-tools-form.tsx +2 -0
- package/src/components/shell/board-notice.tsx +22 -1
- package/src/components/shell/confirm-dialog.tsx +93 -0
- package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
- package/src/components/shell/notice-toast.tsx +40 -0
- package/src/components/shell/notification-menu.tsx +5 -3
- package/src/components/shell/page-shell.tsx +3 -0
- package/src/components/shell/scheme-toggle.tsx +72 -0
- package/src/components/shell/theme-switcher.tsx +16 -36
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/api/search.ts +8 -2
- package/src/server/api-token-actions.ts +4 -0
- package/src/server/appearance-actions.ts +21 -0
- package/src/server/attachments.ts +35 -0
- package/src/server/auth-actions.ts +2 -0
- package/src/server/auth-form-state.ts +28 -0
- package/src/server/auto-watch.ts +31 -0
- package/src/server/ban-filter-admin-actions.ts +5 -0
- package/src/server/confirm.ts +19 -0
- package/src/server/content-actions.ts +129 -19
- package/src/server/content-admin-actions.ts +31 -0
- package/src/server/discovery.ts +11 -1
- package/src/server/feed-routes.ts +13 -4
- package/src/server/feed-token-actions.ts +61 -0
- package/src/server/feed-token.ts +78 -0
- package/src/server/fixture-post-repo.ts +18 -0
- package/src/server/forum-admin-actions.ts +23 -16
- package/src/server/forum-admin.ts +10 -1
- package/src/server/inline-moderation-actions.ts +6 -0
- package/src/server/message-actions.ts +9 -0
- package/src/server/navigation-admin-actions.ts +6 -0
- package/src/server/plugin-admin.ts +7 -2
- package/src/server/plugin-pages.ts +50 -0
- package/src/server/plugin-panel.ts +34 -1
- package/src/server/plugin-routes.ts +8 -0
- package/src/server/plugin-view.tsx +46 -7
- package/src/server/poll-actions.ts +13 -1
- package/src/server/presence.ts +23 -0
- package/src/server/reply-core.ts +8 -1
- package/src/server/report-actions.ts +13 -2
- package/src/server/reputation-actions.ts +12 -1
- package/src/server/search-page.ts +8 -2
- package/src/server/search.ts +18 -5
- package/src/server/subscription-actions.ts +9 -0
- package/src/server/thread-core.ts +10 -2
- package/src/server/thread-tool-actions.ts +6 -0
- package/src/server/unread-goto.ts +28 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/usercp-actions.ts +31 -1
- package/src/server/viewer-preferences.ts +5 -0
- package/src/server/webhook-actions.ts +98 -0
- package/src/server/webhooks-admin.ts +122 -0
- package/src/theme/contract.fixture.ts +36 -5
- package/src/view/account-copy.ts +39 -0
- package/src/view/admin-copy.ts +4 -9
- package/src/view/admin-nav.ts +6 -0
- package/src/view/admin-panel-copy.ts +23 -0
- package/src/view/attachments.ts +33 -1
- package/src/view/board-index.ts +31 -2
- package/src/view/board-title.ts +1 -0
- package/src/view/content-copy.ts +22 -12
- package/src/view/copy.ts +17 -0
- package/src/view/discovery-view.ts +2 -1
- package/src/view/drafts.ts +45 -0
- package/src/view/editor-toolbar.ts +14 -0
- package/src/view/forum-display.ts +39 -7
- package/src/view/modcp-nav.ts +32 -0
- package/src/view/moderation-copy.ts +20 -0
- package/src/view/notifications.ts +1 -0
- package/src/view/plugin-panel.ts +13 -0
- package/src/view/poll-vote.ts +46 -0
- package/src/view/post-link.ts +8 -1
- package/src/view/progressive-enhancement.ts +5 -0
- package/src/view/shell.ts +2 -1
- package/src/view/subscriptions.ts +1 -0
- package/src/view/thread-view.ts +41 -2
- package/src/view/usercp-nav.ts +6 -0
- package/src/view/usercp.ts +14 -0
package/src/server/search.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
25
|
-
return
|
|
29
|
+
export async function searchLanguage(): Promise<string> {
|
|
30
|
+
return resolveSearchConfig((await getSettings()).get('search.language'))
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
export function
|
|
29
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
{
|
|
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: [
|
|
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: [
|
|
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: {
|
|
335
|
-
|
|
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: [
|
|
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,
|
package/src/view/account-copy.ts
CHANGED
|
@@ -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',
|
package/src/view/admin-copy.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/view/admin-nav.ts
CHANGED
|
@@ -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',
|