@blogmcp/community 0.0.0-stage → 0.1.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-DzjwnHyx.js +4597 -0
  5. package/dist/core/attrs.d.ts +73 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/client.d.ts +27 -0
  8. package/dist/core/discussion.d.ts +144 -0
  9. package/dist/core/download.d.ts +4 -0
  10. package/dist/core/format.d.ts +17 -0
  11. package/dist/core/i18n.d.ts +28 -0
  12. package/dist/core/names.d.ts +15 -0
  13. package/dist/core/notice.d.ts +50 -0
  14. package/dist/core/onboarding.d.ts +52 -0
  15. package/dist/core/outcomes.d.ts +28 -0
  16. package/dist/core/panel.d.ts +80 -0
  17. package/dist/core/receipt.d.ts +5 -0
  18. package/dist/core/text.d.ts +22 -0
  19. package/dist/core/turnstile.d.ts +33 -0
  20. package/dist/element.d.ts +26 -0
  21. package/dist/global.d.ts +8 -0
  22. package/dist/index.d.ts +25 -0
  23. package/dist/index.js +22 -0
  24. package/dist/mount.d.ts +65 -0
  25. package/dist/react.d.ts +17 -0
  26. package/dist/react.js +35 -0
  27. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  28. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  29. package/dist/ui/DiscussionView.vue.d.ts +33 -0
  30. package/dist/ui/LinkedText.vue.d.ts +5 -0
  31. package/dist/ui/MessageComposer.vue.d.ts +24 -0
  32. package/dist/ui/MessageItem.vue.d.ts +6 -0
  33. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  34. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  35. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  36. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  37. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  38. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  39. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  40. package/dist/ui/StatementView.vue.d.ts +15 -0
  41. package/dist/ui/context.d.ts +21 -0
  42. package/dist/ui/discussion-context.d.ts +19 -0
  43. package/dist/ui/types.d.ts +7 -0
  44. package/dist/version.d.ts +1 -0
  45. package/dist/vue.d.ts +89 -0
  46. package/dist/vue.js +97 -0
  47. package/package.json +80 -4
  48. package/src/core/attrs.ts +147 -0
  49. package/src/core/avatars.ts +44 -0
  50. package/src/core/client.ts +54 -0
  51. package/src/core/discussion.ts +691 -0
  52. package/src/core/download.ts +21 -0
  53. package/src/core/format.ts +90 -0
  54. package/src/core/i18n.ts +49 -0
  55. package/src/core/names.ts +51 -0
  56. package/src/core/notice.ts +178 -0
  57. package/src/core/onboarding.ts +345 -0
  58. package/src/core/outcomes.ts +74 -0
  59. package/src/core/panel.ts +411 -0
  60. package/src/core/receipt.ts +28 -0
  61. package/src/core/text.ts +142 -0
  62. package/src/core/turnstile.ts +98 -0
  63. package/src/element.ts +109 -0
  64. package/src/global.ts +12 -0
  65. package/src/index.ts +35 -0
  66. package/src/mount.ts +127 -0
  67. package/src/react.ts +53 -0
  68. package/src/shims.d.ts +11 -0
  69. package/src/ui/AuthorAvatar.vue +26 -0
  70. package/src/ui/ConfirmDialog.vue +38 -0
  71. package/src/ui/DiscussionView.vue +294 -0
  72. package/src/ui/LinkedText.vue +18 -0
  73. package/src/ui/MessageComposer.vue +130 -0
  74. package/src/ui/MessageItem.vue +205 -0
  75. package/src/ui/MessageMenu.vue +67 -0
  76. package/src/ui/MyCommunityView.vue +369 -0
  77. package/src/ui/NoticeFormView.vue +244 -0
  78. package/src/ui/NoticeStatusView.vue +82 -0
  79. package/src/ui/OnboardingSteps.vue +163 -0
  80. package/src/ui/ReactionBar.vue +41 -0
  81. package/src/ui/ReportDialog.vue +88 -0
  82. package/src/ui/StatementView.vue +141 -0
  83. package/src/ui/context.ts +75 -0
  84. package/src/ui/discussion-context.ts +30 -0
  85. package/src/ui/styles.css +243 -0
  86. package/src/ui/types.ts +7 -0
  87. package/src/version.ts +3 -0
  88. package/src/vue.ts +116 -0
@@ -0,0 +1,345 @@
1
+ /**
2
+ * The light onboarding inside the discussion (m1-contracts.md §7.4.2, §10.4; community.md §4.4–§4.6): what a
3
+ * reader does once before their first comment, and again when the tenant's rules change or it starts to ask
4
+ * for the art. 9 consent. In order, only the steps still missing:
5
+ *
6
+ * age the age band, asked once and never offered again (`16_17`, `18_plus`); below the tenant's
7
+ * minimum kb-api stores nothing and the reader is told from what age the discussion is open;
8
+ * rules the rules' summary (`legal/current` → `bodyJson`, text only) with a link to the full text, and
9
+ * the acceptance;
10
+ * art9 the art. 9 consent's text and the acceptance, when the tenant asks for it;
11
+ * profile the display name, the handle (kb-api makes one when none is given; the element shows what it
12
+ * would be), an avatar preset.
13
+ *
14
+ * One `me/onboard` at the end carries them all; kb-api records only what is still missing, so a member who
15
+ * comes back for a newer rules text sends the band and the profile they already have untouched.
16
+ *
17
+ * kb-api records the acceptance of whatever CURRENT text id it is sent (§7.4.2), so a text id goes only for a text
18
+ * this model showed and the reader ticked: the rules text when the rules are a step, else none. A text published
19
+ * meanwhile, or the art. 9 consent switched on meanwhile, is kb-api's VALIDATION on that id: the steps are read
20
+ * again from me/get (a step may be new), the reader goes to the first one left, nothing goes until it is answered.
21
+ */
22
+ import { reactive } from 'vue'
23
+ import {
24
+ communityRulesOf,
25
+ isKnownNeed,
26
+ type AgeBand,
27
+ type CommunityCallOptions,
28
+ type CommunityClient,
29
+ type CommunityFailure,
30
+ type CommunityRule,
31
+ type CurrentLegalText,
32
+ type MyState,
33
+ type OnboardInput,
34
+ } from '@blogmcp/sdk/community'
35
+ import type { Lang } from './i18n'
36
+ import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName, suggestedHandle } from './names'
37
+ import { htmlToParagraphs } from './text'
38
+
39
+ export type OnboardingStep = 'age' | 'rules' | 'art9' | 'profile'
40
+
41
+ export type OnboardingPhase = 'loading' | 'ready' | 'sending' | 'done' | 'failed' | 'refused'
42
+
43
+ export interface OnboardingState {
44
+ /** `refused`: the age band is below the minimum — nothing was stored, the reader may go on reading. */
45
+ phase: OnboardingPhase
46
+ steps: OnboardingStep[]
47
+ index: number
48
+ me: MyState | null
49
+ rulesText: CurrentLegalText | null
50
+ rules: CommunityRule[]
51
+ art9Text: CurrentLegalText | null
52
+ art9Paragraphs: string[]
53
+ /** The member accepted an older rules text; this one asks again. */
54
+ reconsent: boolean
55
+ ageBand: AgeBand | null
56
+ rulesAccepted: boolean
57
+ art9Accepted: boolean
58
+ displayName: string
59
+ handle: string
60
+ avatarPreset: string | null
61
+ failure: CommunityFailure | null
62
+ /** The fields a refusal named (`displayName`, `handle`, …). */
63
+ fields: string[]
64
+ /** The minimum age, once a refusal told it (a refused `16_17` means 18). */
65
+ minAge: number | null
66
+ }
67
+
68
+ export interface OnboardingModel {
69
+ readonly state: OnboardingState
70
+ load(): Promise<void>
71
+ step(): OnboardingStep | null
72
+ isLast(): boolean
73
+ canContinue(): boolean
74
+ /** The next step, or — on the last one — `me/onboard`. */
75
+ next(): Promise<void>
76
+ back(): void
77
+ suggestedHandle(): string | null
78
+ hasFieldError(field: string): boolean
79
+ }
80
+
81
+ export interface OnboardingOptions {
82
+ client: Pick<CommunityClient, 'me' | 'legal'>
83
+ lang: Lang
84
+ /** Called with the member's state once kb-api took the onboarding. */
85
+ onDone(state: MyState): void
86
+ /** The session cannot act as a member any more (signed out meanwhile). */
87
+ onSignIn(): void
88
+ /** The age band was below the tenant's minimum (AGE_BELOW_MINIMUM): kb-api kept nothing, the element keeps this. */
89
+ onRefused?(minAge: number): void
90
+ signal?: AbortSignal
91
+ }
92
+
93
+ const PROFILE_FIELDS = new Set(['displayName', 'handle', 'avatarPreset'])
94
+ const TEXT_FIELDS = new Set(['rulesTextId', 'art9TextId'])
95
+
96
+ /** What is still missing for this member, in order (MyState and its known needs). */
97
+ function stepsOf(me: MyState, needs: readonly string[]): OnboardingStep[] {
98
+ const steps: OnboardingStep[] = []
99
+ if (!me.member?.ageBand) steps.push('age')
100
+ if (me.acceptances.rules !== 'accepted') steps.push('rules')
101
+ if (needs.includes('art9_consent')) steps.push('art9')
102
+ if (!me.profile) steps.push('profile')
103
+ return steps
104
+ }
105
+
106
+ /** The texts of the steps shown. */
107
+ interface Texts {
108
+ rules: CurrentLegalText | null
109
+ art9: CurrentLegalText | null
110
+ }
111
+
112
+ /** One reading of what is left to ask: me/get, its steps, their texts. */
113
+ interface Read {
114
+ me: MyState
115
+ steps: OnboardingStep[]
116
+ texts: Texts
117
+ }
118
+
119
+ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
120
+ const call: CommunityCallOptions = o.signal ? { signal: o.signal } : {}
121
+ const state = reactive<OnboardingState>({
122
+ phase: 'loading',
123
+ steps: [],
124
+ index: 0,
125
+ me: null,
126
+ rulesText: null,
127
+ rules: [],
128
+ art9Text: null,
129
+ art9Paragraphs: [],
130
+ reconsent: false,
131
+ ageBand: null,
132
+ rulesAccepted: false,
133
+ art9Accepted: false,
134
+ displayName: '',
135
+ handle: '',
136
+ avatarPreset: null,
137
+ failure: null,
138
+ fields: [],
139
+ minAge: null,
140
+ }) as OnboardingState
141
+
142
+ const fail = (failure: CommunityFailure): void => {
143
+ state.failure = failure
144
+ state.phase = 'failed'
145
+ }
146
+
147
+ /** The texts of these steps — none for a step that is not one, so its id can never be sent; null when one fails. */
148
+ async function textsFor(steps: OnboardingStep[]): Promise<Texts | null> {
149
+ const [rules, art9] = await Promise.all([
150
+ steps.includes('rules') ? o.client.legal.current({ kind: 'rules', locale: o.lang }, call) : null,
151
+ steps.includes('art9') ? o.client.legal.current({ kind: 'art9_consent', locale: o.lang }, call) : null,
152
+ ])
153
+ for (const r of [rules, art9]) {
154
+ if (r && !r.ok) {
155
+ fail(r)
156
+ return null
157
+ }
158
+ }
159
+ return { rules: rules?.ok ? rules.value : null, art9: art9?.ok ? art9.value : null }
160
+ }
161
+
162
+ /** me/get and what it leaves to ask, with the texts; null when it went elsewhere (a failure, a signed-out session). */
163
+ async function read(): Promise<Read | null> {
164
+ const me = await o.client.me.get(call)
165
+ if (!me.ok) {
166
+ fail(me)
167
+ return null
168
+ }
169
+ const needs = me.value.needs.filter(isKnownNeed)
170
+ if (needs.includes('sign_in')) {
171
+ o.onSignIn()
172
+ return null
173
+ }
174
+ const steps = stepsOf(me.value, needs)
175
+ const texts = await textsFor(steps)
176
+ return texts ? { me: me.value, steps, texts } : null
177
+ }
178
+
179
+ /** What was read, shown at once; an acceptance holds only for the very text it was given to. */
180
+ function apply(r: Read): void {
181
+ if (r.texts.rules?.textId !== state.rulesText?.textId) state.rulesAccepted = false
182
+ if (r.texts.art9?.textId !== state.art9Text?.textId) state.art9Accepted = false
183
+ state.me = r.me
184
+ state.steps = r.steps
185
+ state.reconsent = r.me.acceptances.rules === 'reconsent_required'
186
+ state.rulesText = r.texts.rules
187
+ state.rules = r.texts.rules ? communityRulesOf(r.texts.rules.bodyJson) : []
188
+ state.art9Text = r.texts.art9
189
+ state.art9Paragraphs = r.texts.art9 ? htmlToParagraphs(r.texts.art9.body) : []
190
+ }
191
+
192
+ async function load(): Promise<void> {
193
+ state.phase = 'loading'
194
+ state.failure = null
195
+ const r = await read()
196
+ if (!r) return
197
+ apply(r)
198
+ 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()
201
+ state.phase = 'ready'
202
+ }
203
+
204
+ const step = (): OnboardingStep | null => state.steps[state.index] ?? null
205
+
206
+ /** Whether a step has its answer (a text accepted is the text shown). */
207
+ function answered(s: OnboardingStep): boolean {
208
+ switch (s) {
209
+ case 'age':
210
+ return state.ageBand !== null
211
+ case 'rules':
212
+ return state.rulesAccepted && state.rulesText !== null
213
+ case 'art9':
214
+ return state.art9Accepted && state.art9Text !== null
215
+ case 'profile':
216
+ return isDisplayNameOk(state.displayName) && isHandleOk(state.handle)
217
+ }
218
+ }
219
+
220
+ function canContinue(): boolean {
221
+ const s = step()
222
+ return state.phase === 'ready' && s !== null && answered(s)
223
+ }
224
+
225
+ function goTo(s: OnboardingStep): void {
226
+ const i = state.steps.indexOf(s)
227
+ if (i >= 0) state.index = i
228
+ }
229
+
230
+ /** The first step without its answer, or -1. */
231
+ const firstOpen = (): number => state.steps.findIndex((s) => !answered(s))
232
+
233
+ async function submit(): Promise<void> {
234
+ // Every step shown has its answer before anything goes (a step a refusal added is still open).
235
+ const open = firstOpen()
236
+ if (open >= 0) {
237
+ state.index = open
238
+ return
239
+ }
240
+ state.phase = 'sending'
241
+ state.failure = null
242
+ state.fields = []
243
+ // kb-api records whatever current text id it gets: only the ones shown and ticked here go. The field itself is
244
+ // kb-api's schema's; empty is no text (checked only while the rules are still to accept, and then refused).
245
+ const input: OnboardInput = {
246
+ ageBand: state.ageBand ?? state.me?.member?.ageBand ?? '18_plus',
247
+ rulesTextId: state.steps.includes('rules') && state.rulesText ? state.rulesText.textId : '',
248
+ }
249
+ if (state.steps.includes('art9') && state.art9Text) input.art9TextId = state.art9Text.textId
250
+ if (state.steps.includes('profile')) {
251
+ input.displayName = normalizeName(state.displayName)
252
+ const handle = normalizeHandle(state.handle)
253
+ if (handle !== '') input.handle = handle
254
+ input.avatarPreset = state.avatarPreset
255
+ }
256
+ const r = await o.client.me.onboard(input, call)
257
+ if (r.ok) {
258
+ state.phase = 'done'
259
+ o.onDone(r.value)
260
+ return
261
+ }
262
+ state.failure = r
263
+ state.phase = 'ready'
264
+ switch (r.code) {
265
+ case 'age':
266
+ // Two bands only: a refused band is 16–17 under an 18 minimum.
267
+ state.minAge = 18
268
+ state.phase = 'refused'
269
+ o.onRefused?.(state.minAge)
270
+ return
271
+ case 'name':
272
+ state.fields = normalizeHandle(state.handle) !== '' ? ['displayName', 'handle'] : ['displayName']
273
+ return goTo('profile')
274
+ case 'handle':
275
+ state.fields = ['handle']
276
+ return goTo('profile')
277
+ case 'invalid': {
278
+ state.fields = r.fields ?? []
279
+ if (state.fields.some((f) => PROFILE_FIELDS.has(f))) return goTo('profile')
280
+ if (state.fields.some((f) => TEXT_FIELDS.has(f))) return reread(r)
281
+ if (state.fields.includes('ageBand')) return goTo('age')
282
+ return
283
+ }
284
+ case 'signIn':
285
+ return o.onSignIn()
286
+ default:
287
+ return
288
+ }
289
+ }
290
+
291
+ /**
292
+ * kb-api refused a text id: a newer text was published meanwhile, or the art. 9 consent was switched on. What is
293
+ * left is read again from me/get — a step may be new, with its text —, the acceptance of a text kb-api named is
294
+ * asked again, and the reader goes to the first step left. Nothing is sent again by itself.
295
+ */
296
+ async function reread(refusal: CommunityFailure): Promise<void> {
297
+ const named = [...state.fields]
298
+ // The form stays as it is (disabled) while the steps are read again.
299
+ state.phase = 'sending'
300
+ const r = await read()
301
+ if (!r) return
302
+ apply(r)
303
+ if (named.includes('rulesTextId')) state.rulesAccepted = false
304
+ if (named.includes('art9TextId')) state.art9Accepted = false
305
+ state.failure = refusal
306
+ state.fields = named
307
+ const open = firstOpen()
308
+ if (open >= 0) {
309
+ state.index = open
310
+ state.phase = 'ready'
311
+ return
312
+ }
313
+ // Nothing this model can still ask: finished elsewhere (no need left), or a refusal it cannot answer — shown.
314
+ if (r.me.needs.filter(isKnownNeed).length === 0) {
315
+ state.phase = 'done'
316
+ o.onDone(r.me)
317
+ return
318
+ }
319
+ fail(refusal)
320
+ }
321
+
322
+ return {
323
+ state,
324
+ load,
325
+ step,
326
+ isLast: () => state.index >= state.steps.length - 1,
327
+ canContinue,
328
+ async next() {
329
+ 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()
336
+ },
337
+ back() {
338
+ if (state.phase !== 'ready' || state.index === 0) return
339
+ state.failure = null
340
+ state.index -= 1
341
+ },
342
+ suggestedHandle: () => (normalizeHandle(state.handle) === '' ? suggestedHandle(state.displayName) : null),
343
+ hasFieldError: (field) => state.fields.includes(field),
344
+ }
345
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * How a refusal reads (m1-contracts.md §10.2, §10.4): the client turns every kb-api and host code into one
3
+ * of COMMUNITY_UI_CODES; this turns that into the catalogue's sentence — `error.<code>`, or a sanction's
4
+ * own sentence with its end date — and says what the element does about it besides showing it.
5
+ */
6
+ import type { CommunityFailure, CommunityMessageKey, CommunityUiCode } from '@blogmcp/sdk/community'
7
+ import { formatDate } from './format'
8
+ import type { Lang, TextRef } from './i18n'
9
+
10
+ /** The sentence a refusal shows. Total over COMMUNITY_UI_CODES (test/outcomes.spec.ts). */
11
+ export function failureText(failure: Pick<CommunityFailure, 'code' | 'until'>, lang: Lang): TextRef {
12
+ const until = failure.until ? formatDate(lang, failure.until) : ''
13
+ if (failure.code === 'restricted' && until) return { key: 'sanction.restrictionUntil', params: { date: until } }
14
+ if (failure.code === 'suspended' && until) return { key: 'sanction.suspensionUntil', params: { date: until } }
15
+ return { key: `error.${failure.code}` as CommunityMessageKey }
16
+ }
17
+
18
+ /** Seconds a refusal asks to wait before the same write may go (the burst, a quota, the limiter). */
19
+ export const DEFAULT_WAIT_S: Partial<Record<CommunityUiCode, number>> = Object.freeze({ fast: 3, quota: 60 })
20
+
21
+ /** The epoch milliseconds until which a write waits after `failure`, or null when it may go again at once. */
22
+ export function waitUntilOf(failure: Pick<CommunityFailure, 'code' | 'retryAfterS'>, now: number): number | null {
23
+ const fallback = DEFAULT_WAIT_S[failure.code]
24
+ if (fallback === undefined) return null
25
+ const seconds = typeof failure.retryAfterS === 'number' && failure.retryAfterS > 0 ? failure.retryAfterS : fallback
26
+ return now + seconds * 1000
27
+ }
28
+
29
+ /** A countdown shows only for a short wait (a quota until midnight reads `error.quota` alone). */
30
+ export const COUNTDOWN_MAX_S = 120
31
+
32
+ /**
33
+ * What the element does with a refusal of a write, besides showing its sentence:
34
+ * - `confirm` the author confirms the codes and sends again (the Russmedia confirmation);
35
+ * - `wait` the write waits (a cooldown);
36
+ * - `onboard` the inline onboarding opens (ONBOARDING_REQUIRED);
37
+ * - `reload` the discussion is read again (the viewer's state changed: a sanction, a session);
38
+ * - `notMoved` the discussion does not take writes yet: one sentence instead of the composer;
39
+ * - `hide` discussions are switched off: the element renders nothing;
40
+ * - `retry` nothing was decided (no answer, the ledger): the same write may go again as it is;
41
+ * - `show` anything else: the sentence, the text stays for the author to change.
42
+ */
43
+ export type FailureEffect = 'confirm' | 'wait' | 'onboard' | 'reload' | 'notMoved' | 'hide' | 'retry' | 'show'
44
+
45
+ const EFFECTS: Readonly<Record<CommunityUiCode, FailureEffect>> = Object.freeze({
46
+ method: 'show',
47
+ origin: 'show',
48
+ unavailable: 'retry',
49
+ invalid: 'show',
50
+ signIn: 'reload',
51
+ onboarding: 'onboard',
52
+ age: 'show',
53
+ name: 'show',
54
+ handle: 'show',
55
+ restricted: 'reload',
56
+ suspended: 'reload',
57
+ terminated: 'reload',
58
+ links: 'show',
59
+ fast: 'wait',
60
+ blocked: 'show',
61
+ confirm: 'confirm',
62
+ removed: 'reload',
63
+ quota: 'wait',
64
+ captcha: 'show',
65
+ notFound: 'reload',
66
+ surfaceOff: 'hide',
67
+ notMoved: 'notMoved',
68
+ ledger: 'retry',
69
+ submit: 'show',
70
+ } satisfies Record<CommunityUiCode, FailureEffect>)
71
+
72
+ export function failureEffect(code: CommunityUiCode): FailureEffect {
73
+ return EFFECTS[code] ?? 'show'
74
+ }