@blogmcp/community 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +146 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-DzjwnHyx.js +4597 -0
- package/dist/core/attrs.d.ts +73 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/discussion.d.ts +144 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +17 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +52 -0
- package/dist/core/outcomes.d.ts +28 -0
- package/dist/core/panel.d.ts +80 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/text.d.ts +22 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +26 -0
- package/dist/global.d.ts +8 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +22 -0
- package/dist/mount.d.ts +65 -0
- package/dist/react.d.ts +17 -0
- package/dist/react.js +35 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +33 -0
- package/dist/ui/LinkedText.vue.d.ts +5 -0
- package/dist/ui/MessageComposer.vue.d.ts +24 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +89 -0
- package/dist/vue.js +97 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +147 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/client.ts +54 -0
- package/src/core/discussion.ts +691 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +90 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +345 -0
- package/src/core/outcomes.ts +74 -0
- package/src/core/panel.ts +411 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/text.ts +142 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +109 -0
- package/src/global.ts +12 -0
- package/src/index.ts +35 -0
- package/src/mount.ts +127 -0
- package/src/react.ts +53 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +294 -0
- package/src/ui/LinkedText.vue +18 -0
- package/src/ui/MessageComposer.vue +130 -0
- package/src/ui/MessageItem.vue +205 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +369 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/OnboardingSteps.vue +163 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +243 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +116 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The light onboarding inside the discussion (m1-contracts.md §7.4.2, §10.4; community.md §4.4–§4.6): what a
|
|
3
|
+
* reader does once before their first comment, and again when the tenant's rules change or it starts to ask
|
|
4
|
+
* for the art. 9 consent. In order, only the steps still missing:
|
|
5
|
+
*
|
|
6
|
+
* age the age band, asked once and never offered again (`16_17`, `18_plus`); below the tenant's
|
|
7
|
+
* minimum kb-api stores nothing and the reader is told from what age the discussion is open;
|
|
8
|
+
* rules the rules' summary (`legal/current` → `bodyJson`, text only) with a link to the full text, and
|
|
9
|
+
* the acceptance;
|
|
10
|
+
* art9 the art. 9 consent's text and the acceptance, when the tenant asks for it;
|
|
11
|
+
* profile the display name, the handle (kb-api makes one when none is given; the element shows what it
|
|
12
|
+
* would be), an avatar preset.
|
|
13
|
+
*
|
|
14
|
+
* One `me/onboard` at the end carries them all; kb-api records only what is still missing, so a member who
|
|
15
|
+
* comes back for a newer rules text sends the band and the profile they already have untouched.
|
|
16
|
+
*
|
|
17
|
+
* kb-api records the acceptance of whatever CURRENT text id it is sent (§7.4.2), so a text id goes only for a text
|
|
18
|
+
* this model showed and the reader ticked: the rules text when the rules are a step, else none. A text published
|
|
19
|
+
* meanwhile, or the art. 9 consent switched on meanwhile, is kb-api's VALIDATION on that id: the steps are read
|
|
20
|
+
* again from me/get (a step may be new), the reader goes to the first one left, nothing goes until it is answered.
|
|
21
|
+
*/
|
|
22
|
+
import { reactive } from 'vue'
|
|
23
|
+
import {
|
|
24
|
+
communityRulesOf,
|
|
25
|
+
isKnownNeed,
|
|
26
|
+
type AgeBand,
|
|
27
|
+
type CommunityCallOptions,
|
|
28
|
+
type CommunityClient,
|
|
29
|
+
type CommunityFailure,
|
|
30
|
+
type CommunityRule,
|
|
31
|
+
type CurrentLegalText,
|
|
32
|
+
type MyState,
|
|
33
|
+
type OnboardInput,
|
|
34
|
+
} from '@blogmcp/sdk/community'
|
|
35
|
+
import type { Lang } from './i18n'
|
|
36
|
+
import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName, suggestedHandle } from './names'
|
|
37
|
+
import { htmlToParagraphs } from './text'
|
|
38
|
+
|
|
39
|
+
export type OnboardingStep = 'age' | 'rules' | 'art9' | 'profile'
|
|
40
|
+
|
|
41
|
+
export type OnboardingPhase = 'loading' | 'ready' | 'sending' | 'done' | 'failed' | 'refused'
|
|
42
|
+
|
|
43
|
+
export interface OnboardingState {
|
|
44
|
+
/** `refused`: the age band is below the minimum — nothing was stored, the reader may go on reading. */
|
|
45
|
+
phase: OnboardingPhase
|
|
46
|
+
steps: OnboardingStep[]
|
|
47
|
+
index: number
|
|
48
|
+
me: MyState | null
|
|
49
|
+
rulesText: CurrentLegalText | null
|
|
50
|
+
rules: CommunityRule[]
|
|
51
|
+
art9Text: CurrentLegalText | null
|
|
52
|
+
art9Paragraphs: string[]
|
|
53
|
+
/** The member accepted an older rules text; this one asks again. */
|
|
54
|
+
reconsent: boolean
|
|
55
|
+
ageBand: AgeBand | null
|
|
56
|
+
rulesAccepted: boolean
|
|
57
|
+
art9Accepted: boolean
|
|
58
|
+
displayName: string
|
|
59
|
+
handle: string
|
|
60
|
+
avatarPreset: string | null
|
|
61
|
+
failure: CommunityFailure | null
|
|
62
|
+
/** The fields a refusal named (`displayName`, `handle`, …). */
|
|
63
|
+
fields: string[]
|
|
64
|
+
/** The minimum age, once a refusal told it (a refused `16_17` means 18). */
|
|
65
|
+
minAge: number | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface OnboardingModel {
|
|
69
|
+
readonly state: OnboardingState
|
|
70
|
+
load(): Promise<void>
|
|
71
|
+
step(): OnboardingStep | null
|
|
72
|
+
isLast(): boolean
|
|
73
|
+
canContinue(): boolean
|
|
74
|
+
/** The next step, or — on the last one — `me/onboard`. */
|
|
75
|
+
next(): Promise<void>
|
|
76
|
+
back(): void
|
|
77
|
+
suggestedHandle(): string | null
|
|
78
|
+
hasFieldError(field: string): boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface OnboardingOptions {
|
|
82
|
+
client: Pick<CommunityClient, 'me' | 'legal'>
|
|
83
|
+
lang: Lang
|
|
84
|
+
/** Called with the member's state once kb-api took the onboarding. */
|
|
85
|
+
onDone(state: MyState): void
|
|
86
|
+
/** The session cannot act as a member any more (signed out meanwhile). */
|
|
87
|
+
onSignIn(): void
|
|
88
|
+
/** The age band was below the tenant's minimum (AGE_BELOW_MINIMUM): kb-api kept nothing, the element keeps this. */
|
|
89
|
+
onRefused?(minAge: number): void
|
|
90
|
+
signal?: AbortSignal
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const PROFILE_FIELDS = new Set(['displayName', 'handle', 'avatarPreset'])
|
|
94
|
+
const TEXT_FIELDS = new Set(['rulesTextId', 'art9TextId'])
|
|
95
|
+
|
|
96
|
+
/** What is still missing for this member, in order (MyState and its known needs). */
|
|
97
|
+
function stepsOf(me: MyState, needs: readonly string[]): OnboardingStep[] {
|
|
98
|
+
const steps: OnboardingStep[] = []
|
|
99
|
+
if (!me.member?.ageBand) steps.push('age')
|
|
100
|
+
if (me.acceptances.rules !== 'accepted') steps.push('rules')
|
|
101
|
+
if (needs.includes('art9_consent')) steps.push('art9')
|
|
102
|
+
if (!me.profile) steps.push('profile')
|
|
103
|
+
return steps
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The texts of the steps shown. */
|
|
107
|
+
interface Texts {
|
|
108
|
+
rules: CurrentLegalText | null
|
|
109
|
+
art9: CurrentLegalText | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One reading of what is left to ask: me/get, its steps, their texts. */
|
|
113
|
+
interface Read {
|
|
114
|
+
me: MyState
|
|
115
|
+
steps: OnboardingStep[]
|
|
116
|
+
texts: Texts
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
120
|
+
const call: CommunityCallOptions = o.signal ? { signal: o.signal } : {}
|
|
121
|
+
const state = reactive<OnboardingState>({
|
|
122
|
+
phase: 'loading',
|
|
123
|
+
steps: [],
|
|
124
|
+
index: 0,
|
|
125
|
+
me: null,
|
|
126
|
+
rulesText: null,
|
|
127
|
+
rules: [],
|
|
128
|
+
art9Text: null,
|
|
129
|
+
art9Paragraphs: [],
|
|
130
|
+
reconsent: false,
|
|
131
|
+
ageBand: null,
|
|
132
|
+
rulesAccepted: false,
|
|
133
|
+
art9Accepted: false,
|
|
134
|
+
displayName: '',
|
|
135
|
+
handle: '',
|
|
136
|
+
avatarPreset: null,
|
|
137
|
+
failure: null,
|
|
138
|
+
fields: [],
|
|
139
|
+
minAge: null,
|
|
140
|
+
}) as OnboardingState
|
|
141
|
+
|
|
142
|
+
const fail = (failure: CommunityFailure): void => {
|
|
143
|
+
state.failure = failure
|
|
144
|
+
state.phase = 'failed'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The texts of these steps — none for a step that is not one, so its id can never be sent; null when one fails. */
|
|
148
|
+
async function textsFor(steps: OnboardingStep[]): Promise<Texts | null> {
|
|
149
|
+
const [rules, art9] = await Promise.all([
|
|
150
|
+
steps.includes('rules') ? o.client.legal.current({ kind: 'rules', locale: o.lang }, call) : null,
|
|
151
|
+
steps.includes('art9') ? o.client.legal.current({ kind: 'art9_consent', locale: o.lang }, call) : null,
|
|
152
|
+
])
|
|
153
|
+
for (const r of [rules, art9]) {
|
|
154
|
+
if (r && !r.ok) {
|
|
155
|
+
fail(r)
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { rules: rules?.ok ? rules.value : null, art9: art9?.ok ? art9.value : null }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** me/get and what it leaves to ask, with the texts; null when it went elsewhere (a failure, a signed-out session). */
|
|
163
|
+
async function read(): Promise<Read | null> {
|
|
164
|
+
const me = await o.client.me.get(call)
|
|
165
|
+
if (!me.ok) {
|
|
166
|
+
fail(me)
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
const needs = me.value.needs.filter(isKnownNeed)
|
|
170
|
+
if (needs.includes('sign_in')) {
|
|
171
|
+
o.onSignIn()
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
const steps = stepsOf(me.value, needs)
|
|
175
|
+
const texts = await textsFor(steps)
|
|
176
|
+
return texts ? { me: me.value, steps, texts } : null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What was read, shown at once; an acceptance holds only for the very text it was given to. */
|
|
180
|
+
function apply(r: Read): void {
|
|
181
|
+
if (r.texts.rules?.textId !== state.rulesText?.textId) state.rulesAccepted = false
|
|
182
|
+
if (r.texts.art9?.textId !== state.art9Text?.textId) state.art9Accepted = false
|
|
183
|
+
state.me = r.me
|
|
184
|
+
state.steps = r.steps
|
|
185
|
+
state.reconsent = r.me.acceptances.rules === 'reconsent_required'
|
|
186
|
+
state.rulesText = r.texts.rules
|
|
187
|
+
state.rules = r.texts.rules ? communityRulesOf(r.texts.rules.bodyJson) : []
|
|
188
|
+
state.art9Text = r.texts.art9
|
|
189
|
+
state.art9Paragraphs = r.texts.art9 ? htmlToParagraphs(r.texts.art9.body) : []
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function load(): Promise<void> {
|
|
193
|
+
state.phase = 'loading'
|
|
194
|
+
state.failure = null
|
|
195
|
+
const r = await read()
|
|
196
|
+
if (!r) return
|
|
197
|
+
apply(r)
|
|
198
|
+
state.index = 0
|
|
199
|
+
// Nothing left to ask (another tab finished it): kb-api's answer settles the needs.
|
|
200
|
+
if (r.steps.length === 0) return submit()
|
|
201
|
+
state.phase = 'ready'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const step = (): OnboardingStep | null => state.steps[state.index] ?? null
|
|
205
|
+
|
|
206
|
+
/** Whether a step has its answer (a text accepted is the text shown). */
|
|
207
|
+
function answered(s: OnboardingStep): boolean {
|
|
208
|
+
switch (s) {
|
|
209
|
+
case 'age':
|
|
210
|
+
return state.ageBand !== null
|
|
211
|
+
case 'rules':
|
|
212
|
+
return state.rulesAccepted && state.rulesText !== null
|
|
213
|
+
case 'art9':
|
|
214
|
+
return state.art9Accepted && state.art9Text !== null
|
|
215
|
+
case 'profile':
|
|
216
|
+
return isDisplayNameOk(state.displayName) && isHandleOk(state.handle)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function canContinue(): boolean {
|
|
221
|
+
const s = step()
|
|
222
|
+
return state.phase === 'ready' && s !== null && answered(s)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function goTo(s: OnboardingStep): void {
|
|
226
|
+
const i = state.steps.indexOf(s)
|
|
227
|
+
if (i >= 0) state.index = i
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The first step without its answer, or -1. */
|
|
231
|
+
const firstOpen = (): number => state.steps.findIndex((s) => !answered(s))
|
|
232
|
+
|
|
233
|
+
async function submit(): Promise<void> {
|
|
234
|
+
// Every step shown has its answer before anything goes (a step a refusal added is still open).
|
|
235
|
+
const open = firstOpen()
|
|
236
|
+
if (open >= 0) {
|
|
237
|
+
state.index = open
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
state.phase = 'sending'
|
|
241
|
+
state.failure = null
|
|
242
|
+
state.fields = []
|
|
243
|
+
// kb-api records whatever current text id it gets: only the ones shown and ticked here go. The field itself is
|
|
244
|
+
// kb-api's schema's; empty is no text (checked only while the rules are still to accept, and then refused).
|
|
245
|
+
const input: OnboardInput = {
|
|
246
|
+
ageBand: state.ageBand ?? state.me?.member?.ageBand ?? '18_plus',
|
|
247
|
+
rulesTextId: state.steps.includes('rules') && state.rulesText ? state.rulesText.textId : '',
|
|
248
|
+
}
|
|
249
|
+
if (state.steps.includes('art9') && state.art9Text) input.art9TextId = state.art9Text.textId
|
|
250
|
+
if (state.steps.includes('profile')) {
|
|
251
|
+
input.displayName = normalizeName(state.displayName)
|
|
252
|
+
const handle = normalizeHandle(state.handle)
|
|
253
|
+
if (handle !== '') input.handle = handle
|
|
254
|
+
input.avatarPreset = state.avatarPreset
|
|
255
|
+
}
|
|
256
|
+
const r = await o.client.me.onboard(input, call)
|
|
257
|
+
if (r.ok) {
|
|
258
|
+
state.phase = 'done'
|
|
259
|
+
o.onDone(r.value)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
state.failure = r
|
|
263
|
+
state.phase = 'ready'
|
|
264
|
+
switch (r.code) {
|
|
265
|
+
case 'age':
|
|
266
|
+
// Two bands only: a refused band is 16–17 under an 18 minimum.
|
|
267
|
+
state.minAge = 18
|
|
268
|
+
state.phase = 'refused'
|
|
269
|
+
o.onRefused?.(state.minAge)
|
|
270
|
+
return
|
|
271
|
+
case 'name':
|
|
272
|
+
state.fields = normalizeHandle(state.handle) !== '' ? ['displayName', 'handle'] : ['displayName']
|
|
273
|
+
return goTo('profile')
|
|
274
|
+
case 'handle':
|
|
275
|
+
state.fields = ['handle']
|
|
276
|
+
return goTo('profile')
|
|
277
|
+
case 'invalid': {
|
|
278
|
+
state.fields = r.fields ?? []
|
|
279
|
+
if (state.fields.some((f) => PROFILE_FIELDS.has(f))) return goTo('profile')
|
|
280
|
+
if (state.fields.some((f) => TEXT_FIELDS.has(f))) return reread(r)
|
|
281
|
+
if (state.fields.includes('ageBand')) return goTo('age')
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
case 'signIn':
|
|
285
|
+
return o.onSignIn()
|
|
286
|
+
default:
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* kb-api refused a text id: a newer text was published meanwhile, or the art. 9 consent was switched on. What is
|
|
293
|
+
* left is read again from me/get — a step may be new, with its text —, the acceptance of a text kb-api named is
|
|
294
|
+
* asked again, and the reader goes to the first step left. Nothing is sent again by itself.
|
|
295
|
+
*/
|
|
296
|
+
async function reread(refusal: CommunityFailure): Promise<void> {
|
|
297
|
+
const named = [...state.fields]
|
|
298
|
+
// The form stays as it is (disabled) while the steps are read again.
|
|
299
|
+
state.phase = 'sending'
|
|
300
|
+
const r = await read()
|
|
301
|
+
if (!r) return
|
|
302
|
+
apply(r)
|
|
303
|
+
if (named.includes('rulesTextId')) state.rulesAccepted = false
|
|
304
|
+
if (named.includes('art9TextId')) state.art9Accepted = false
|
|
305
|
+
state.failure = refusal
|
|
306
|
+
state.fields = named
|
|
307
|
+
const open = firstOpen()
|
|
308
|
+
if (open >= 0) {
|
|
309
|
+
state.index = open
|
|
310
|
+
state.phase = 'ready'
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
// Nothing this model can still ask: finished elsewhere (no need left), or a refusal it cannot answer — shown.
|
|
314
|
+
if (r.me.needs.filter(isKnownNeed).length === 0) {
|
|
315
|
+
state.phase = 'done'
|
|
316
|
+
o.onDone(r.me)
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
fail(refusal)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
state,
|
|
324
|
+
load,
|
|
325
|
+
step,
|
|
326
|
+
isLast: () => state.index >= state.steps.length - 1,
|
|
327
|
+
canContinue,
|
|
328
|
+
async next() {
|
|
329
|
+
if (!canContinue()) return
|
|
330
|
+
state.failure = null
|
|
331
|
+
if (state.index < state.steps.length - 1) {
|
|
332
|
+
state.index += 1
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
await submit()
|
|
336
|
+
},
|
|
337
|
+
back() {
|
|
338
|
+
if (state.phase !== 'ready' || state.index === 0) return
|
|
339
|
+
state.failure = null
|
|
340
|
+
state.index -= 1
|
|
341
|
+
},
|
|
342
|
+
suggestedHandle: () => (normalizeHandle(state.handle) === '' ? suggestedHandle(state.displayName) : null),
|
|
343
|
+
hasFieldError: (field) => state.fields.includes(field),
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a refusal reads (m1-contracts.md §10.2, §10.4): the client turns every kb-api and host code into one
|
|
3
|
+
* of COMMUNITY_UI_CODES; this turns that into the catalogue's sentence — `error.<code>`, or a sanction's
|
|
4
|
+
* own sentence with its end date — and says what the element does about it besides showing it.
|
|
5
|
+
*/
|
|
6
|
+
import type { CommunityFailure, CommunityMessageKey, CommunityUiCode } from '@blogmcp/sdk/community'
|
|
7
|
+
import { formatDate } from './format'
|
|
8
|
+
import type { Lang, TextRef } from './i18n'
|
|
9
|
+
|
|
10
|
+
/** The sentence a refusal shows. Total over COMMUNITY_UI_CODES (test/outcomes.spec.ts). */
|
|
11
|
+
export function failureText(failure: Pick<CommunityFailure, 'code' | 'until'>, lang: Lang): TextRef {
|
|
12
|
+
const until = failure.until ? formatDate(lang, failure.until) : ''
|
|
13
|
+
if (failure.code === 'restricted' && until) return { key: 'sanction.restrictionUntil', params: { date: until } }
|
|
14
|
+
if (failure.code === 'suspended' && until) return { key: 'sanction.suspensionUntil', params: { date: until } }
|
|
15
|
+
return { key: `error.${failure.code}` as CommunityMessageKey }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Seconds a refusal asks to wait before the same write may go (the burst, a quota, the limiter). */
|
|
19
|
+
export const DEFAULT_WAIT_S: Partial<Record<CommunityUiCode, number>> = Object.freeze({ fast: 3, quota: 60 })
|
|
20
|
+
|
|
21
|
+
/** The epoch milliseconds until which a write waits after `failure`, or null when it may go again at once. */
|
|
22
|
+
export function waitUntilOf(failure: Pick<CommunityFailure, 'code' | 'retryAfterS'>, now: number): number | null {
|
|
23
|
+
const fallback = DEFAULT_WAIT_S[failure.code]
|
|
24
|
+
if (fallback === undefined) return null
|
|
25
|
+
const seconds = typeof failure.retryAfterS === 'number' && failure.retryAfterS > 0 ? failure.retryAfterS : fallback
|
|
26
|
+
return now + seconds * 1000
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A countdown shows only for a short wait (a quota until midnight reads `error.quota` alone). */
|
|
30
|
+
export const COUNTDOWN_MAX_S = 120
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the element does with a refusal of a write, besides showing its sentence:
|
|
34
|
+
* - `confirm` the author confirms the codes and sends again (the Russmedia confirmation);
|
|
35
|
+
* - `wait` the write waits (a cooldown);
|
|
36
|
+
* - `onboard` the inline onboarding opens (ONBOARDING_REQUIRED);
|
|
37
|
+
* - `reload` the discussion is read again (the viewer's state changed: a sanction, a session);
|
|
38
|
+
* - `notMoved` the discussion does not take writes yet: one sentence instead of the composer;
|
|
39
|
+
* - `hide` discussions are switched off: the element renders nothing;
|
|
40
|
+
* - `retry` nothing was decided (no answer, the ledger): the same write may go again as it is;
|
|
41
|
+
* - `show` anything else: the sentence, the text stays for the author to change.
|
|
42
|
+
*/
|
|
43
|
+
export type FailureEffect = 'confirm' | 'wait' | 'onboard' | 'reload' | 'notMoved' | 'hide' | 'retry' | 'show'
|
|
44
|
+
|
|
45
|
+
const EFFECTS: Readonly<Record<CommunityUiCode, FailureEffect>> = Object.freeze({
|
|
46
|
+
method: 'show',
|
|
47
|
+
origin: 'show',
|
|
48
|
+
unavailable: 'retry',
|
|
49
|
+
invalid: 'show',
|
|
50
|
+
signIn: 'reload',
|
|
51
|
+
onboarding: 'onboard',
|
|
52
|
+
age: 'show',
|
|
53
|
+
name: 'show',
|
|
54
|
+
handle: 'show',
|
|
55
|
+
restricted: 'reload',
|
|
56
|
+
suspended: 'reload',
|
|
57
|
+
terminated: 'reload',
|
|
58
|
+
links: 'show',
|
|
59
|
+
fast: 'wait',
|
|
60
|
+
blocked: 'show',
|
|
61
|
+
confirm: 'confirm',
|
|
62
|
+
removed: 'reload',
|
|
63
|
+
quota: 'wait',
|
|
64
|
+
captcha: 'show',
|
|
65
|
+
notFound: 'reload',
|
|
66
|
+
surfaceOff: 'hide',
|
|
67
|
+
notMoved: 'notMoved',
|
|
68
|
+
ledger: 'retry',
|
|
69
|
+
submit: 'show',
|
|
70
|
+
} satisfies Record<CommunityUiCode, FailureEffect>)
|
|
71
|
+
|
|
72
|
+
export function failureEffect(code: CommunityUiCode): FailureEffect {
|
|
73
|
+
return EFFECTS[code] ?? 'show'
|
|
74
|
+
}
|