@blogmcp/community 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. package/src/vue.ts +242 -0
@@ -0,0 +1,876 @@
1
+ /**
2
+ * `<blogmcp-discussion>`'s brain, without any view (m1-contracts.md §10.4): the SDK's discussion store fed by
3
+ * `discussions/list` pages and the `discussions/head` poll, the composer (a new message, a reply, an edit)
4
+ * with its optimistic rows, the Russmedia confirmation and the cooldowns, reactions, blocks, reports, and the
5
+ * inline onboarding. The element renders `controller.state`; tests drive it directly over a fake transport.
6
+ *
7
+ * Every request goes through the SDK client (the host's relative base); nothing here builds a path.
8
+ */
9
+ import { reactive, shallowRef, type ShallowRef } from 'vue'
10
+ import {
11
+ isKnownModuleState,
12
+ COMMUNITY_MESSAGE_MAX,
13
+ COMMUNITY_REPORT_NOTE_MAX,
14
+ communityMentionHandles,
15
+ communityRulesOf,
16
+ createDiscussionStore,
17
+ findMessage,
18
+ isOwnMessage,
19
+ loadMoreDiscussion,
20
+ newClientMsgId,
21
+ pollDiscussionHead,
22
+ reactionAfter,
23
+ reactionsFor,
24
+ refreshDiscussion,
25
+ viewerNeeds,
26
+ type CaseCategory,
27
+ type CommunityCallOptions,
28
+ type CommunityClient,
29
+ type CommunityFailure,
30
+ type CommunityRule,
31
+ type DiscussionHead,
32
+ type DiscussionHeadPoller,
33
+ type DiscussionMessage,
34
+ type DiscussionState,
35
+ type DiscussionStore,
36
+ type KnownAuthor,
37
+ type MyState,
38
+ type ReactionTarget,
39
+ type ViewerNeeds,
40
+ } from '@blogmcp/sdk/community'
41
+ import { warnOnce } from './client'
42
+ import type { Lang, TextRef } from './i18n'
43
+ import { createOnboarding, type OnboardingModel } from './onboarding'
44
+ import { failureEffect, failureText, waitUntilOf } from './outcomes'
45
+ import type { DiscussionRealtime, DiscussionRealtimeSession } from './realtime'
46
+ import { codePointLength, normalizeBody } from './text'
47
+
48
+ export type DiscussionPhase = 'loading' | 'ready' | 'failed' | 'hidden'
49
+
50
+ /** One composer's text and what happened to it. */
51
+ export interface Draft {
52
+ body: string
53
+ sending: boolean
54
+ failure: CommunityFailure | null
55
+ /** CONFIRMATION_REQUIRED: the codes the author confirms before the same text goes again. */
56
+ confirm: { codes: string[]; checked: string[] } | null
57
+ /** A cooldown: the epoch milliseconds until which this write waits. */
58
+ waitUntil: number | null
59
+ /** A post's id, kept until kb-api took it: a retry of a write whose answer was lost cannot post twice. */
60
+ clientMsgId: string | null
61
+ }
62
+
63
+ export type InlineKind = 'reply' | 'edit'
64
+
65
+ /** The one reply or edit open under a message (opening another closes it). */
66
+ export interface InlineDraft extends Draft {
67
+ kind: InlineKind
68
+ /** The message edited, or the TOP-LEVEL message a reply goes under (two levels only). */
69
+ seq: number
70
+ /** A reply to a reply names whom it answers. */
71
+ replyingTo: string | null
72
+ }
73
+
74
+ export interface ReportDraft {
75
+ conversationId: string
76
+ seq: number
77
+ category: CaseCategory | ''
78
+ ruleId: string
79
+ note: string
80
+ rules: CommunityRule[] | null
81
+ rulesFailed: boolean
82
+ sending: boolean
83
+ failure: CommunityFailure | null
84
+ done: boolean
85
+ }
86
+
87
+ /**
88
+ * Where the composer stands: `write`; `signIn` (no session that can write); `onboarding` (the inline
89
+ * onboarding waits); `ageRefused` (the age band declared in it was below the minimum: reading only, the
90
+ * onboarding is not offered again); `sanction` (a restriction, suspension or ban in effect); `notMoved` (the
91
+ * discussion takes no writes yet — one sentence); `restricted` (the member is read-only or deleting their
92
+ * profile); `braked` (M2: the community is paused — `me/get`'s `module.brake`, or a write refused BRAKED: reading
93
+ * goes on, every member write waits — one sentence); `pending` (being decided).
94
+ *
95
+ * A module that is `read_only` changes nothing here (m2-contracts.md §3.1: the discussions under articles keep
96
+ * taking writes; only live updates, push and the Pro surfaces stop) — the discussion polls instead of a socket.
97
+ */
98
+ export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'braked' | 'pending'
99
+
100
+ export interface DiscussionUi {
101
+ phase: DiscussionPhase
102
+ /** Why the first load failed (`failed`). */
103
+ failure: CommunityFailure | null
104
+ thread: DiscussionState
105
+ loadingMore: boolean
106
+ refreshing: boolean
107
+ /** A head poll saw messages the page does not show yet. */
108
+ newMessages: boolean
109
+ me: MyState | null
110
+ /** What a write or the member's state taught: no writes yet, none for this member, or a pause (BRAKED). */
111
+ block: 'notMoved' | 'restricted' | 'braked' | null
112
+ composer: Draft
113
+ inline: InlineDraft | null
114
+ report: ReportDraft | null
115
+ /** The last outcome to announce (aria-live), and a counter so the same text is announced again. */
116
+ announcement: TextRef | null
117
+ announceId: number
118
+ /** Now, ticking while a cooldown runs. */
119
+ clock: number
120
+ /** Actions on their way, by key (`react:…`, `delete:<seq>`, `block:<memberId>`). */
121
+ busy: Record<string, boolean>
122
+ /**
123
+ * The minimum age a declared band fell below (AGE_BELOW_MINIMUM). kb-api keeps nothing of a refused band
124
+ * (§7.4.2), so the element does: the onboarding is not offered again while it lives.
125
+ */
126
+ refusedAge: number | null
127
+ /**
128
+ * M2 T9: the onboarding just finished with the art. 9 consent refused (`public_write`): the composer says once,
129
+ * in view, what the refusal leaves (writing in the public discussion). Not kept beyond this element.
130
+ */
131
+ art9RefusedNote: boolean
132
+ /**
133
+ * M2 (§3.5): the live updates' session fell back to polling (live updates are off here, or the server cut the
134
+ * socket for good — 4501): the discussion says so once and keeps polling; it never opens the socket again itself.
135
+ */
136
+ realtimeOff: boolean
137
+ }
138
+
139
+ export interface DiscussionControllerOptions {
140
+ client: CommunityClient
141
+ articleId: string
142
+ lang: Lang
143
+ /** Head poll interval (default 20 s; 0 = no polling). */
144
+ pollMs?: number
145
+ now?: () => number
146
+ /** The viewer's message was posted (`held`: it waits for review). */
147
+ onPosted?(state: 'visible' | 'held'): void
148
+ /** A refusal the element saw before (a discussion started again in another language keeps it). */
149
+ refusedAge?: number | null
150
+ /** The onboarding's age band was refused: the element keeps it for its next discussions. */
151
+ onAgeRefused?(minAge: number): void
152
+ /**
153
+ * M2 (§3.5, §3.10): the host's live-updates session factory (`createRealtimeSession` of
154
+ * `@blogmcp/sdk/community/realtime`). Absent: the head poll, as before.
155
+ */
156
+ realtime?: DiscussionRealtime | null
157
+ }
158
+
159
+ export interface DiscussionController {
160
+ readonly state: DiscussionUi
161
+ readonly store: DiscussionStore
162
+ readonly onboarding: ShallowRef<OnboardingModel | null>
163
+ /** The first load, then the poll. */
164
+ start(): Promise<void>
165
+ refresh(): Promise<void>
166
+ loadMore(): Promise<void>
167
+ mode(): ComposerMode
168
+ needs(): ViewerNeeds
169
+ isOwn(m: Pick<DiscussionMessage, 'author'>): boolean
170
+ canReply(m: DiscussionMessage): boolean
171
+ canEdit(m: DiscussionMessage): boolean
172
+ canDelete(m: DiscussionMessage): boolean
173
+ canReact(): boolean
174
+ /** A member's report (`messages/report`): any signed-in reader, on someone else's message. */
175
+ canReport(m: DiscussionMessage): boolean
176
+ /** A reader without a session reports through the host's notice page instead (reportLink). */
177
+ canReportAsNotifier(m: DiscussionMessage): boolean
178
+ canBlock(m: DiscussionMessage): boolean
179
+ /** The poll may run (the element is on screen). */
180
+ setInView(inView: boolean): void
181
+ submit(): Promise<void>
182
+ /** Reply under `seq`'s thread (a reply to a reply goes under its top-level message, naming its author). */
183
+ openReply(seq: number, replyingTo?: string | null): void
184
+ openEdit(seq: number): void
185
+ closeInline(): void
186
+ submitInline(): Promise<void>
187
+ /** Tick or untick one code of a draft's confirmation. */
188
+ setConfirmed(draft: Draft, code: string, checked: boolean): void
189
+ /** Send the draft again with every confirmed code (the Russmedia confirmation). */
190
+ sendConfirmed(draft: Draft): Promise<void>
191
+ remove(seq: number): Promise<boolean>
192
+ react(target: ReactionTarget, emoji: string): Promise<void>
193
+ setBlocked(memberId: string, blocked: boolean): Promise<void>
194
+ openReport(m: DiscussionMessage): void
195
+ closeReport(): void
196
+ submitReport(): Promise<void>
197
+ /**
198
+ * The onboarding inline; `offerArt9` asks the art. 9 consent also of a member who refused it (`public_write`, M2 T9) —
199
+ * the give-consent link on their own message held until the consent.
200
+ */
201
+ beginOnboarding(options?: { offerArt9?: boolean }): void
202
+ cancelOnboarding(): void
203
+ /** Seconds a draft still waits (0 when it may go). */
204
+ waitLeft(draft: Draft): number
205
+ /**
206
+ * M2 T10: the distinct `@handle`s one message of the viewer may carry (me/get's `caps.mentionsPerMessage`), or null
207
+ * when nothing is known to limit it (no member yet, an older kb-api, a module whose mentions notify nobody).
208
+ */
209
+ mentionCap(): number | null
210
+ dispose(): void
211
+ }
212
+
213
+ const emptyDraft = (body = ''): Draft => ({ body, sending: false, failure: null, confirm: null, waitUntil: null, clientMsgId: null })
214
+
215
+ const documentVisible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
216
+
217
+ export function createDiscussionController(o: DiscussionControllerOptions): DiscussionController {
218
+ const { client, lang } = o
219
+ const articleId = o.articleId.toLowerCase()
220
+ const now = o.now ?? Date.now
221
+ const store = createDiscussionStore()
222
+ const abort = new AbortController()
223
+ const call: CommunityCallOptions = { signal: abort.signal }
224
+
225
+ const state = reactive<DiscussionUi>({
226
+ phase: 'loading',
227
+ failure: null,
228
+ thread: store.getState(),
229
+ loadingMore: false,
230
+ refreshing: false,
231
+ newMessages: false,
232
+ me: null,
233
+ block: null,
234
+ composer: emptyDraft(),
235
+ inline: null,
236
+ report: null,
237
+ announcement: null,
238
+ announceId: 0,
239
+ clock: now(),
240
+ busy: {},
241
+ refusedAge: o.refusedAge ?? null,
242
+ art9RefusedNote: false,
243
+ realtimeOff: false,
244
+ }) as DiscussionUi
245
+ const unsubscribe = store.subscribe((s) => {
246
+ state.thread = s
247
+ })
248
+ const onboarding = shallowRef<OnboardingModel | null>(null)
249
+ /** The open onboarding is the give-consent flow (read with `onboarding`, which it is set beside). */
250
+ let consentFlow = false
251
+
252
+ let poller: DiscussionHeadPoller | null = null
253
+ let session: DiscussionRealtimeSession | null = null
254
+ let inView = true
255
+ let disposed = false
256
+ let lastSync = 0
257
+ let refreshing: Promise<void> | null = null
258
+ let mePromise: Promise<void> | null = null
259
+ let ticker: ReturnType<typeof setInterval> | null = null
260
+ /** The brake's end (its `until`, or a BRAKED refusal's): me/get is read again then. */
261
+ let brakeTimer: ReturnType<typeof setTimeout> | null = null
262
+ /** A BRAKED refusal's own end (epoch ms), while `state.block` is `braked` because of it; null: none given. */
263
+ let refusedBrakeUntil: number | null = null
264
+ const interval = o.pollMs ?? 20_000
265
+
266
+ function announce(text: TextRef): void {
267
+ state.announcement = text
268
+ state.announceId += 1
269
+ }
270
+
271
+ function hide(): void {
272
+ state.phase = 'hidden'
273
+ poller?.stop()
274
+ poller = null
275
+ session?.stop()
276
+ session = null
277
+ }
278
+
279
+ // ───────────────────────── loading ─────────────────────────
280
+
281
+ /** me/get once (`fresh`: again, keeping what is shown until the answer is in — the composer does not flicker). */
282
+ function ensureMe(fresh = false): Promise<void> {
283
+ if (state.me && !fresh) return Promise.resolve()
284
+ mePromise ??= client.me.get(call).then((r) => {
285
+ mePromise = null
286
+ if (!r.ok || disposed) return
287
+ state.me = r.value
288
+ scheduleBrakeEnd()
289
+ })
290
+ return mePromise
291
+ }
292
+
293
+ /** The end of a brake as me/get states it (`module.brake.until`, or `module.until` of a braked module), epoch ms. */
294
+ function brakeEndOf(me: MyState | null): number | null {
295
+ const m = me?.module
296
+ if (!m) return null
297
+ const until = m.brake ? m.brake.until : isKnownModuleState(m.state) && m.state === 'braked' ? m.until : undefined
298
+ const at = typeof until === 'string' ? Date.parse(until) : NaN
299
+ return Number.isFinite(at) ? at : null
300
+ }
301
+
302
+ /**
303
+ * While the discussion is braked, a timer at the brake's end (the later of me/get's `until` and a refusal's): then
304
+ * me/get is read again and the composer comes back when the brake is over. A brake without an end waits for the
305
+ * next refresh (afterLoad reads me/get again while braked).
306
+ */
307
+ function scheduleBrakeEnd(): void {
308
+ if (brakeTimer) clearTimeout(brakeTimer)
309
+ brakeTimer = null
310
+ if (disposed) return
311
+ const ends: number[] = []
312
+ if (brakedNow()) {
313
+ const at = brakeEndOf(state.me)
314
+ if (at !== null) ends.push(at)
315
+ }
316
+ if (state.block === 'braked' && refusedBrakeUntil !== null) ends.push(refusedBrakeUntil)
317
+ if (ends.length === 0) return
318
+ // A second after the end (clocks differ by a little), within setTimeout's range; an end me/get still reports
319
+ // after it passed (the brake not lifted yet on kb-api's side) is asked again every 30 s, never in a tight loop.
320
+ const left = Math.max(...ends) - now()
321
+ const delay = left > 0 ? Math.min(left + 1000, 2_147_000_000) : 30_000
322
+ brakeTimer = setTimeout(() => {
323
+ brakeTimer = null
324
+ if (disposed) return
325
+ liftRefusedBrake()
326
+ void ensureMe(true)
327
+ }, delay)
328
+ }
329
+
330
+ /** A BRAKED refusal's block ends once its own end has passed (or it gave none): me/get decides from then on. */
331
+ function liftRefusedBrake(): void {
332
+ if (state.block !== 'braked') return
333
+ if (refusedBrakeUntil !== null && refusedBrakeUntil > now()) return
334
+ state.block = null
335
+ refusedBrakeUntil = null
336
+ }
337
+
338
+ /**
339
+ * canPost is false, nothing is needed and no sanction applies: a brake (M2 T10: kb-api's canPost is false while one
340
+ * is in effect — me/get, read again, names it, and the composer gives way to the brake's sentence), else the
341
+ * member's status says whether it is the cutover.
342
+ */
343
+ function decideBlock(): void {
344
+ const s = store.getState()
345
+ if (s.viewer.canPost || viewerNeeds(s).any || s.viewer.restrictions.length > 0) return
346
+ void ensureMe(state.block === null).then(() => {
347
+ const me = state.me
348
+ if (disposed || !me || store.getState().viewer.canPost) return
349
+ if (brakedNow()) return
350
+ // An active member who may not post: the discussion takes no writes yet (the cutover).
351
+ state.block = !me.member || me.member.status === 'active' ? 'notMoved' : 'restricted'
352
+ })
353
+ }
354
+
355
+ function afterLoad(): void {
356
+ const s = store.getState()
357
+ // A braked discussion asks me/get again on every refresh: the brake may be over (kb-api's canPost does not know it).
358
+ const wasBraked = state.block === 'braked' || brakedNow()
359
+ if (s.viewer.canPost && state.block !== 'braked') state.block = null
360
+ if (wasBraked) liftRefusedBrake()
361
+ if (s.viewer.memberId !== null) void ensureMe(wasBraked)
362
+ decideBlock()
363
+ // Finished elsewhere (another tab): nothing left to onboard.
364
+ if (onboarding.value && !viewerNeeds(s).any && onboarding.value.state.phase !== 'sending') onboarding.value = null
365
+ }
366
+
367
+ function loadFailed(f: CommunityFailure): void {
368
+ if (f.code === 'surfaceOff') return hide()
369
+ if (f.code === 'notFound' && state.phase !== 'ready') {
370
+ warnOnce('not-found', '[blogmcp-community] the discussion of this article-id was not found (no such live article, or it is not readable here)')
371
+ return hide()
372
+ }
373
+ if (state.phase === 'loading' || state.phase === 'failed') {
374
+ state.phase = 'failed'
375
+ state.failure = f
376
+ }
377
+ // A loaded discussion keeps what it shows; the poll tries again.
378
+ }
379
+
380
+ function refresh(): Promise<void> {
381
+ if (disposed) return Promise.resolve()
382
+ refreshing ??= (async () => {
383
+ state.refreshing = true
384
+ const r = await refreshDiscussion(store, client, { articleId, languageCode: lang }, call)
385
+ state.refreshing = false
386
+ if (disposed) return
387
+ lastSync = now()
388
+ if (r.ok) {
389
+ state.phase = 'ready'
390
+ state.failure = null
391
+ state.newMessages = false
392
+ afterLoad()
393
+ } else {
394
+ loadFailed(r)
395
+ }
396
+ })().finally(() => {
397
+ refreshing = null
398
+ })
399
+ return refreshing
400
+ }
401
+
402
+ async function loadMore(): Promise<void> {
403
+ if (state.loadingMore || state.thread.nextCursor === null) return
404
+ state.loadingMore = true
405
+ const r = await loadMoreDiscussion(store, client, { articleId, languageCode: lang }, call)
406
+ state.loadingMore = false
407
+ if (disposed || r.ok) return
408
+ if (r.code === 'surfaceOff') return hide()
409
+ announce(failureText(r, lang))
410
+ }
411
+
412
+ function onHeads(heads: DiscussionHead[]): void {
413
+ for (const head of heads) {
414
+ if (typeof head?.articleId !== 'string' || head.articleId.toLowerCase() !== articleId) continue
415
+ if (state.phase === 'failed') {
416
+ void refresh()
417
+ continue
418
+ }
419
+ const before = store.getState()
420
+ const newer = typeof head.lastSeq === 'number' && head.lastSeq > before.lastSeq
421
+ if (!store.applyHead(head)) continue
422
+ // New messages wait for the reader (the page does not move under them); an edit, a reaction or the
423
+ // reader's own post are folded in at once.
424
+ if (newer && before.loaded) state.newMessages = true
425
+ else void refresh()
426
+ }
427
+ }
428
+
429
+ function startPolling(): void {
430
+ if (poller || interval === 0 || disposed || state.phase === 'hidden') return
431
+ poller = pollDiscussionHead(client, [articleId], {
432
+ intervalMs: interval,
433
+ visible: () => documentVisible() && inView,
434
+ onChange: onHeads,
435
+ onFailure: (f) => {
436
+ if (f.code === 'surfaceOff') hide()
437
+ },
438
+ signal: abort.signal,
439
+ })
440
+ }
441
+
442
+ /**
443
+ * Live updates (M2, §3.5): the host's session over this element's store, after the first list. Not for an anonymous
444
+ * reader (no host session: the token route refuses it) nor while `me/get` says the module offers none (`read_only`,
445
+ * `dark`, live updates not configured) — the head poll then, and no socket. The session falls back to polling by
446
+ * itself (`realtimeOff`, the server's terminal 4501) and the element never starts it again.
447
+ */
448
+ async function startRealtime(factory: DiscussionRealtime): Promise<void> {
449
+ if (session || disposed || state.phase === 'hidden') return
450
+ if (viewerNeeds(store.getState()).signIn) return startPolling()
451
+ await ensureMe()
452
+ // The discussion may have been hidden (SURFACE_OFF) while me/get was on its way.
453
+ if (disposed || session || (state.phase as DiscussionPhase) === 'hidden') return
454
+ if (state.me?.module && state.me.module.features.realtime !== true) return startPolling()
455
+ try {
456
+ session = factory({
457
+ client,
458
+ store,
459
+ languageCode: lang,
460
+ visible: () => documentVisible() && inView,
461
+ ...(interval > 0 ? { pollIntervalMs: interval } : {}),
462
+ onMode: (mode) => {
463
+ if (disposed) return
464
+ // Said once per fallback: live again (the SDK session reconnected) takes the sentence away.
465
+ if (mode === 'polling' && !state.realtimeOff) state.realtimeOff = true
466
+ else if (mode === 'live' && state.realtimeOff) state.realtimeOff = false
467
+ },
468
+ })
469
+ } catch {
470
+ warnOnce('realtime', '[blogmcp-community] the realtime property is not a session factory (createRealtimeSession of @blogmcp/sdk/community/realtime): the discussion polls')
471
+ session = null
472
+ startPolling()
473
+ }
474
+ }
475
+
476
+ /** The member's community is paused (`me/get`'s module.brake or state, M2 §3.1): every member write is refused. */
477
+ function brakedNow(): boolean {
478
+ const m = state.me?.module
479
+ if (!m) return false
480
+ return m.brake !== undefined || (isKnownModuleState(m.state) && m.state === 'braked')
481
+ }
482
+
483
+ // ───────────────────────── who may do what ─────────────────────────
484
+
485
+ function mode(): ComposerMode {
486
+ const s = state.thread
487
+ const n = viewerNeeds(s)
488
+ if (n.signIn) return 'signIn'
489
+ if (state.block === 'notMoved') return 'notMoved'
490
+ if (state.block === 'braked' || brakedNow()) return 'braked'
491
+ if (!s.viewer.canPost && s.viewer.restrictions.length > 0) return 'sanction'
492
+ // A refused age band: the refusal stays where the composer was once the reader closed it.
493
+ if (n.any) return state.refusedAge !== null && !onboarding.value ? 'ageRefused' : 'onboarding'
494
+ // The give-consent flow of a member who refused the consent (needs name none): its step where the composer was.
495
+ if (onboarding.value && consentFlow) return 'onboarding'
496
+ if (state.block === 'restricted') return 'restricted'
497
+ return s.viewer.canPost ? 'write' : 'pending'
498
+ }
499
+
500
+ /** me/get's per-message mention cap, where kb-api applies it (a member, the module live or braked: §3.3). */
501
+ function mentionCap(): number | null {
502
+ const me = state.me
503
+ const cap = me?.caps?.mentionsPerMessage
504
+ const m = me?.module?.state
505
+ if (!me?.member || typeof cap !== 'number' || !Number.isSafeInteger(cap) || cap < 0) return null
506
+ return m === 'live' || m === 'braked' ? cap : null
507
+ }
508
+ /** The draft mentions more distinct handles than the cap (kb-api's count: as typed, whether they exist or not). */
509
+ const overMentionCap = (body: string): boolean => {
510
+ const cap = mentionCap()
511
+ return cap !== null && communityMentionHandles(body).length > cap
512
+ }
513
+
514
+ const isOwn = (m: Pick<DiscussionMessage, 'author'>): boolean => isOwnMessage(state.thread, m)
515
+ const settled = (m: DiscussionMessage): boolean => !m.pending && m.seq > 0
516
+ const canWrite = (): boolean => mode() === 'write'
517
+
518
+ // ───────────────────────── writes ─────────────────────────
519
+
520
+ /** The viewer as their own messages show them (for the optimistic row), or null when not known yet. */
521
+ function ownAuthor(): KnownAuthor | null {
522
+ const me = state.me
523
+ if (me?.member && me.profile) {
524
+ return { kind: 'member', memberId: me.member.memberId, handle: me.profile.handle, displayName: me.profile.displayName, avatarPreset: me.profile.avatarPreset }
525
+ }
526
+ const viewer = state.thread.viewer.memberId
527
+ if (viewer === null) return null
528
+ for (const m of state.thread.messages) {
529
+ for (const x of [m, ...m.replies]) if (x.author.kind === 'member' && x.author.memberId === viewer) return x.author
530
+ }
531
+ return null
532
+ }
533
+
534
+ function startClock(): void {
535
+ state.clock = now()
536
+ if (ticker) return
537
+ ticker = setInterval(() => {
538
+ state.clock = now()
539
+ let waiting = false
540
+ for (const d of [state.composer, state.inline]) {
541
+ if (!d || d.waitUntil === null) continue
542
+ if (d.waitUntil <= state.clock) {
543
+ d.waitUntil = null
544
+ if (d.failure && (d.failure.code === 'fast' || d.failure.code === 'quota')) d.failure = null
545
+ } else {
546
+ waiting = true
547
+ }
548
+ }
549
+ if (!waiting && ticker) {
550
+ clearInterval(ticker)
551
+ ticker = null
552
+ }
553
+ }, 1000)
554
+ }
555
+
556
+ const waitLeft = (d: Draft): number => (d.waitUntil === null ? 0 : Math.max(0, Math.ceil((d.waitUntil - Math.max(state.clock, now())) / 1000)))
557
+
558
+ /** What a refused write does besides showing its sentence. */
559
+ function refused(d: Draft | null, f: CommunityFailure): void {
560
+ if (d) d.failure = f
561
+ switch (failureEffect(f.code)) {
562
+ case 'confirm':
563
+ if (d && f.codes && f.codes.length > 0) d.confirm = { codes: [...f.codes], checked: [] }
564
+ return
565
+ case 'wait':
566
+ if (d) {
567
+ d.waitUntil = waitUntilOf(f, now())
568
+ if (d.waitUntil !== null) startClock()
569
+ }
570
+ if (f.code === 'braked') {
571
+ // Paused (BRAKED): the composer gives way to the sentence until the brake's end; me/get is read again.
572
+ state.block = 'braked'
573
+ refusedBrakeUntil = waitUntilOf(f, now())
574
+ scheduleBrakeEnd()
575
+ void ensureMe(true)
576
+ }
577
+ return
578
+ case 'onboard':
579
+ beginOnboarding()
580
+ void refresh()
581
+ return
582
+ case 'reload':
583
+ void refresh()
584
+ return
585
+ case 'notMoved':
586
+ state.block = 'notMoved'
587
+ return
588
+ case 'hide':
589
+ // Live updates switched off hide nothing: the discussion polls (no write answers it; a realtime route does).
590
+ return f.code === 'realtimeOff' ? undefined : hide()
591
+ default:
592
+ return
593
+ }
594
+ }
595
+
596
+ type Target = { kind: 'new' } | { kind: InlineKind; seq: number }
597
+
598
+ async function send(d: Draft, target: Target, confirmations?: string[]): Promise<void> {
599
+ if (d.sending || waitLeft(d) > 0 || disposed) return
600
+ const body = normalizeBody(d.body)
601
+ if (body === '' || codePointLength(body) > COMMUNITY_MESSAGE_MAX) return
602
+ const n = viewerNeeds(state.thread)
603
+ if (n.signIn) return
604
+ if (n.any) return beginOnboarding()
605
+ const parent = target.kind === 'new' ? null : findMessage(state.thread, target.seq)
606
+ if (target.kind !== 'new' && (!parent || !settled(parent))) return
607
+ // Too many mentions for the member's level: said before posting (the composer shows it as it is typed); kb-api's
608
+ // MENTION_LIMIT stays the rule.
609
+ if (overMentionCap(body)) {
610
+ d.failure = { ok: false, code: 'mentionLimit' }
611
+ return
612
+ }
613
+
614
+ d.sending = true
615
+ d.failure = null
616
+ const author = target.kind === 'edit' ? null : ownAuthor()
617
+ // A post keeps its id until kb-api took it; an edit's id only names its optimistic change.
618
+ const changeId = target.kind === 'edit' ? newClientMsgId() : (d.clientMsgId ??= newClientMsgId())
619
+ if (target.kind === 'edit') store.applyMessage({ type: 'edit', clientMsgId: changeId, seq: target.seq, body })
620
+ else if (author) store.applyMessage({ type: 'post', clientMsgId: changeId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, author, languageCode: lang })
621
+
622
+ const r =
623
+ target.kind === 'edit'
624
+ ? await client.messages.edit({ conversationId: parent!.conversationId, seq: target.seq, body, ...(confirmations ? { confirmations } : {}) }, call)
625
+ : await client.messages.post(
626
+ { articleId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, clientMsgId: changeId, languageCode: lang, ...(confirmations ? { confirmations } : {}) },
627
+ call,
628
+ )
629
+ if (disposed) return
630
+ d.sending = false
631
+ if (r.ok) {
632
+ store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message, warnings: r.value.warnings })
633
+ const held = r.value.message.state === 'held'
634
+ d.body = ''
635
+ d.clientMsgId = null
636
+ d.confirm = null
637
+ d.failure = null
638
+ if (target.kind !== 'new' && state.inline === d) state.inline = null
639
+ announce({ key: held ? 'composer.held' : 'composer.posted' })
640
+ if (target.kind !== 'edit') o.onPosted?.(held ? 'held' : 'visible')
641
+ return
642
+ }
643
+ store.applyMessage({ type: 'rollback', clientMsgId: changeId })
644
+ refused(d, r)
645
+ }
646
+
647
+ function setConfirmed(d: Draft, code: string, checked: boolean): void {
648
+ if (!d.confirm || !d.confirm.codes.includes(code)) return
649
+ const rest = d.confirm.checked.filter((c) => c !== code)
650
+ d.confirm.checked = checked ? [...rest, code] : rest
651
+ }
652
+
653
+ async function sendConfirmed(d: Draft): Promise<void> {
654
+ const confirm = d.confirm
655
+ if (!confirm || !confirm.codes.every((c) => confirm.checked.includes(c))) return
656
+ const target: Target = d === state.composer ? { kind: 'new' } : state.inline === d ? { kind: state.inline.kind, seq: state.inline.seq } : { kind: 'new' }
657
+ await send(d, target, [...confirm.codes])
658
+ }
659
+
660
+ async function remove(seq: number): Promise<boolean> {
661
+ const m = findMessage(state.thread, seq)
662
+ const key = `delete:${seq}`
663
+ if (!m || !settled(m) || state.busy[key]) return false
664
+ state.busy[key] = true
665
+ const changeId = newClientMsgId()
666
+ store.applyMessage({ type: 'delete', clientMsgId: changeId, seq })
667
+ const r = await client.messages.delete({ conversationId: m.conversationId, seq }, call)
668
+ delete state.busy[key]
669
+ if (disposed) return false
670
+ if (r.ok) {
671
+ store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message })
672
+ return true
673
+ }
674
+ store.applyMessage({ type: 'rollback', clientMsgId: changeId })
675
+ announce(failureText(r, lang))
676
+ refused(null, r)
677
+ return false
678
+ }
679
+
680
+ const reactionKey = (t: ReactionTarget): string => ('articleId' in t ? 'react:article' : `react:${t.seq}`)
681
+
682
+ async function react(target: ReactionTarget, emoji: string): Promise<void> {
683
+ const key = reactionKey(target)
684
+ if (!canWrite() || state.busy[key] || !state.thread.reactionSet.includes(emoji)) return
685
+ const before = reactionsFor(state.thread, target)
686
+ const next = before.mine === emoji ? null : emoji
687
+ state.busy[key] = true
688
+ store.applyReaction(target, reactionAfter(before, next))
689
+ const r = await client.reactions.set({ target, emoji: next }, call)
690
+ delete state.busy[key]
691
+ if (disposed) return
692
+ if (r.ok) {
693
+ store.applyReaction(target, r.value.reactions)
694
+ return
695
+ }
696
+ store.applyReaction(target, before)
697
+ announce(failureText(r, lang))
698
+ refused(null, r)
699
+ }
700
+
701
+ async function setBlocked(memberId: string, blocked: boolean): Promise<void> {
702
+ const key = `block:${memberId}`
703
+ if (state.busy[key]) return
704
+ state.busy[key] = true
705
+ const r = await client.blocks.set({ memberId, blocked }, call)
706
+ delete state.busy[key]
707
+ if (disposed) return
708
+ if (r.ok) {
709
+ store.collapse(memberId, r.value.blocked)
710
+ announce({ key: r.value.blocked ? 'discussion.blocked' : 'discussion.unblocked' })
711
+ return
712
+ }
713
+ announce(failureText(r, lang))
714
+ refused(null, r)
715
+ }
716
+
717
+ // ───────────────────────── reports ─────────────────────────
718
+
719
+ function openReport(m: DiscussionMessage): void {
720
+ state.report = { conversationId: m.conversationId, seq: m.seq, category: '', ruleId: '', note: '', rules: null, rulesFailed: false, sending: false, failure: null, done: false }
721
+ const report = state.report
722
+ void client.legal.current({ kind: 'rules', locale: lang }, call).then((r) => {
723
+ if (state.report !== report) return
724
+ if (r.ok) report.rules = communityRulesOf(r.value.bodyJson)
725
+ else report.rulesFailed = true
726
+ })
727
+ }
728
+
729
+ async function submitReport(): Promise<void> {
730
+ const report = state.report
731
+ if (!report || report.sending || report.done || report.category === '') return
732
+ if (report.category === 'rules' && report.ruleId === '') return
733
+ const note = normalizeBody(report.note)
734
+ if (codePointLength(note) > COMMUNITY_REPORT_NOTE_MAX) return
735
+ report.sending = true
736
+ report.failure = null
737
+ const r = await client.messages.report(
738
+ { conversationId: report.conversationId, seq: report.seq, category: report.category, ruleId: report.category === 'rules' ? report.ruleId : null, note: note === '' ? null : note },
739
+ call,
740
+ )
741
+ report.sending = false
742
+ if (disposed) return
743
+ if (r.ok) {
744
+ report.done = true
745
+ announce({ key: 'report.done' })
746
+ return
747
+ }
748
+ report.failure = r
749
+ if (r.code === 'surfaceOff') hide()
750
+ }
751
+
752
+ // ───────────────────────── onboarding ─────────────────────────
753
+
754
+ function beginOnboarding(options: { offerArt9?: boolean } = {}): void {
755
+ if (onboarding.value || disposed || state.refusedAge !== null) return
756
+ const offerArt9 = options.offerArt9 === true
757
+ consentFlow = offerArt9
758
+ const model = createOnboarding({
759
+ client,
760
+ lang,
761
+ signal: abort.signal,
762
+ ...(offerArt9 ? { offerArt9: true } : {}),
763
+ onRefused: (minAge) => {
764
+ state.refusedAge = minAge
765
+ o.onAgeRefused?.(minAge)
766
+ },
767
+ onDone: (me) => {
768
+ state.me = me
769
+ onboarding.value = null
770
+ // A refused art. 9 consent (`public_write`): the member joined, and is told what the refusal leaves.
771
+ state.art9RefusedNote = model.state.art9RefusedSent
772
+ if (offerArt9) {
773
+ // The give-consent flow: announced only when a consent is in force (it also closes with nothing left to ask).
774
+ if (me.acceptances.art9Consent === 'accepted') announce({ key: 'panel.art9Given' })
775
+ } else announce({ key: model.state.art9RefusedSent ? 'onboarding.art9RefusedDone' : 'onboarding.done' })
776
+ void refresh()
777
+ },
778
+ onSignIn: () => {
779
+ onboarding.value = null
780
+ void refresh()
781
+ },
782
+ })
783
+ onboarding.value = model
784
+ void model.load()
785
+ }
786
+
787
+ return {
788
+ state,
789
+ store,
790
+ onboarding,
791
+ async start() {
792
+ await refresh()
793
+ if (o.realtime && state.phase === 'ready') await startRealtime(o.realtime)
794
+ else startPolling()
795
+ },
796
+ refresh,
797
+ loadMore,
798
+ mode,
799
+ needs: () => viewerNeeds(state.thread),
800
+ isOwn,
801
+ canReply: (m) => {
802
+ if (!canWrite() || !settled(m) || m.state !== 'visible' || m.body === null) return false
803
+ if (m.replyToSeq === null) return true
804
+ const parent = findMessage(state.thread, m.replyToSeq)
805
+ return parent !== null && settled(parent) && parent.state === 'visible'
806
+ },
807
+ canEdit: (m) => canWrite() && isOwn(m) && settled(m) && m.body !== null && (m.state === 'visible' || m.state === 'held'),
808
+ // Not under a brake: kb-api refuses `messages/delete` BRAKED too (assertNotBraked).
809
+ canDelete: (m) => state.thread.viewer.memberId !== null && mode() !== 'braked' && isOwn(m) && settled(m) && m.body !== null && m.state !== 'deleted',
810
+ canReact: canWrite,
811
+ mentionCap,
812
+ canReport: (m) => !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.body !== null && m.author.kind !== 'system',
813
+ canReportAsNotifier: (m) => viewerNeeds(state.thread).signIn && settled(m) && m.body !== null && m.author.kind !== 'system',
814
+ canBlock: (m) =>
815
+ !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.author.kind === 'member' && m.author.memberId !== null,
816
+ setInView(v) {
817
+ const was = inView
818
+ inView = v
819
+ if (v && !was && poller && now() - lastSync >= interval) poller.now()
820
+ },
821
+ submit: () => send(state.composer, { kind: 'new' }),
822
+ openReply(seq, replyingTo = null) {
823
+ const m = findMessage(state.thread, seq)
824
+ if (!m) return
825
+ // Two levels: a reply to a reply goes under the top-level message.
826
+ const top = m.replyToSeq ?? m.seq
827
+ if (state.inline?.kind === 'reply' && state.inline.seq === top) {
828
+ state.inline.replyingTo = replyingTo
829
+ return
830
+ }
831
+ state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo }
832
+ },
833
+ openEdit(seq) {
834
+ const m = findMessage(state.thread, seq)
835
+ if (!m || m.body === null) return
836
+ state.inline = { ...emptyDraft(m.body), kind: 'edit', seq, replyingTo: null }
837
+ },
838
+ closeInline() {
839
+ state.inline = null
840
+ },
841
+ async submitInline() {
842
+ const d = state.inline
843
+ if (d) await send(d, { kind: d.kind, seq: d.seq })
844
+ },
845
+ setConfirmed,
846
+ sendConfirmed,
847
+ remove,
848
+ react,
849
+ setBlocked,
850
+ openReport,
851
+ closeReport() {
852
+ state.report = null
853
+ },
854
+ submitReport,
855
+ beginOnboarding,
856
+ cancelOnboarding() {
857
+ onboarding.value = null
858
+ },
859
+ waitLeft,
860
+ dispose() {
861
+ if (disposed) return
862
+ disposed = true
863
+ abort.abort()
864
+ poller?.stop()
865
+ poller = null
866
+ session?.stop()
867
+ session = null
868
+ unsubscribe()
869
+ if (ticker) clearInterval(ticker)
870
+ ticker = null
871
+ if (brakeTimer) clearTimeout(brakeTimer)
872
+ brakeTimer = null
873
+ onboarding.value = null
874
+ },
875
+ }
876
+ }