@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.
Files changed (68) hide show
  1. package/README.md +108 -11
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/{mount-DzjwnHyx.js → mount-WadUnaTa.js} +3909 -2070
  4. package/dist/core/attrs.d.ts +27 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +36 -5
  9. package/dist/core/format.d.ts +7 -0
  10. package/dist/core/onboarding.d.ts +42 -2
  11. package/dist/core/outcomes.d.ts +2 -1
  12. package/dist/core/panel.d.ts +17 -1
  13. package/dist/core/profile.d.ts +40 -0
  14. package/dist/core/push.d.ts +63 -0
  15. package/dist/core/realtime.d.ts +35 -0
  16. package/dist/core/settings.d.ts +53 -0
  17. package/dist/core/text.d.ts +13 -0
  18. package/dist/element.d.ts +22 -1
  19. package/dist/global.d.ts +7 -4
  20. package/dist/index.d.ts +10 -2
  21. package/dist/index.js +34 -11
  22. package/dist/mount.d.ts +40 -0
  23. package/dist/react.d.ts +14 -3
  24. package/dist/react.js +24 -4
  25. package/dist/ui/BadgeView.vue.d.ts +13 -0
  26. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  27. package/dist/ui/LinkedText.vue.d.ts +1 -0
  28. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  29. package/dist/ui/MessageComposer.vue.d.ts +2 -0
  30. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  31. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  32. package/dist/ui/ProfileView.vue.d.ts +13 -0
  33. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  34. package/dist/vue.d.ts +114 -3
  35. package/dist/vue.js +109 -5
  36. package/package.json +3 -3
  37. package/src/core/attrs.ts +41 -2
  38. package/src/core/badge.ts +120 -0
  39. package/src/core/centre.ts +136 -0
  40. package/src/core/digest-token.ts +26 -0
  41. package/src/core/discussion.ts +203 -18
  42. package/src/core/format.ts +22 -0
  43. package/src/core/onboarding.ts +165 -18
  44. package/src/core/outcomes.ts +13 -1
  45. package/src/core/panel.ts +39 -4
  46. package/src/core/profile.ts +144 -0
  47. package/src/core/push.ts +174 -0
  48. package/src/core/realtime.ts +39 -0
  49. package/src/core/settings.ts +363 -0
  50. package/src/core/text.ts +32 -0
  51. package/src/element.ts +62 -6
  52. package/src/global.ts +18 -4
  53. package/src/index.ts +36 -1
  54. package/src/mount.ts +108 -1
  55. package/src/react.ts +47 -3
  56. package/src/ui/BadgeView.vue +51 -0
  57. package/src/ui/DiscussionView.vue +43 -3
  58. package/src/ui/LinkedText.vue +11 -5
  59. package/src/ui/MemberSettingsView.vue +175 -0
  60. package/src/ui/MessageComposer.vue +15 -4
  61. package/src/ui/MessageItem.vue +24 -6
  62. package/src/ui/MyCommunityView.vue +9 -0
  63. package/src/ui/NotificationCentreView.vue +159 -0
  64. package/src/ui/OnboardingSteps.vue +17 -6
  65. package/src/ui/ProfileView.vue +91 -0
  66. package/src/ui/WelcomeView.vue +72 -0
  67. package/src/ui/styles.css +16 -0
  68. package/src/vue.ts +129 -3
@@ -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
- if (needs.includes('art9_consent')) steps.push('art9')
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
- art9Accepted: 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
+ },
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
- if (r.texts.art9?.textId !== state.art9Text?.textId) state.art9Accepted = 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
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
- // Nothing left to ask (another tab finished it): kb-api's answer settles the needs.
200
- if (r.steps.length === 0) return submit()
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.art9Accepted && state.art9Text !== null
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
- return state.phase === 'ready' && s !== null && answered(s)
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
- if (state.steps.includes('art9') && state.art9Text) input.art9TextId = state.art9Text.textId
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
- state.failure = null
331
- if (state.index < state.steps.length - 1) {
332
- state.index += 1
333
- return
334
- }
335
- await submit()
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),
@@ -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 { TextRef } from './i18n'
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
+ }