@blogmcp/community 0.1.0 → 0.3.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 (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -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 +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. package/dist/chunks/mount-DzjwnHyx.js +0 -4597
@@ -7,7 +7,13 @@
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 — or, M3, before a forum write,
11
+ * whatever the tenant's setting says (`forceArt9`: m3-contracts.md §3.1 step 5, U14) — (M2 T9, m2-contracts.md §3.8:
12
+ * the layout from me/get's `settings.art9ConsentLayout` — `joint` one consent, `split` one per controller of
13
+ * the text's `bodyJson.controllers` when it names two, all of them required, the text itself shown once —
14
+ * and, where `settings.art9Refusal` is `public_write`, a refusal that joins without the consent: kb-api then
15
+ * opens the public discussion to the member, so `art9Refused` goes instead of the text id; under `read_only`
16
+ * no refusal is offered and a reader who closes the step keeps reading);
11
17
  * profile the display name, the handle (kb-api makes one when none is given; the element shows what it
12
18
  * would be), an avatar preset.
13
19
  *
@@ -21,9 +27,14 @@
21
27
  */
22
28
  import { reactive } from 'vue'
23
29
  import {
30
+ art9ConsentLayoutOf,
31
+ art9ControllersOf,
32
+ art9RefusalOf,
24
33
  communityRulesOf,
25
34
  isKnownNeed,
26
35
  type AgeBand,
36
+ type Art9ConsentLayout,
37
+ type Art9Refusal,
27
38
  type CommunityCallOptions,
28
39
  type CommunityClient,
29
40
  type CommunityFailure,
@@ -32,7 +43,8 @@ import {
32
43
  type MyState,
33
44
  type OnboardInput,
34
45
  } from '@blogmcp/sdk/community'
35
- import type { Lang } from './i18n'
46
+ import type { Lang, TextRef } from './i18n'
47
+ import { failureText } from './outcomes'
36
48
  import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName, suggestedHandle } from './names'
37
49
  import { htmlToParagraphs } from './text'
38
50
 
@@ -40,6 +52,15 @@ export type OnboardingStep = 'age' | 'rules' | 'art9' | 'profile'
40
52
 
41
53
  export type OnboardingPhase = 'loading' | 'ready' | 'sending' | 'done' | 'failed' | 'refused'
42
54
 
55
+ /** One consent of the art. 9 step: a controller the text names (or the tenant, when it names none) and its tick. */
56
+ export interface Art9Consent {
57
+ /** The controller's registry ref from the text's `bodyJson`; empty for the tenant of a text that names none. */
58
+ readonly entity: string
59
+ /** The name the reader sees; empty for the tenant of a text that names none (the step's own sentence then). */
60
+ readonly label: string
61
+ accepted: boolean
62
+ }
63
+
43
64
  export interface OnboardingState {
44
65
  /** `refused`: the age band is below the minimum — nothing was stored, the reader may go on reading. */
45
66
  phase: OnboardingPhase
@@ -50,10 +71,24 @@ export interface OnboardingState {
50
71
  rules: CommunityRule[]
51
72
  art9Text: CurrentLegalText | null
52
73
  art9Paragraphs: string[]
74
+ /** me/get's `settings.art9ConsentLayout` (absent: `joint`). */
75
+ art9Layout: Art9ConsentLayout
76
+ /** me/get's `settings.art9Refusal` (absent: `read_only`). */
77
+ art9Refusal: Art9Refusal
78
+ /**
79
+ * The consents the step asks for, each ticked on its own: one under `joint` (or a text naming fewer than two
80
+ * controllers), the text's two controllers under `split`. Empty while there is no art. 9 text.
81
+ */
82
+ art9Controllers: Art9Consent[]
83
+ /** The reader chose „Nesouhlasím" (only where the refusal is offered). */
84
+ art9Refused: boolean
85
+ /** The me/onboard kb-api took carried the refusal (the done note says what it leaves). */
86
+ art9RefusedSent: boolean
53
87
  /** The member accepted an older rules text; this one asks again. */
54
88
  reconsent: boolean
55
89
  ageBand: AgeBand | null
56
90
  rulesAccepted: boolean
91
+ /** Every consent of the step ticked (setting it ticks or unticks them all). */
57
92
  art9Accepted: boolean
58
93
  displayName: string
59
94
  handle: string
@@ -76,6 +111,15 @@ export interface OnboardingModel {
76
111
  back(): void
77
112
  suggestedHandle(): string | null
78
113
  hasFieldError(field: string): boolean
114
+ /**
115
+ * The tenant takes a refusal of the art. 9 consent here (`art9Refusal = public_write`, not the give-consent flow): the
116
+ * step's lede then does not make writing depend on the consent (community.md §9.8 q8, art. 9(2)(a) — freely given).
117
+ */
118
+ refusalOffered(): boolean
119
+ /** The art. 9 step offers „Nesouhlasím" (`art9Refusal = public_write`, never in the give-consent flow). */
120
+ canRefuse(): boolean
121
+ /** „Nesouhlasím": the step is answered without the consent; the next step, or — on the last one — `me/onboard`. */
122
+ refuse(): Promise<void>
79
123
  }
80
124
 
81
125
  export interface OnboardingOptions {
@@ -87,22 +131,61 @@ export interface OnboardingOptions {
87
131
  onSignIn(): void
88
132
  /** The age band was below the tenant's minimum (AGE_BELOW_MINIMUM): kb-api kept nothing, the element keeps this. */
89
133
  onRefused?(minAge: number): void
134
+ /**
135
+ * The give-consent flow („Moje komunita", M2 T9): the art. 9 step is asked also when `needs` does not name it — a
136
+ * member who refused it, or has not given it, where the tenant asks for it — and no refusal is offered.
137
+ */
138
+ offerArt9?: boolean
139
+ /**
140
+ * M3 (m3-contracts.md §3.1 step 5, U14): a forum write needs the art. 9 consent whatever the tenant's
141
+ * `require_art9_consent` says, so the step is asked whenever the consent is not in force — me/get names no
142
+ * `art9_consent` need (and may report the consent as null) where the tenant does not ask for it elsewhere. A refusal
143
+ * is offered as for the discussion (`art9Refusal = public_write`), and a member who refused there is not asked again.
144
+ */
145
+ forceArt9?: boolean
90
146
  signal?: AbortSignal
91
147
  }
92
148
 
93
149
  const PROFILE_FIELDS = new Set(['displayName', 'handle', 'avatarPreset'])
94
150
  const TEXT_FIELDS = new Set(['rulesTextId', 'art9TextId'])
151
+ /** kb-api's issue code for a refusal the tenant does not take (`art9Refusal = read_only`, §3.8). */
152
+ export const ART9_REFUSAL_READ_ONLY = 'art9_refusal_read_only'
153
+
154
+ /**
155
+ * A refusal of the art. 9 refusal itself: the tenant's setting is `read_only` (changed meanwhile, or a stale page) —
156
+ * kb-api's issue code, never the path alone. A path-only `art9Refused` is kb-api's „a consent is already in force"
157
+ * (given in another tab): a plain reread with the generic sentence.
158
+ */
159
+ const refusalRefused = (f: Pick<CommunityFailure, 'code' | 'issueCodes'>): boolean =>
160
+ f.code === 'invalid' && f.issueCodes?.includes(ART9_REFUSAL_READ_ONLY) === true
161
+
162
+ /** The sentence a refusal of the onboarding shows: a refused refusal says the consent is needed to write. */
163
+ export function onboardingFailureText(failure: Pick<CommunityFailure, 'code' | 'until' | 'fields' | 'issueCodes'>, lang: Lang): TextRef {
164
+ return refusalRefused(failure) ? { key: 'onboarding.art9RequiredToWrite' } : failureText(failure, lang)
165
+ }
95
166
 
96
167
  /** What is still missing for this member, in order (MyState and its known needs). */
97
- function stepsOf(me: MyState, needs: readonly string[]): OnboardingStep[] {
168
+ function stepsOf(me: MyState, needs: readonly string[], offerArt9: boolean, forceArt9: boolean): OnboardingStep[] {
98
169
  const steps: OnboardingStep[] = []
99
170
  if (!me.member?.ageBand) steps.push('age')
100
171
  if (me.acceptances.rules !== 'accepted') steps.push('rules')
101
- if (needs.includes('art9_consent')) steps.push('art9')
172
+ const art9 = me.acceptances.art9Consent
173
+ // A forum write: the consent, unless it is in force or refused where a refusal leaves public writes (kb-api's needs).
174
+ const refusedOpen = me.acceptances.art9Refused === true && art9RefusalOf(me.settings?.art9Refusal) === 'public_write'
175
+ const forced = forceArt9 && art9 !== 'accepted' && !refusedOpen
176
+ if (needs.includes('art9_consent') || (offerArt9 && art9 !== null && art9 !== 'accepted') || forced) steps.push('art9')
102
177
  if (!me.profile) steps.push('profile')
103
178
  return steps
104
179
  }
105
180
 
181
+ /** The consents the step asks for: the text's two controllers under `split`, else one (its first, or the tenant). */
182
+ function consentsOf(text: CurrentLegalText | null, layout: Art9ConsentLayout): Array<{ entity: string; label: string }> {
183
+ if (!text) return []
184
+ const listed = art9ControllersOf(text.bodyJson)
185
+ if (layout === 'split' && listed.length > 1) return listed.map((c) => ({ entity: c.entity, label: c.label }))
186
+ return [{ entity: listed[0]?.entity ?? '', label: listed.length > 1 ? '' : (listed[0]?.label ?? '') }]
187
+ }
188
+
106
189
  /** The texts of the steps shown. */
107
190
  interface Texts {
108
191
  rules: CurrentLegalText | null
@@ -118,6 +201,8 @@ interface Read {
118
201
 
119
202
  export function createOnboarding(o: OnboardingOptions): OnboardingModel {
120
203
  const call: CommunityCallOptions = o.signal ? { signal: o.signal } : {}
204
+ const offerArt9 = o.offerArt9 === true
205
+ const forceArt9 = o.forceArt9 === true && !offerArt9
121
206
  const state = reactive<OnboardingState>({
122
207
  phase: 'loading',
123
208
  steps: [],
@@ -127,10 +212,21 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
127
212
  rules: [],
128
213
  art9Text: null,
129
214
  art9Paragraphs: [],
215
+ art9Layout: 'joint',
216
+ art9Refusal: 'read_only',
217
+ art9Controllers: [],
218
+ art9Refused: false,
219
+ art9RefusedSent: false,
130
220
  reconsent: false,
131
221
  ageBand: null,
132
222
  rulesAccepted: false,
133
- art9Accepted: false,
223
+ // Read through the reactive proxy (`this`), so a view's v-model ticks every consent and the guard sees them all.
224
+ get art9Accepted(): boolean {
225
+ return this.art9Controllers.length > 0 && this.art9Controllers.every((c) => c.accepted)
226
+ },
227
+ set art9Accepted(value: boolean) {
228
+ for (const c of this.art9Controllers) c.accepted = value
229
+ },
134
230
  displayName: '',
135
231
  handle: '',
136
232
  avatarPreset: null,
@@ -171,15 +267,27 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
171
267
  o.onSignIn()
172
268
  return null
173
269
  }
174
- const steps = stepsOf(me.value, needs)
270
+ const steps = stepsOf(me.value, needs, offerArt9, forceArt9)
175
271
  const texts = await textsFor(steps)
176
272
  return texts ? { me: me.value, steps, texts } : null
177
273
  }
178
274
 
275
+ /** The refusal is offered: the tenant takes it (`public_write`), and this is not the give-consent flow. */
276
+ const refusalOffered = (): boolean => !offerArt9 && state.art9Refusal === 'public_write'
277
+
179
278
  /** What was read, shown at once; an acceptance holds only for the very text it was given to. */
180
279
  function apply(r: Read): void {
181
280
  if (r.texts.rules?.textId !== state.rulesText?.textId) state.rulesAccepted = false
182
- if (r.texts.art9?.textId !== state.art9Text?.textId) state.art9Accepted = false
281
+ const layout = art9ConsentLayoutOf(r.me.settings?.art9ConsentLayout)
282
+ const sameText = r.texts.art9 !== null && r.texts.art9.textId === state.art9Text?.textId
283
+ // A tick holds for the same controller of the same text; anything else is asked again.
284
+ state.art9Controllers = consentsOf(r.texts.art9, layout).map((c, i) => {
285
+ const before = state.art9Controllers[i]
286
+ return { ...c, accepted: sameText && before?.entity === c.entity && before.accepted === true }
287
+ })
288
+ state.art9Layout = layout
289
+ state.art9Refusal = art9RefusalOf(r.me.settings?.art9Refusal)
290
+ if (!sameText || !refusalOffered()) state.art9Refused = false
183
291
  state.me = r.me
184
292
  state.steps = r.steps
185
293
  state.reconsent = r.me.acceptances.rules === 'reconsent_required'
@@ -196,8 +304,17 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
196
304
  if (!r) return
197
305
  apply(r)
198
306
  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()
307
+ if (r.steps.length === 0) {
308
+ // The give-consent flow with no consent left to ask (given in another tab, or the tenant stopped asking for it):
309
+ // nothing is sent — an empty me/onboard would be no consent — and the caller reads the state it is handed.
310
+ if (offerArt9) {
311
+ state.phase = 'done'
312
+ o.onDone(r.me)
313
+ return
314
+ }
315
+ // Nothing left to ask (another tab finished it): kb-api's answer settles the needs.
316
+ return submit()
317
+ }
201
318
  state.phase = 'ready'
202
319
  }
203
320
 
@@ -211,7 +328,7 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
211
328
  case 'rules':
212
329
  return state.rulesAccepted && state.rulesText !== null
213
330
  case 'art9':
214
- return state.art9Accepted && state.art9Text !== null
331
+ return state.art9Text !== null && (state.art9Accepted || (state.art9Refused && refusalOffered()))
215
332
  case 'profile':
216
333
  return isDisplayNameOk(state.displayName) && isHandleOk(state.handle)
217
334
  }
@@ -219,7 +336,11 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
219
336
 
220
337
  function canContinue(): boolean {
221
338
  const s = step()
222
- return state.phase === 'ready' && s !== null && answered(s)
339
+ if (state.phase !== 'ready' || s === null) return false
340
+ // „Pokračovat" on the art. 9 step means the consent: a refusal goes only through „Nesouhlasím" (refuse()), never
341
+ // silently through the primary button.
342
+ if (s === 'art9') return state.art9Text !== null && state.art9Accepted
343
+ return answered(s)
223
344
  }
224
345
 
225
346
  function goTo(s: OnboardingStep): void {
@@ -246,7 +367,15 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
246
367
  ageBand: state.ageBand ?? state.me?.member?.ageBand ?? '18_plus',
247
368
  rulesTextId: state.steps.includes('rules') && state.rulesText ? state.rulesText.textId : '',
248
369
  }
249
- if (state.steps.includes('art9') && state.art9Text) input.art9TextId = state.art9Text.textId
370
+ // A consent ticked wins over a refusal chosen before (the reader came back and ticked it).
371
+ let refusing = false
372
+ if (state.steps.includes('art9') && state.art9Text) {
373
+ if (state.art9Accepted) input.art9TextId = state.art9Text.textId
374
+ else if (state.art9Refused && refusalOffered()) {
375
+ input.art9Refused = true
376
+ refusing = true
377
+ }
378
+ }
250
379
  if (state.steps.includes('profile')) {
251
380
  input.displayName = normalizeName(state.displayName)
252
381
  const handle = normalizeHandle(state.handle)
@@ -255,6 +384,7 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
255
384
  }
256
385
  const r = await o.client.me.onboard(input, call)
257
386
  if (r.ok) {
387
+ state.art9RefusedSent = refusing
258
388
  state.phase = 'done'
259
389
  o.onDone(r.value)
260
390
  return
@@ -276,6 +406,16 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
276
406
  return goTo('profile')
277
407
  case 'invalid': {
278
408
  state.fields = r.fields ?? []
409
+ // The tenant takes no refusal (now): the settings are read again, the consent asked, the reader told why.
410
+ if (refusalRefused(r)) {
411
+ state.art9Refused = false
412
+ return reread(r)
413
+ }
414
+ // A refusal kb-api did not take because a consent is in force already (another tab): read again.
415
+ if (state.fields.includes('art9Refused')) {
416
+ state.art9Refused = false
417
+ return reread(r)
418
+ }
279
419
  if (state.fields.some((f) => PROFILE_FIELDS.has(f))) return goTo('profile')
280
420
  if (state.fields.some((f) => TEXT_FIELDS.has(f))) return reread(r)
281
421
  if (state.fields.includes('ageBand')) return goTo('age')
@@ -319,6 +459,18 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
319
459
  fail(refusal)
320
460
  }
321
461
 
462
+ /** On to the next step, or — on the last one — `me/onboard`. */
463
+ async function advance(): Promise<void> {
464
+ state.failure = null
465
+ if (state.index < state.steps.length - 1) {
466
+ state.index += 1
467
+ return
468
+ }
469
+ await submit()
470
+ }
471
+
472
+ const canRefuse = (): boolean => state.phase === 'ready' && step() === 'art9' && state.art9Text !== null && refusalOffered()
473
+
322
474
  return {
323
475
  state,
324
476
  load,
@@ -327,17 +479,24 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
327
479
  canContinue,
328
480
  async next() {
329
481
  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()
482
+ // The consent given: a refusal chosen before on this step no longer stands.
483
+ if (step() === 'art9' && state.art9Accepted) state.art9Refused = false
484
+ await advance()
485
+ },
486
+ canRefuse,
487
+ async refuse() {
488
+ if (!canRefuse()) return
489
+ state.art9Accepted = false
490
+ state.art9Refused = true
491
+ await advance()
336
492
  },
493
+ refusalOffered,
337
494
  back() {
338
495
  if (state.phase !== 'ready' || state.index === 0) return
339
496
  state.failure = null
340
497
  state.index -= 1
498
+ // Back on the art. 9 step: the reader chooses again — a refusal chosen before does not stand unseen.
499
+ if (step() === 'art9') state.art9Refused = false
341
500
  },
342
501
  suggestedHandle: () => (normalizeHandle(state.handle) === '' ? suggestedHandle(state.displayName) : null),
343
502
  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,27 @@ 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',
81
+ // M3 (m3-contracts.md §1.2): the forum is not open to this viewer — read again, and the read's answer is the
82
+ // members-only state with its sign-in or onboarding (`needs`); no write of a topic answers it (a topic id's refusals
83
+ // are its `notFound`, A11).
84
+ membersOnly: 'reload',
85
+ // The topic was closed meanwhile: read again — the topic's state closes the composer (`error.topicLocked` until then).
86
+ topicLocked: 'reload',
87
+ // A category that takes no topic from the member: the sentence, and the category's composer goes.
88
+ topicCreationClosed: 'show',
89
+ // Following as much as one member may: the sentence (the member unfollows something first).
90
+ followLimit: 'show',
69
91
  submit: 'show',
70
92
  } satisfies Record<CommunityUiCode, FailureEffect>)
71
93
 
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,155 @@
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 ForumCategoryRef,
22
+ type KnownAuthor,
23
+ type PublicProfile,
24
+ } from '@blogmcp/sdk/community'
25
+
26
+ export type LoadPhase = 'idle' | 'loading' | 'ready' | 'failed' | 'signIn' | 'notFound'
27
+
28
+ export interface ProfileUi {
29
+ phase: LoadPhase
30
+ failure: CommunityFailure | null
31
+ author: KnownAuthor | null
32
+ memberSince: string | null
33
+ bio: string | null
34
+ /** The trust level's catalogue key (`trust.<n>`), or null for a label this SDK does not know. */
35
+ trustKey: CommunityMessageKey | null
36
+ /**
37
+ * M3 (m3-contracts.md §3.5, §4.5; D-M3-7): the categories the member moderates now — „Moderátor" names this
38
+ * appointment on the profile, never a trust level; kb-api sends it to a forum member only (else none).
39
+ */
40
+ moderates: ForumCategoryRef[]
41
+ }
42
+
43
+ export interface ProfileController {
44
+ readonly state: ProfileUi
45
+ load(handle: string): Promise<void>
46
+ dispose(): void
47
+ }
48
+
49
+ /** A handle as an attribute may give it (`@jana_n`, `Jana_N`): lower case, without the `@`; null for none. */
50
+ export function handleOf(value: unknown): string | null {
51
+ if (typeof value !== 'string') return null
52
+ const h = value.trim().replace(/^@/, '').toLowerCase()
53
+ return COMMUNITY_HANDLE_RE.test(h) ? h : null
54
+ }
55
+
56
+ export function createProfile(o: { client: CommunityClient }): ProfileController {
57
+ const { client } = o
58
+ const abort = new AbortController()
59
+ const call: CommunityCallOptions = { signal: abort.signal }
60
+ let disposed = false
61
+ let ticket = 0
62
+ const state = reactive<ProfileUi>({ phase: 'idle', failure: null, author: null, memberSince: null, bio: null, trustKey: null, moderates: [] }) as ProfileUi
63
+
64
+ return {
65
+ state,
66
+ async load(handle) {
67
+ const mine = ++ticket
68
+ state.failure = null
69
+ state.author = null
70
+ state.memberSince = null
71
+ state.bio = null
72
+ state.trustKey = null
73
+ state.moderates = []
74
+ const h = handleOf(handle)
75
+ if (h === null) {
76
+ state.phase = 'notFound'
77
+ return
78
+ }
79
+ state.phase = 'loading'
80
+ const r = await client.profile.get({ handle: h }, call)
81
+ if (disposed || mine !== ticket) return
82
+ if (!r.ok) {
83
+ state.phase = r.code === 'signIn' ? 'signIn' : r.code === 'notFound' ? 'notFound' : 'failed'
84
+ state.failure = r
85
+ return
86
+ }
87
+ const p: PublicProfile = r.value
88
+ // A profile is a member's; an author of another kind (or one a newer kb-api added) is no profile to show.
89
+ if (!isKnownAuthor(p.author) || p.author.kind !== 'member' || p.author.memberId === null) {
90
+ state.phase = 'notFound'
91
+ return
92
+ }
93
+ state.author = p.author
94
+ state.memberSince = typeof p.memberSince === 'string' ? p.memberSince : null
95
+ state.bio = typeof p.bio === 'string' && p.bio.trim() !== '' ? p.bio : null
96
+ state.trustKey = trustLabelMessageKey(p.trustLabel)
97
+ // M3: the appointments acting now (a category's slug and name each; anything else is skipped).
98
+ state.moderates = Array.isArray(p.moderates)
99
+ ? p.moderates.filter((c): c is ForumCategoryRef => typeof c?.slug === 'string' && typeof c?.name === 'string').map((c) => ({ slug: c.slug, name: c.name }))
100
+ : []
101
+ state.phase = 'ready'
102
+ },
103
+ dispose() {
104
+ disposed = true
105
+ abort.abort()
106
+ },
107
+ }
108
+ }
109
+
110
+ export interface WelcomeUi {
111
+ phase: 'idle' | 'loading' | 'ready'
112
+ rules: CommunityRule[]
113
+ /** The rules could not be read: the page still links them. */
114
+ rulesFailed: boolean
115
+ /** The signed-in member's handle (the profile link), or null for a visitor and a reader who never joined. */
116
+ handle: string | null
117
+ /** Nobody is signed in: the sign-in link takes the profile link's place. */
118
+ visitor: boolean
119
+ }
120
+
121
+ export interface WelcomeController {
122
+ readonly state: WelcomeUi
123
+ load(): Promise<void>
124
+ dispose(): void
125
+ }
126
+
127
+ export function createWelcome(o: { client: CommunityClient; lang: string }): WelcomeController {
128
+ const { client } = o
129
+ const abort = new AbortController()
130
+ const call: CommunityCallOptions = { signal: abort.signal }
131
+ let disposed = false
132
+ const state = reactive<WelcomeUi>({ phase: 'idle', rules: [], rulesFailed: false, handle: null, visitor: false }) as WelcomeUi
133
+
134
+ return {
135
+ state,
136
+ async load() {
137
+ state.phase = 'loading'
138
+ const [rules, me] = await Promise.all([client.legal.current({ kind: 'rules', locale: o.lang }, call), client.me.get(call)])
139
+ if (disposed) return
140
+ if (rules.ok) state.rules = communityRulesOf(rules.value.bodyJson)
141
+ else state.rulesFailed = true
142
+ if (me.ok) {
143
+ state.visitor = me.value.needs.includes('sign_in')
144
+ state.handle = me.value.member ? handleOf(me.value.profile?.handle) : null
145
+ } else {
146
+ state.visitor = me.code === 'signIn'
147
+ }
148
+ state.phase = 'ready'
149
+ },
150
+ dispose() {
151
+ disposed = true
152
+ abort.abort()
153
+ },
154
+ }
155
+ }