@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.
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-WadUnaTa.js +6436 -0
- package/dist/core/attrs.d.ts +99 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +175 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +24 -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 +92 -0
- package/dist/core/outcomes.d.ts +29 -0
- package/dist/core/panel.d.ts +96 -0
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +35 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +47 -0
- package/dist/global.d.ts +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +45 -0
- package/dist/mount.d.ts +105 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +55 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +44 -0
- package/dist/ui/LinkedText.vue.d.ts +6 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +26 -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/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ProfileView.vue.d.ts +13 -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/WelcomeView.vue.d.ts +12 -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 +200 -0
- package/dist/vue.js +201 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +186 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/client.ts +54 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +876 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +112 -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 +492 -0
- package/src/core/outcomes.ts +86 -0
- package/src/core/panel.ts +446 -0
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +174 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +165 -0
- package/src/global.ts +26 -0
- package/src/index.ts +70 -0
- package/src/mount.ts +234 -0
- package/src/react.ts +97 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +334 -0
- package/src/ui/LinkedText.vue +24 -0
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +141 -0
- package/src/ui/MessageItem.vue +223 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +378 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +174 -0
- package/src/ui/ProfileView.vue +91 -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/WelcomeView.vue +72 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +259 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- 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
|
+
}
|