@blogmcp/community 0.0.0-stage → 0.2.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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. package/src/vue.ts +242 -0
@@ -0,0 +1,492 @@
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 (M2 T9, m2-contracts.md §3.8:
11
+ * the layout from me/get's `settings.art9ConsentLayout` — `joint` one consent, `split` one per controller of
12
+ * the text's `bodyJson.controllers` when it names two, all of them required, the text itself shown once —
13
+ * and, where `settings.art9Refusal` is `public_write`, a refusal that joins without the consent: kb-api then
14
+ * opens the public discussion to the member, so `art9Refused` goes instead of the text id; under `read_only`
15
+ * no refusal is offered and a reader who closes the step keeps reading);
16
+ * profile the display name, the handle (kb-api makes one when none is given; the element shows what it
17
+ * would be), an avatar preset.
18
+ *
19
+ * One `me/onboard` at the end carries them all; kb-api records only what is still missing, so a member who
20
+ * comes back for a newer rules text sends the band and the profile they already have untouched.
21
+ *
22
+ * 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
23
+ * this model showed and the reader ticked: the rules text when the rules are a step, else none. A text published
24
+ * meanwhile, or the art. 9 consent switched on meanwhile, is kb-api's VALIDATION on that id: the steps are read
25
+ * again from me/get (a step may be new), the reader goes to the first one left, nothing goes until it is answered.
26
+ */
27
+ import { reactive } from 'vue'
28
+ import {
29
+ art9ConsentLayoutOf,
30
+ art9ControllersOf,
31
+ art9RefusalOf,
32
+ communityRulesOf,
33
+ isKnownNeed,
34
+ type AgeBand,
35
+ type Art9ConsentLayout,
36
+ type Art9Refusal,
37
+ type CommunityCallOptions,
38
+ type CommunityClient,
39
+ type CommunityFailure,
40
+ type CommunityRule,
41
+ type CurrentLegalText,
42
+ type MyState,
43
+ type OnboardInput,
44
+ } from '@blogmcp/sdk/community'
45
+ import type { Lang, TextRef } from './i18n'
46
+ import { failureText } from './outcomes'
47
+ import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName, suggestedHandle } from './names'
48
+ import { htmlToParagraphs } from './text'
49
+
50
+ export type OnboardingStep = 'age' | 'rules' | 'art9' | 'profile'
51
+
52
+ export type OnboardingPhase = 'loading' | 'ready' | 'sending' | 'done' | 'failed' | 'refused'
53
+
54
+ /** One consent of the art. 9 step: a controller the text names (or the tenant, when it names none) and its tick. */
55
+ export interface Art9Consent {
56
+ /** The controller's registry ref from the text's `bodyJson`; empty for the tenant of a text that names none. */
57
+ readonly entity: string
58
+ /** The name the reader sees; empty for the tenant of a text that names none (the step's own sentence then). */
59
+ readonly label: string
60
+ accepted: boolean
61
+ }
62
+
63
+ export interface OnboardingState {
64
+ /** `refused`: the age band is below the minimum — nothing was stored, the reader may go on reading. */
65
+ phase: OnboardingPhase
66
+ steps: OnboardingStep[]
67
+ index: number
68
+ me: MyState | null
69
+ rulesText: CurrentLegalText | null
70
+ rules: CommunityRule[]
71
+ art9Text: CurrentLegalText | null
72
+ art9Paragraphs: string[]
73
+ /** me/get's `settings.art9ConsentLayout` (absent: `joint`). */
74
+ art9Layout: Art9ConsentLayout
75
+ /** me/get's `settings.art9Refusal` (absent: `read_only`). */
76
+ art9Refusal: Art9Refusal
77
+ /**
78
+ * The consents the step asks for, each ticked on its own: one under `joint` (or a text naming fewer than two
79
+ * controllers), the text's two controllers under `split`. Empty while there is no art. 9 text.
80
+ */
81
+ art9Controllers: Art9Consent[]
82
+ /** The reader chose „Nesouhlasím" (only where the refusal is offered). */
83
+ art9Refused: boolean
84
+ /** The me/onboard kb-api took carried the refusal (the done note says what it leaves). */
85
+ art9RefusedSent: boolean
86
+ /** The member accepted an older rules text; this one asks again. */
87
+ reconsent: boolean
88
+ ageBand: AgeBand | null
89
+ rulesAccepted: boolean
90
+ /** Every consent of the step ticked (setting it ticks or unticks them all). */
91
+ art9Accepted: boolean
92
+ displayName: string
93
+ handle: string
94
+ avatarPreset: string | null
95
+ failure: CommunityFailure | null
96
+ /** The fields a refusal named (`displayName`, `handle`, …). */
97
+ fields: string[]
98
+ /** The minimum age, once a refusal told it (a refused `16_17` means 18). */
99
+ minAge: number | null
100
+ }
101
+
102
+ export interface OnboardingModel {
103
+ readonly state: OnboardingState
104
+ load(): Promise<void>
105
+ step(): OnboardingStep | null
106
+ isLast(): boolean
107
+ canContinue(): boolean
108
+ /** The next step, or — on the last one — `me/onboard`. */
109
+ next(): Promise<void>
110
+ back(): void
111
+ suggestedHandle(): string | null
112
+ hasFieldError(field: string): boolean
113
+ /**
114
+ * The tenant takes a refusal of the art. 9 consent here (`art9Refusal = public_write`, not the give-consent flow): the
115
+ * step's lede then does not make writing depend on the consent (community.md §9.8 q8, art. 9(2)(a) — freely given).
116
+ */
117
+ refusalOffered(): boolean
118
+ /** The art. 9 step offers „Nesouhlasím" (`art9Refusal = public_write`, never in the give-consent flow). */
119
+ canRefuse(): boolean
120
+ /** „Nesouhlasím": the step is answered without the consent; the next step, or — on the last one — `me/onboard`. */
121
+ refuse(): Promise<void>
122
+ }
123
+
124
+ export interface OnboardingOptions {
125
+ client: Pick<CommunityClient, 'me' | 'legal'>
126
+ lang: Lang
127
+ /** Called with the member's state once kb-api took the onboarding. */
128
+ onDone(state: MyState): void
129
+ /** The session cannot act as a member any more (signed out meanwhile). */
130
+ onSignIn(): void
131
+ /** The age band was below the tenant's minimum (AGE_BELOW_MINIMUM): kb-api kept nothing, the element keeps this. */
132
+ onRefused?(minAge: number): void
133
+ /**
134
+ * The give-consent flow („Moje komunita", M2 T9): the art. 9 step is asked also when `needs` does not name it — a
135
+ * member who refused it, or has not given it, where the tenant asks for it — and no refusal is offered.
136
+ */
137
+ offerArt9?: boolean
138
+ signal?: AbortSignal
139
+ }
140
+
141
+ const PROFILE_FIELDS = new Set(['displayName', 'handle', 'avatarPreset'])
142
+ const TEXT_FIELDS = new Set(['rulesTextId', 'art9TextId'])
143
+ /** kb-api's issue code for a refusal the tenant does not take (`art9Refusal = read_only`, §3.8). */
144
+ export const ART9_REFUSAL_READ_ONLY = 'art9_refusal_read_only'
145
+
146
+ /**
147
+ * A refusal of the art. 9 refusal itself: the tenant's setting is `read_only` (changed meanwhile, or a stale page) —
148
+ * kb-api's issue code, never the path alone. A path-only `art9Refused` is kb-api's „a consent is already in force"
149
+ * (given in another tab): a plain reread with the generic sentence.
150
+ */
151
+ const refusalRefused = (f: Pick<CommunityFailure, 'code' | 'issueCodes'>): boolean =>
152
+ f.code === 'invalid' && f.issueCodes?.includes(ART9_REFUSAL_READ_ONLY) === true
153
+
154
+ /** The sentence a refusal of the onboarding shows: a refused refusal says the consent is needed to write. */
155
+ export function onboardingFailureText(failure: Pick<CommunityFailure, 'code' | 'until' | 'fields' | 'issueCodes'>, lang: Lang): TextRef {
156
+ return refusalRefused(failure) ? { key: 'onboarding.art9RequiredToWrite' } : failureText(failure, lang)
157
+ }
158
+
159
+ /** What is still missing for this member, in order (MyState and its known needs). */
160
+ function stepsOf(me: MyState, needs: readonly string[], offerArt9: boolean): OnboardingStep[] {
161
+ const steps: OnboardingStep[] = []
162
+ if (!me.member?.ageBand) steps.push('age')
163
+ if (me.acceptances.rules !== 'accepted') steps.push('rules')
164
+ const art9 = me.acceptances.art9Consent
165
+ if (needs.includes('art9_consent') || (offerArt9 && art9 !== null && art9 !== 'accepted')) steps.push('art9')
166
+ if (!me.profile) steps.push('profile')
167
+ return steps
168
+ }
169
+
170
+ /** The consents the step asks for: the text's two controllers under `split`, else one (its first, or the tenant). */
171
+ function consentsOf(text: CurrentLegalText | null, layout: Art9ConsentLayout): Array<{ entity: string; label: string }> {
172
+ if (!text) return []
173
+ const listed = art9ControllersOf(text.bodyJson)
174
+ if (layout === 'split' && listed.length > 1) return listed.map((c) => ({ entity: c.entity, label: c.label }))
175
+ return [{ entity: listed[0]?.entity ?? '', label: listed.length > 1 ? '' : (listed[0]?.label ?? '') }]
176
+ }
177
+
178
+ /** The texts of the steps shown. */
179
+ interface Texts {
180
+ rules: CurrentLegalText | null
181
+ art9: CurrentLegalText | null
182
+ }
183
+
184
+ /** One reading of what is left to ask: me/get, its steps, their texts. */
185
+ interface Read {
186
+ me: MyState
187
+ steps: OnboardingStep[]
188
+ texts: Texts
189
+ }
190
+
191
+ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
192
+ const call: CommunityCallOptions = o.signal ? { signal: o.signal } : {}
193
+ const offerArt9 = o.offerArt9 === true
194
+ const state = reactive<OnboardingState>({
195
+ phase: 'loading',
196
+ steps: [],
197
+ index: 0,
198
+ me: null,
199
+ rulesText: null,
200
+ rules: [],
201
+ art9Text: null,
202
+ art9Paragraphs: [],
203
+ art9Layout: 'joint',
204
+ art9Refusal: 'read_only',
205
+ art9Controllers: [],
206
+ art9Refused: false,
207
+ art9RefusedSent: false,
208
+ reconsent: false,
209
+ ageBand: null,
210
+ rulesAccepted: false,
211
+ // Read through the reactive proxy (`this`), so a view's v-model ticks every consent and the guard sees them all.
212
+ get art9Accepted(): boolean {
213
+ return this.art9Controllers.length > 0 && this.art9Controllers.every((c) => c.accepted)
214
+ },
215
+ set art9Accepted(value: boolean) {
216
+ for (const c of this.art9Controllers) c.accepted = value
217
+ },
218
+ displayName: '',
219
+ handle: '',
220
+ avatarPreset: null,
221
+ failure: null,
222
+ fields: [],
223
+ minAge: null,
224
+ }) as OnboardingState
225
+
226
+ const fail = (failure: CommunityFailure): void => {
227
+ state.failure = failure
228
+ state.phase = 'failed'
229
+ }
230
+
231
+ /** The texts of these steps — none for a step that is not one, so its id can never be sent; null when one fails. */
232
+ async function textsFor(steps: OnboardingStep[]): Promise<Texts | null> {
233
+ const [rules, art9] = await Promise.all([
234
+ steps.includes('rules') ? o.client.legal.current({ kind: 'rules', locale: o.lang }, call) : null,
235
+ steps.includes('art9') ? o.client.legal.current({ kind: 'art9_consent', locale: o.lang }, call) : null,
236
+ ])
237
+ for (const r of [rules, art9]) {
238
+ if (r && !r.ok) {
239
+ fail(r)
240
+ return null
241
+ }
242
+ }
243
+ return { rules: rules?.ok ? rules.value : null, art9: art9?.ok ? art9.value : null }
244
+ }
245
+
246
+ /** me/get and what it leaves to ask, with the texts; null when it went elsewhere (a failure, a signed-out session). */
247
+ async function read(): Promise<Read | null> {
248
+ const me = await o.client.me.get(call)
249
+ if (!me.ok) {
250
+ fail(me)
251
+ return null
252
+ }
253
+ const needs = me.value.needs.filter(isKnownNeed)
254
+ if (needs.includes('sign_in')) {
255
+ o.onSignIn()
256
+ return null
257
+ }
258
+ const steps = stepsOf(me.value, needs, offerArt9)
259
+ const texts = await textsFor(steps)
260
+ return texts ? { me: me.value, steps, texts } : null
261
+ }
262
+
263
+ /** The refusal is offered: the tenant takes it (`public_write`), and this is not the give-consent flow. */
264
+ const refusalOffered = (): boolean => !offerArt9 && state.art9Refusal === 'public_write'
265
+
266
+ /** What was read, shown at once; an acceptance holds only for the very text it was given to. */
267
+ function apply(r: Read): void {
268
+ if (r.texts.rules?.textId !== state.rulesText?.textId) state.rulesAccepted = false
269
+ const layout = art9ConsentLayoutOf(r.me.settings?.art9ConsentLayout)
270
+ const sameText = r.texts.art9 !== null && r.texts.art9.textId === state.art9Text?.textId
271
+ // A tick holds for the same controller of the same text; anything else is asked again.
272
+ state.art9Controllers = consentsOf(r.texts.art9, layout).map((c, i) => {
273
+ const before = state.art9Controllers[i]
274
+ return { ...c, accepted: sameText && before?.entity === c.entity && before.accepted === true }
275
+ })
276
+ state.art9Layout = layout
277
+ state.art9Refusal = art9RefusalOf(r.me.settings?.art9Refusal)
278
+ if (!sameText || !refusalOffered()) state.art9Refused = false
279
+ state.me = r.me
280
+ state.steps = r.steps
281
+ state.reconsent = r.me.acceptances.rules === 'reconsent_required'
282
+ state.rulesText = r.texts.rules
283
+ state.rules = r.texts.rules ? communityRulesOf(r.texts.rules.bodyJson) : []
284
+ state.art9Text = r.texts.art9
285
+ state.art9Paragraphs = r.texts.art9 ? htmlToParagraphs(r.texts.art9.body) : []
286
+ }
287
+
288
+ async function load(): Promise<void> {
289
+ state.phase = 'loading'
290
+ state.failure = null
291
+ const r = await read()
292
+ if (!r) return
293
+ apply(r)
294
+ state.index = 0
295
+ if (r.steps.length === 0) {
296
+ // The give-consent flow with no consent left to ask (given in another tab, or the tenant stopped asking for it):
297
+ // nothing is sent — an empty me/onboard would be no consent — and the caller reads the state it is handed.
298
+ if (offerArt9) {
299
+ state.phase = 'done'
300
+ o.onDone(r.me)
301
+ return
302
+ }
303
+ // Nothing left to ask (another tab finished it): kb-api's answer settles the needs.
304
+ return submit()
305
+ }
306
+ state.phase = 'ready'
307
+ }
308
+
309
+ const step = (): OnboardingStep | null => state.steps[state.index] ?? null
310
+
311
+ /** Whether a step has its answer (a text accepted is the text shown). */
312
+ function answered(s: OnboardingStep): boolean {
313
+ switch (s) {
314
+ case 'age':
315
+ return state.ageBand !== null
316
+ case 'rules':
317
+ return state.rulesAccepted && state.rulesText !== null
318
+ case 'art9':
319
+ return state.art9Text !== null && (state.art9Accepted || (state.art9Refused && refusalOffered()))
320
+ case 'profile':
321
+ return isDisplayNameOk(state.displayName) && isHandleOk(state.handle)
322
+ }
323
+ }
324
+
325
+ function canContinue(): boolean {
326
+ const s = step()
327
+ if (state.phase !== 'ready' || s === null) return false
328
+ // „Pokračovat" on the art. 9 step means the consent: a refusal goes only through „Nesouhlasím" (refuse()), never
329
+ // silently through the primary button.
330
+ if (s === 'art9') return state.art9Text !== null && state.art9Accepted
331
+ return answered(s)
332
+ }
333
+
334
+ function goTo(s: OnboardingStep): void {
335
+ const i = state.steps.indexOf(s)
336
+ if (i >= 0) state.index = i
337
+ }
338
+
339
+ /** The first step without its answer, or -1. */
340
+ const firstOpen = (): number => state.steps.findIndex((s) => !answered(s))
341
+
342
+ async function submit(): Promise<void> {
343
+ // Every step shown has its answer before anything goes (a step a refusal added is still open).
344
+ const open = firstOpen()
345
+ if (open >= 0) {
346
+ state.index = open
347
+ return
348
+ }
349
+ state.phase = 'sending'
350
+ state.failure = null
351
+ state.fields = []
352
+ // kb-api records whatever current text id it gets: only the ones shown and ticked here go. The field itself is
353
+ // kb-api's schema's; empty is no text (checked only while the rules are still to accept, and then refused).
354
+ const input: OnboardInput = {
355
+ ageBand: state.ageBand ?? state.me?.member?.ageBand ?? '18_plus',
356
+ rulesTextId: state.steps.includes('rules') && state.rulesText ? state.rulesText.textId : '',
357
+ }
358
+ // A consent ticked wins over a refusal chosen before (the reader came back and ticked it).
359
+ let refusing = false
360
+ if (state.steps.includes('art9') && state.art9Text) {
361
+ if (state.art9Accepted) input.art9TextId = state.art9Text.textId
362
+ else if (state.art9Refused && refusalOffered()) {
363
+ input.art9Refused = true
364
+ refusing = true
365
+ }
366
+ }
367
+ if (state.steps.includes('profile')) {
368
+ input.displayName = normalizeName(state.displayName)
369
+ const handle = normalizeHandle(state.handle)
370
+ if (handle !== '') input.handle = handle
371
+ input.avatarPreset = state.avatarPreset
372
+ }
373
+ const r = await o.client.me.onboard(input, call)
374
+ if (r.ok) {
375
+ state.art9RefusedSent = refusing
376
+ state.phase = 'done'
377
+ o.onDone(r.value)
378
+ return
379
+ }
380
+ state.failure = r
381
+ state.phase = 'ready'
382
+ switch (r.code) {
383
+ case 'age':
384
+ // Two bands only: a refused band is 16–17 under an 18 minimum.
385
+ state.minAge = 18
386
+ state.phase = 'refused'
387
+ o.onRefused?.(state.minAge)
388
+ return
389
+ case 'name':
390
+ state.fields = normalizeHandle(state.handle) !== '' ? ['displayName', 'handle'] : ['displayName']
391
+ return goTo('profile')
392
+ case 'handle':
393
+ state.fields = ['handle']
394
+ return goTo('profile')
395
+ case 'invalid': {
396
+ state.fields = r.fields ?? []
397
+ // The tenant takes no refusal (now): the settings are read again, the consent asked, the reader told why.
398
+ if (refusalRefused(r)) {
399
+ state.art9Refused = false
400
+ return reread(r)
401
+ }
402
+ // A refusal kb-api did not take because a consent is in force already (another tab): read again.
403
+ if (state.fields.includes('art9Refused')) {
404
+ state.art9Refused = false
405
+ return reread(r)
406
+ }
407
+ if (state.fields.some((f) => PROFILE_FIELDS.has(f))) return goTo('profile')
408
+ if (state.fields.some((f) => TEXT_FIELDS.has(f))) return reread(r)
409
+ if (state.fields.includes('ageBand')) return goTo('age')
410
+ return
411
+ }
412
+ case 'signIn':
413
+ return o.onSignIn()
414
+ default:
415
+ return
416
+ }
417
+ }
418
+
419
+ /**
420
+ * kb-api refused a text id: a newer text was published meanwhile, or the art. 9 consent was switched on. What is
421
+ * left is read again from me/get — a step may be new, with its text —, the acceptance of a text kb-api named is
422
+ * asked again, and the reader goes to the first step left. Nothing is sent again by itself.
423
+ */
424
+ async function reread(refusal: CommunityFailure): Promise<void> {
425
+ const named = [...state.fields]
426
+ // The form stays as it is (disabled) while the steps are read again.
427
+ state.phase = 'sending'
428
+ const r = await read()
429
+ if (!r) return
430
+ apply(r)
431
+ if (named.includes('rulesTextId')) state.rulesAccepted = false
432
+ if (named.includes('art9TextId')) state.art9Accepted = false
433
+ state.failure = refusal
434
+ state.fields = named
435
+ const open = firstOpen()
436
+ if (open >= 0) {
437
+ state.index = open
438
+ state.phase = 'ready'
439
+ return
440
+ }
441
+ // Nothing this model can still ask: finished elsewhere (no need left), or a refusal it cannot answer — shown.
442
+ if (r.me.needs.filter(isKnownNeed).length === 0) {
443
+ state.phase = 'done'
444
+ o.onDone(r.me)
445
+ return
446
+ }
447
+ fail(refusal)
448
+ }
449
+
450
+ /** On to the next step, or — on the last one — `me/onboard`. */
451
+ async function advance(): Promise<void> {
452
+ state.failure = null
453
+ if (state.index < state.steps.length - 1) {
454
+ state.index += 1
455
+ return
456
+ }
457
+ await submit()
458
+ }
459
+
460
+ const canRefuse = (): boolean => state.phase === 'ready' && step() === 'art9' && state.art9Text !== null && refusalOffered()
461
+
462
+ return {
463
+ state,
464
+ load,
465
+ step,
466
+ isLast: () => state.index >= state.steps.length - 1,
467
+ canContinue,
468
+ async next() {
469
+ if (!canContinue()) return
470
+ // The consent given: a refusal chosen before on this step no longer stands.
471
+ if (step() === 'art9' && state.art9Accepted) state.art9Refused = false
472
+ await advance()
473
+ },
474
+ canRefuse,
475
+ async refuse() {
476
+ if (!canRefuse()) return
477
+ state.art9Accepted = false
478
+ state.art9Refused = true
479
+ await advance()
480
+ },
481
+ refusalOffered,
482
+ back() {
483
+ if (state.phase !== 'ready' || state.index === 0) return
484
+ state.failure = null
485
+ state.index -= 1
486
+ // Back on the art. 9 step: the reader chooses again — a refusal chosen before does not stand unseen.
487
+ if (step() === 'art9') state.art9Refused = false
488
+ },
489
+ suggestedHandle: () => (normalizeHandle(state.handle) === '' ? suggestedHandle(state.displayName) : null),
490
+ hasFieldError: (field) => state.fields.includes(field),
491
+ }
492
+ }
@@ -0,0 +1,86 @@
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 (`realtimeOff`: only live updates are —
40
+ * the element keeps polling and never hides the discussion for it);
41
+ * - `retry` nothing was decided (no answer, the ledger): the same write may go again as it is;
42
+ * - `show` anything else: the sentence, the text stays for the author to change.
43
+ */
44
+ export type FailureEffect = 'confirm' | 'wait' | 'onboard' | 'reload' | 'notMoved' | 'hide' | 'retry' | 'show'
45
+
46
+ const EFFECTS: Readonly<Record<CommunityUiCode, FailureEffect>> = Object.freeze({
47
+ method: 'show',
48
+ origin: 'show',
49
+ unavailable: 'retry',
50
+ invalid: 'show',
51
+ signIn: 'reload',
52
+ onboarding: 'onboard',
53
+ age: 'show',
54
+ name: 'show',
55
+ handle: 'show',
56
+ restricted: 'reload',
57
+ suspended: 'reload',
58
+ terminated: 'reload',
59
+ links: 'show',
60
+ fast: 'wait',
61
+ blocked: 'show',
62
+ confirm: 'confirm',
63
+ removed: 'reload',
64
+ quota: 'wait',
65
+ captcha: 'show',
66
+ notFound: 'reload',
67
+ surfaceOff: 'hide',
68
+ notMoved: 'notMoved',
69
+ ledger: 'retry',
70
+ // M2 (m2-contracts.md §3.1): a pause refuses every member write until it ends (`until`) — the write waits and the
71
+ // composer gives way to `state.braked`, and me/get is read again for the brake's end (reads go on).
72
+ braked: 'wait',
73
+ // A Pro feature of a module that went read_only: the member's state changed under the page — read it again.
74
+ readOnly: 'reload',
75
+ // Too many @mentions for the member's level: the sentence, and the text stays for the author to trim.
76
+ mentionLimit: 'show',
77
+ // A browser whose push service kb-api will not send to: the sentence; the switch stays off (the settings element).
78
+ pushRefused: 'show',
79
+ // Live updates are off here: nothing to show the reader — the discussion keeps polling (the realtime session's fallback).
80
+ realtimeOff: 'hide',
81
+ submit: 'show',
82
+ } satisfies Record<CommunityUiCode, FailureEffect>)
83
+
84
+ export function failureEffect(code: CommunityUiCode): FailureEffect {
85
+ return EFFECTS[code] ?? 'show'
86
+ }