@blogmcp/community 0.1.0 → 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/README.md +108 -11
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/{mount-DzjwnHyx.js → mount-WadUnaTa.js} +3909 -2070
- package/dist/core/attrs.d.ts +27 -1
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +36 -5
- package/dist/core/format.d.ts +7 -0
- package/dist/core/onboarding.d.ts +42 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- 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/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/element.d.ts +22 -1
- package/dist/global.d.ts +7 -4
- package/dist/index.d.ts +10 -2
- package/dist/index.js +34 -11
- package/dist/mount.d.ts +40 -0
- package/dist/react.d.ts +14 -3
- package/dist/react.js +24 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- package/dist/ui/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +2 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/vue.d.ts +114 -3
- package/dist/vue.js +109 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +41 -2
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +203 -18
- package/src/core/format.ts +22 -0
- package/src/core/onboarding.ts +165 -18
- package/src/core/outcomes.ts +13 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/element.ts +62 -6
- package/src/global.ts +18 -4
- package/src/index.ts +36 -1
- package/src/mount.ts +108 -1
- package/src/react.ts +47 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- package/src/ui/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +15 -4
- package/src/ui/MessageItem.vue +24 -6
- package/src/ui/MyCommunityView.vue +9 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/styles.css +16 -0
- package/src/vue.ts +129 -3
package/src/core/onboarding.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* minimum kb-api stores nothing and the reader is told from what age the discussion is open;
|
|
8
8
|
* rules the rules' summary (`legal/current` → `bodyJson`, text only) with a link to the full text, and
|
|
9
9
|
* the acceptance;
|
|
10
|
-
* art9 the art. 9 consent's text and the acceptance, when the tenant asks for it
|
|
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);
|
|
11
16
|
* profile the display name, the handle (kb-api makes one when none is given; the element shows what it
|
|
12
17
|
* would be), an avatar preset.
|
|
13
18
|
*
|
|
@@ -21,9 +26,14 @@
|
|
|
21
26
|
*/
|
|
22
27
|
import { reactive } from 'vue'
|
|
23
28
|
import {
|
|
29
|
+
art9ConsentLayoutOf,
|
|
30
|
+
art9ControllersOf,
|
|
31
|
+
art9RefusalOf,
|
|
24
32
|
communityRulesOf,
|
|
25
33
|
isKnownNeed,
|
|
26
34
|
type AgeBand,
|
|
35
|
+
type Art9ConsentLayout,
|
|
36
|
+
type Art9Refusal,
|
|
27
37
|
type CommunityCallOptions,
|
|
28
38
|
type CommunityClient,
|
|
29
39
|
type CommunityFailure,
|
|
@@ -32,7 +42,8 @@ import {
|
|
|
32
42
|
type MyState,
|
|
33
43
|
type OnboardInput,
|
|
34
44
|
} from '@blogmcp/sdk/community'
|
|
35
|
-
import type { Lang } from './i18n'
|
|
45
|
+
import type { Lang, TextRef } from './i18n'
|
|
46
|
+
import { failureText } from './outcomes'
|
|
36
47
|
import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName, suggestedHandle } from './names'
|
|
37
48
|
import { htmlToParagraphs } from './text'
|
|
38
49
|
|
|
@@ -40,6 +51,15 @@ export type OnboardingStep = 'age' | 'rules' | 'art9' | 'profile'
|
|
|
40
51
|
|
|
41
52
|
export type OnboardingPhase = 'loading' | 'ready' | 'sending' | 'done' | 'failed' | 'refused'
|
|
42
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
|
+
|
|
43
63
|
export interface OnboardingState {
|
|
44
64
|
/** `refused`: the age band is below the minimum — nothing was stored, the reader may go on reading. */
|
|
45
65
|
phase: OnboardingPhase
|
|
@@ -50,10 +70,24 @@ export interface OnboardingState {
|
|
|
50
70
|
rules: CommunityRule[]
|
|
51
71
|
art9Text: CurrentLegalText | null
|
|
52
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
|
|
53
86
|
/** The member accepted an older rules text; this one asks again. */
|
|
54
87
|
reconsent: boolean
|
|
55
88
|
ageBand: AgeBand | null
|
|
56
89
|
rulesAccepted: boolean
|
|
90
|
+
/** Every consent of the step ticked (setting it ticks or unticks them all). */
|
|
57
91
|
art9Accepted: boolean
|
|
58
92
|
displayName: string
|
|
59
93
|
handle: string
|
|
@@ -76,6 +110,15 @@ export interface OnboardingModel {
|
|
|
76
110
|
back(): void
|
|
77
111
|
suggestedHandle(): string | null
|
|
78
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>
|
|
79
122
|
}
|
|
80
123
|
|
|
81
124
|
export interface OnboardingOptions {
|
|
@@ -87,22 +130,51 @@ export interface OnboardingOptions {
|
|
|
87
130
|
onSignIn(): void
|
|
88
131
|
/** The age band was below the tenant's minimum (AGE_BELOW_MINIMUM): kb-api kept nothing, the element keeps this. */
|
|
89
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
|
|
90
138
|
signal?: AbortSignal
|
|
91
139
|
}
|
|
92
140
|
|
|
93
141
|
const PROFILE_FIELDS = new Set(['displayName', 'handle', 'avatarPreset'])
|
|
94
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
|
+
}
|
|
95
158
|
|
|
96
159
|
/** What is still missing for this member, in order (MyState and its known needs). */
|
|
97
|
-
function stepsOf(me: MyState, needs: readonly string[]): OnboardingStep[] {
|
|
160
|
+
function stepsOf(me: MyState, needs: readonly string[], offerArt9: boolean): OnboardingStep[] {
|
|
98
161
|
const steps: OnboardingStep[] = []
|
|
99
162
|
if (!me.member?.ageBand) steps.push('age')
|
|
100
163
|
if (me.acceptances.rules !== 'accepted') steps.push('rules')
|
|
101
|
-
|
|
164
|
+
const art9 = me.acceptances.art9Consent
|
|
165
|
+
if (needs.includes('art9_consent') || (offerArt9 && art9 !== null && art9 !== 'accepted')) steps.push('art9')
|
|
102
166
|
if (!me.profile) steps.push('profile')
|
|
103
167
|
return steps
|
|
104
168
|
}
|
|
105
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
|
+
|
|
106
178
|
/** The texts of the steps shown. */
|
|
107
179
|
interface Texts {
|
|
108
180
|
rules: CurrentLegalText | null
|
|
@@ -118,6 +190,7 @@ interface Read {
|
|
|
118
190
|
|
|
119
191
|
export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
120
192
|
const call: CommunityCallOptions = o.signal ? { signal: o.signal } : {}
|
|
193
|
+
const offerArt9 = o.offerArt9 === true
|
|
121
194
|
const state = reactive<OnboardingState>({
|
|
122
195
|
phase: 'loading',
|
|
123
196
|
steps: [],
|
|
@@ -127,10 +200,21 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
127
200
|
rules: [],
|
|
128
201
|
art9Text: null,
|
|
129
202
|
art9Paragraphs: [],
|
|
203
|
+
art9Layout: 'joint',
|
|
204
|
+
art9Refusal: 'read_only',
|
|
205
|
+
art9Controllers: [],
|
|
206
|
+
art9Refused: false,
|
|
207
|
+
art9RefusedSent: false,
|
|
130
208
|
reconsent: false,
|
|
131
209
|
ageBand: null,
|
|
132
210
|
rulesAccepted: false,
|
|
133
|
-
|
|
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
|
+
},
|
|
134
218
|
displayName: '',
|
|
135
219
|
handle: '',
|
|
136
220
|
avatarPreset: null,
|
|
@@ -171,15 +255,27 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
171
255
|
o.onSignIn()
|
|
172
256
|
return null
|
|
173
257
|
}
|
|
174
|
-
const steps = stepsOf(me.value, needs)
|
|
258
|
+
const steps = stepsOf(me.value, needs, offerArt9)
|
|
175
259
|
const texts = await textsFor(steps)
|
|
176
260
|
return texts ? { me: me.value, steps, texts } : null
|
|
177
261
|
}
|
|
178
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
|
+
|
|
179
266
|
/** What was read, shown at once; an acceptance holds only for the very text it was given to. */
|
|
180
267
|
function apply(r: Read): void {
|
|
181
268
|
if (r.texts.rules?.textId !== state.rulesText?.textId) state.rulesAccepted = false
|
|
182
|
-
|
|
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
|
|
183
279
|
state.me = r.me
|
|
184
280
|
state.steps = r.steps
|
|
185
281
|
state.reconsent = r.me.acceptances.rules === 'reconsent_required'
|
|
@@ -196,8 +292,17 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
196
292
|
if (!r) return
|
|
197
293
|
apply(r)
|
|
198
294
|
state.index = 0
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|
|
201
306
|
state.phase = 'ready'
|
|
202
307
|
}
|
|
203
308
|
|
|
@@ -211,7 +316,7 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
211
316
|
case 'rules':
|
|
212
317
|
return state.rulesAccepted && state.rulesText !== null
|
|
213
318
|
case 'art9':
|
|
214
|
-
return state.
|
|
319
|
+
return state.art9Text !== null && (state.art9Accepted || (state.art9Refused && refusalOffered()))
|
|
215
320
|
case 'profile':
|
|
216
321
|
return isDisplayNameOk(state.displayName) && isHandleOk(state.handle)
|
|
217
322
|
}
|
|
@@ -219,7 +324,11 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
219
324
|
|
|
220
325
|
function canContinue(): boolean {
|
|
221
326
|
const s = step()
|
|
222
|
-
|
|
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)
|
|
223
332
|
}
|
|
224
333
|
|
|
225
334
|
function goTo(s: OnboardingStep): void {
|
|
@@ -246,7 +355,15 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
246
355
|
ageBand: state.ageBand ?? state.me?.member?.ageBand ?? '18_plus',
|
|
247
356
|
rulesTextId: state.steps.includes('rules') && state.rulesText ? state.rulesText.textId : '',
|
|
248
357
|
}
|
|
249
|
-
|
|
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
|
+
}
|
|
250
367
|
if (state.steps.includes('profile')) {
|
|
251
368
|
input.displayName = normalizeName(state.displayName)
|
|
252
369
|
const handle = normalizeHandle(state.handle)
|
|
@@ -255,6 +372,7 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
255
372
|
}
|
|
256
373
|
const r = await o.client.me.onboard(input, call)
|
|
257
374
|
if (r.ok) {
|
|
375
|
+
state.art9RefusedSent = refusing
|
|
258
376
|
state.phase = 'done'
|
|
259
377
|
o.onDone(r.value)
|
|
260
378
|
return
|
|
@@ -276,6 +394,16 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
276
394
|
return goTo('profile')
|
|
277
395
|
case 'invalid': {
|
|
278
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
|
+
}
|
|
279
407
|
if (state.fields.some((f) => PROFILE_FIELDS.has(f))) return goTo('profile')
|
|
280
408
|
if (state.fields.some((f) => TEXT_FIELDS.has(f))) return reread(r)
|
|
281
409
|
if (state.fields.includes('ageBand')) return goTo('age')
|
|
@@ -319,6 +447,18 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
319
447
|
fail(refusal)
|
|
320
448
|
}
|
|
321
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
|
+
|
|
322
462
|
return {
|
|
323
463
|
state,
|
|
324
464
|
load,
|
|
@@ -327,17 +467,24 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
327
467
|
canContinue,
|
|
328
468
|
async next() {
|
|
329
469
|
if (!canContinue()) return
|
|
330
|
-
|
|
331
|
-
if (state.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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()
|
|
336
480
|
},
|
|
481
|
+
refusalOffered,
|
|
337
482
|
back() {
|
|
338
483
|
if (state.phase !== 'ready' || state.index === 0) return
|
|
339
484
|
state.failure = null
|
|
340
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
|
|
341
488
|
},
|
|
342
489
|
suggestedHandle: () => (normalizeHandle(state.handle) === '' ? suggestedHandle(state.displayName) : null),
|
|
343
490
|
hasFieldError: (field) => state.fields.includes(field),
|
package/src/core/outcomes.ts
CHANGED
|
@@ -36,7 +36,8 @@ export const COUNTDOWN_MAX_S = 120
|
|
|
36
36
|
* - `onboard` the inline onboarding opens (ONBOARDING_REQUIRED);
|
|
37
37
|
* - `reload` the discussion is read again (the viewer's state changed: a sanction, a session);
|
|
38
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
|
|
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);
|
|
40
41
|
* - `retry` nothing was decided (no answer, the ledger): the same write may go again as it is;
|
|
41
42
|
* - `show` anything else: the sentence, the text stays for the author to change.
|
|
42
43
|
*/
|
|
@@ -66,6 +67,17 @@ const EFFECTS: Readonly<Record<CommunityUiCode, FailureEffect>> = Object.freeze(
|
|
|
66
67
|
surfaceOff: 'hide',
|
|
67
68
|
notMoved: 'notMoved',
|
|
68
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',
|
|
69
81
|
submit: 'show',
|
|
70
82
|
} satisfies Record<CommunityUiCode, FailureEffect>)
|
|
71
83
|
|
package/src/core/panel.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `<blogmcp-my-community>`'s brain (m1-contracts.md §7.4, §8.3, §10.4; community.md §5): the reader's
|
|
3
3
|
* „Moje komunita" — the statements of reasons about their content (the art. 17(3) fields, the appeal within
|
|
4
|
-
* its window, an open appeal's state), their reports, their notifications, the art. 9 consent's withdrawal,
|
|
5
|
-
* their blocks, the export (a JSON download) and the profile's deletion with its grace and cancel.
|
|
4
|
+
* its window, an open appeal's state), their reports, their notifications, the art. 9 consent's withdrawal (and,
|
|
5
|
+
* M2 T9, a refused one given after all — the onboarding's art. 9 step, opened here), their blocks, the export (a JSON download) and the profile's deletion with its grace and cancel.
|
|
6
6
|
*
|
|
7
7
|
* With a decision id it shows that one statement (the host's statement page). With a notifier's receipt it
|
|
8
8
|
* shows that notice's status and appeal — `notices/status`, `notices/appeal`: no session needed for that.
|
|
9
9
|
*/
|
|
10
|
-
import { reactive } from 'vue'
|
|
10
|
+
import { reactive, shallowRef, type ShallowRef } from 'vue'
|
|
11
11
|
import {
|
|
12
12
|
COMMUNITY_APPEAL_MAX,
|
|
13
13
|
isKnownNotificationCode,
|
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
type ReportItem,
|
|
26
26
|
type Statement,
|
|
27
27
|
} from '@blogmcp/sdk/community'
|
|
28
|
-
import type
|
|
28
|
+
import { pickLang, type TextRef } from './i18n'
|
|
29
|
+
import { createOnboarding, type OnboardingModel } from './onboarding'
|
|
29
30
|
import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName } from './names'
|
|
30
31
|
import { codePointLength, normalizeBody } from './text'
|
|
31
32
|
|
|
@@ -71,6 +72,8 @@ export interface PanelUi {
|
|
|
71
72
|
|
|
72
73
|
export interface PanelController {
|
|
73
74
|
readonly state: PanelUi
|
|
75
|
+
/** The give-consent flow (the onboarding's art. 9 step), while it is open. */
|
|
76
|
+
readonly consent: ShallowRef<OnboardingModel | null>
|
|
74
77
|
/** The member's sections (me/get and the lists). */
|
|
75
78
|
loadMember(): Promise<void>
|
|
76
79
|
loadStatement(decisionId: string): Promise<void>
|
|
@@ -82,6 +85,9 @@ export interface PanelController {
|
|
|
82
85
|
fileNoticeAppeal(): Promise<void>
|
|
83
86
|
withdrawNoticeAppeal(): Promise<void>
|
|
84
87
|
withdrawConsent(): Promise<boolean>
|
|
88
|
+
/** A refused art. 9 consent (`acceptances.art9Refused`): opens its step to give it after all. */
|
|
89
|
+
giveConsent(): void
|
|
90
|
+
closeConsent(): void
|
|
85
91
|
unblock(memberId: string): Promise<void>
|
|
86
92
|
editProfile(): void
|
|
87
93
|
cancelProfile(): void
|
|
@@ -127,6 +133,7 @@ export function createPanel(o: PanelOptions): PanelController {
|
|
|
127
133
|
announceId: 0,
|
|
128
134
|
}) as PanelUi
|
|
129
135
|
let disposed = false
|
|
136
|
+
const consent = shallowRef<OnboardingModel | null>(null)
|
|
130
137
|
|
|
131
138
|
const announce = (text: TextRef): void => {
|
|
132
139
|
state.announcement = text
|
|
@@ -225,8 +232,35 @@ export function createPanel(o: PanelOptions): PanelController {
|
|
|
225
232
|
const appealLength = (d: AppealDraft): number => codePointLength(normalizeBody(d.body))
|
|
226
233
|
const canAppealWith = (d: AppealDraft): boolean => !d.sending && appealLength(d) > 0 && appealLength(d) <= COMMUNITY_APPEAL_MAX
|
|
227
234
|
|
|
235
|
+
function giveConsent(): void {
|
|
236
|
+
if (consent.value || disposed || !state.me?.member || state.me.acceptances.art9Refused !== true) return
|
|
237
|
+
const model = createOnboarding({
|
|
238
|
+
client,
|
|
239
|
+
lang: pickLang(o.lang),
|
|
240
|
+
signal: abort.signal,
|
|
241
|
+
offerArt9: true,
|
|
242
|
+
onDone: (me) => {
|
|
243
|
+
state.me = me
|
|
244
|
+
consent.value = null
|
|
245
|
+
// Only a consent in force is announced: the flow also closes when no step was left to ask (nothing sent).
|
|
246
|
+
if (me.acceptances.art9Consent === 'accepted') announce({ key: 'panel.art9Given' })
|
|
247
|
+
},
|
|
248
|
+
onSignIn: () => {
|
|
249
|
+
consent.value = null
|
|
250
|
+
state.phase = 'signIn'
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
consent.value = model
|
|
254
|
+
void model.load()
|
|
255
|
+
}
|
|
256
|
+
|
|
228
257
|
return {
|
|
229
258
|
state,
|
|
259
|
+
consent,
|
|
260
|
+
giveConsent,
|
|
261
|
+
closeConsent() {
|
|
262
|
+
consent.value = null
|
|
263
|
+
},
|
|
230
264
|
loadMember,
|
|
231
265
|
loadStatement,
|
|
232
266
|
async loadNotice(receipt) {
|
|
@@ -406,6 +440,7 @@ export function createPanel(o: PanelOptions): PanelController {
|
|
|
406
440
|
dispose() {
|
|
407
441
|
disposed = true
|
|
408
442
|
abort.abort()
|
|
443
|
+
consent.value = null
|
|
409
444
|
},
|
|
410
445
|
}
|
|
411
446
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-profile handle>`'s and `<blogmcp-welcome>`'s brains (m2-contracts.md §3.2, §3.10).
|
|
3
|
+
*
|
|
4
|
+
* - The profile: `profile/get { handle }` — the public projection (the author, `memberSince` as `YYYY-MM`, the bio
|
|
5
|
+
* as text, the trust level as its LABEL, never the number). kb-api answers a signed-in host session only; an
|
|
6
|
+
* unknown handle, an erased member and one hidden from the viewer are the same `notFound`.
|
|
7
|
+
* - The welcome page: the tenant's current rules (`legal/current`, their summary from `bodyJson` as the onboarding
|
|
8
|
+
* shows it) and who is viewing (`me/get`: the profile link needs the member's handle).
|
|
9
|
+
*/
|
|
10
|
+
import { reactive } from 'vue'
|
|
11
|
+
import {
|
|
12
|
+
COMMUNITY_HANDLE_RE,
|
|
13
|
+
communityRulesOf,
|
|
14
|
+
isKnownAuthor,
|
|
15
|
+
trustLabelMessageKey,
|
|
16
|
+
type CommunityCallOptions,
|
|
17
|
+
type CommunityClient,
|
|
18
|
+
type CommunityFailure,
|
|
19
|
+
type CommunityMessageKey,
|
|
20
|
+
type CommunityRule,
|
|
21
|
+
type KnownAuthor,
|
|
22
|
+
type PublicProfile,
|
|
23
|
+
} from '@blogmcp/sdk/community'
|
|
24
|
+
|
|
25
|
+
export type LoadPhase = 'idle' | 'loading' | 'ready' | 'failed' | 'signIn' | 'notFound'
|
|
26
|
+
|
|
27
|
+
export interface ProfileUi {
|
|
28
|
+
phase: LoadPhase
|
|
29
|
+
failure: CommunityFailure | null
|
|
30
|
+
author: KnownAuthor | null
|
|
31
|
+
memberSince: string | null
|
|
32
|
+
bio: string | null
|
|
33
|
+
/** The trust level's catalogue key (`trust.<n>`), or null for a label this SDK does not know. */
|
|
34
|
+
trustKey: CommunityMessageKey | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProfileController {
|
|
38
|
+
readonly state: ProfileUi
|
|
39
|
+
load(handle: string): Promise<void>
|
|
40
|
+
dispose(): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A handle as an attribute may give it (`@jana_n`, `Jana_N`): lower case, without the `@`; null for none. */
|
|
44
|
+
export function handleOf(value: unknown): string | null {
|
|
45
|
+
if (typeof value !== 'string') return null
|
|
46
|
+
const h = value.trim().replace(/^@/, '').toLowerCase()
|
|
47
|
+
return COMMUNITY_HANDLE_RE.test(h) ? h : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createProfile(o: { client: CommunityClient }): ProfileController {
|
|
51
|
+
const { client } = o
|
|
52
|
+
const abort = new AbortController()
|
|
53
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
54
|
+
let disposed = false
|
|
55
|
+
let ticket = 0
|
|
56
|
+
const state = reactive<ProfileUi>({ phase: 'idle', failure: null, author: null, memberSince: null, bio: null, trustKey: null }) as ProfileUi
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
state,
|
|
60
|
+
async load(handle) {
|
|
61
|
+
const mine = ++ticket
|
|
62
|
+
state.failure = null
|
|
63
|
+
state.author = null
|
|
64
|
+
state.memberSince = null
|
|
65
|
+
state.bio = null
|
|
66
|
+
state.trustKey = null
|
|
67
|
+
const h = handleOf(handle)
|
|
68
|
+
if (h === null) {
|
|
69
|
+
state.phase = 'notFound'
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
state.phase = 'loading'
|
|
73
|
+
const r = await client.profile.get({ handle: h }, call)
|
|
74
|
+
if (disposed || mine !== ticket) return
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
state.phase = r.code === 'signIn' ? 'signIn' : r.code === 'notFound' ? 'notFound' : 'failed'
|
|
77
|
+
state.failure = r
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const p: PublicProfile = r.value
|
|
81
|
+
// A profile is a member's; an author of another kind (or one a newer kb-api added) is no profile to show.
|
|
82
|
+
if (!isKnownAuthor(p.author) || p.author.kind !== 'member' || p.author.memberId === null) {
|
|
83
|
+
state.phase = 'notFound'
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
state.author = p.author
|
|
87
|
+
state.memberSince = typeof p.memberSince === 'string' ? p.memberSince : null
|
|
88
|
+
state.bio = typeof p.bio === 'string' && p.bio.trim() !== '' ? p.bio : null
|
|
89
|
+
state.trustKey = trustLabelMessageKey(p.trustLabel)
|
|
90
|
+
state.phase = 'ready'
|
|
91
|
+
},
|
|
92
|
+
dispose() {
|
|
93
|
+
disposed = true
|
|
94
|
+
abort.abort()
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface WelcomeUi {
|
|
100
|
+
phase: 'idle' | 'loading' | 'ready'
|
|
101
|
+
rules: CommunityRule[]
|
|
102
|
+
/** The rules could not be read: the page still links them. */
|
|
103
|
+
rulesFailed: boolean
|
|
104
|
+
/** The signed-in member's handle (the profile link), or null for a visitor and a reader who never joined. */
|
|
105
|
+
handle: string | null
|
|
106
|
+
/** Nobody is signed in: the sign-in link takes the profile link's place. */
|
|
107
|
+
visitor: boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface WelcomeController {
|
|
111
|
+
readonly state: WelcomeUi
|
|
112
|
+
load(): Promise<void>
|
|
113
|
+
dispose(): void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createWelcome(o: { client: CommunityClient; lang: string }): WelcomeController {
|
|
117
|
+
const { client } = o
|
|
118
|
+
const abort = new AbortController()
|
|
119
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
120
|
+
let disposed = false
|
|
121
|
+
const state = reactive<WelcomeUi>({ phase: 'idle', rules: [], rulesFailed: false, handle: null, visitor: false }) as WelcomeUi
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
state,
|
|
125
|
+
async load() {
|
|
126
|
+
state.phase = 'loading'
|
|
127
|
+
const [rules, me] = await Promise.all([client.legal.current({ kind: 'rules', locale: o.lang }, call), client.me.get(call)])
|
|
128
|
+
if (disposed) return
|
|
129
|
+
if (rules.ok) state.rules = communityRulesOf(rules.value.bodyJson)
|
|
130
|
+
else state.rulesFailed = true
|
|
131
|
+
if (me.ok) {
|
|
132
|
+
state.visitor = me.value.needs.includes('sign_in')
|
|
133
|
+
state.handle = me.value.member ? handleOf(me.value.profile?.handle) : null
|
|
134
|
+
} else {
|
|
135
|
+
state.visitor = me.code === 'signIn'
|
|
136
|
+
}
|
|
137
|
+
state.phase = 'ready'
|
|
138
|
+
},
|
|
139
|
+
dispose() {
|
|
140
|
+
disposed = true
|
|
141
|
+
abort.abort()
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|