@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,691 @@
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
+ COMMUNITY_MESSAGE_MAX,
12
+ COMMUNITY_REPORT_NOTE_MAX,
13
+ communityRulesOf,
14
+ createDiscussionStore,
15
+ findMessage,
16
+ isOwnMessage,
17
+ loadMoreDiscussion,
18
+ newClientMsgId,
19
+ pollDiscussionHead,
20
+ reactionAfter,
21
+ reactionsFor,
22
+ refreshDiscussion,
23
+ viewerNeeds,
24
+ type CaseCategory,
25
+ type CommunityCallOptions,
26
+ type CommunityClient,
27
+ type CommunityFailure,
28
+ type CommunityRule,
29
+ type DiscussionHead,
30
+ type DiscussionHeadPoller,
31
+ type DiscussionMessage,
32
+ type DiscussionState,
33
+ type DiscussionStore,
34
+ type KnownAuthor,
35
+ type MyState,
36
+ type ReactionTarget,
37
+ type ViewerNeeds,
38
+ } from '@blogmcp/sdk/community'
39
+ import { warnOnce } from './client'
40
+ import type { Lang, TextRef } from './i18n'
41
+ import { createOnboarding, type OnboardingModel } from './onboarding'
42
+ import { failureEffect, failureText, waitUntilOf } from './outcomes'
43
+ import { codePointLength, normalizeBody } from './text'
44
+
45
+ export type DiscussionPhase = 'loading' | 'ready' | 'failed' | 'hidden'
46
+
47
+ /** One composer's text and what happened to it. */
48
+ export interface Draft {
49
+ body: string
50
+ sending: boolean
51
+ failure: CommunityFailure | null
52
+ /** CONFIRMATION_REQUIRED: the codes the author confirms before the same text goes again. */
53
+ confirm: { codes: string[]; checked: string[] } | null
54
+ /** A cooldown: the epoch milliseconds until which this write waits. */
55
+ waitUntil: number | null
56
+ /** A post's id, kept until kb-api took it: a retry of a write whose answer was lost cannot post twice. */
57
+ clientMsgId: string | null
58
+ }
59
+
60
+ export type InlineKind = 'reply' | 'edit'
61
+
62
+ /** The one reply or edit open under a message (opening another closes it). */
63
+ export interface InlineDraft extends Draft {
64
+ kind: InlineKind
65
+ /** The message edited, or the TOP-LEVEL message a reply goes under (two levels only). */
66
+ seq: number
67
+ /** A reply to a reply names whom it answers. */
68
+ replyingTo: string | null
69
+ }
70
+
71
+ export interface ReportDraft {
72
+ conversationId: string
73
+ seq: number
74
+ category: CaseCategory | ''
75
+ ruleId: string
76
+ note: string
77
+ rules: CommunityRule[] | null
78
+ rulesFailed: boolean
79
+ sending: boolean
80
+ failure: CommunityFailure | null
81
+ done: boolean
82
+ }
83
+
84
+ /**
85
+ * Where the composer stands: `write`; `signIn` (no session that can write); `onboarding` (the inline
86
+ * onboarding waits); `ageRefused` (the age band declared in it was below the minimum: reading only, the
87
+ * onboarding is not offered again); `sanction` (a restriction, suspension or ban in effect); `notMoved` (the
88
+ * discussion takes no writes yet — one sentence); `restricted` (the member is read-only or deleting their
89
+ * profile); `pending` (being decided).
90
+ */
91
+ export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'pending'
92
+
93
+ export interface DiscussionUi {
94
+ phase: DiscussionPhase
95
+ /** Why the first load failed (`failed`). */
96
+ failure: CommunityFailure | null
97
+ thread: DiscussionState
98
+ loadingMore: boolean
99
+ refreshing: boolean
100
+ /** A head poll saw messages the page does not show yet. */
101
+ newMessages: boolean
102
+ me: MyState | null
103
+ /** What a write or the member's state taught: no writes yet, or none for this member. */
104
+ block: 'notMoved' | 'restricted' | null
105
+ composer: Draft
106
+ inline: InlineDraft | null
107
+ report: ReportDraft | null
108
+ /** The last outcome to announce (aria-live), and a counter so the same text is announced again. */
109
+ announcement: TextRef | null
110
+ announceId: number
111
+ /** Now, ticking while a cooldown runs. */
112
+ clock: number
113
+ /** Actions on their way, by key (`react:…`, `delete:<seq>`, `block:<memberId>`). */
114
+ busy: Record<string, boolean>
115
+ /**
116
+ * The minimum age a declared band fell below (AGE_BELOW_MINIMUM). kb-api keeps nothing of a refused band
117
+ * (§7.4.2), so the element does: the onboarding is not offered again while it lives.
118
+ */
119
+ refusedAge: number | null
120
+ }
121
+
122
+ export interface DiscussionControllerOptions {
123
+ client: CommunityClient
124
+ articleId: string
125
+ lang: Lang
126
+ /** Head poll interval (default 20 s; 0 = no polling). */
127
+ pollMs?: number
128
+ now?: () => number
129
+ /** The viewer's message was posted (`held`: it waits for review). */
130
+ onPosted?(state: 'visible' | 'held'): void
131
+ /** A refusal the element saw before (a discussion started again in another language keeps it). */
132
+ refusedAge?: number | null
133
+ /** The onboarding's age band was refused: the element keeps it for its next discussions. */
134
+ onAgeRefused?(minAge: number): void
135
+ }
136
+
137
+ export interface DiscussionController {
138
+ readonly state: DiscussionUi
139
+ readonly store: DiscussionStore
140
+ readonly onboarding: ShallowRef<OnboardingModel | null>
141
+ /** The first load, then the poll. */
142
+ start(): Promise<void>
143
+ refresh(): Promise<void>
144
+ loadMore(): Promise<void>
145
+ mode(): ComposerMode
146
+ needs(): ViewerNeeds
147
+ isOwn(m: Pick<DiscussionMessage, 'author'>): boolean
148
+ canReply(m: DiscussionMessage): boolean
149
+ canEdit(m: DiscussionMessage): boolean
150
+ canDelete(m: DiscussionMessage): boolean
151
+ canReact(): boolean
152
+ /** A member's report (`messages/report`): any signed-in reader, on someone else's message. */
153
+ canReport(m: DiscussionMessage): boolean
154
+ /** A reader without a session reports through the host's notice page instead (reportLink). */
155
+ canReportAsNotifier(m: DiscussionMessage): boolean
156
+ canBlock(m: DiscussionMessage): boolean
157
+ /** The poll may run (the element is on screen). */
158
+ setInView(inView: boolean): void
159
+ submit(): Promise<void>
160
+ /** Reply under `seq`'s thread (a reply to a reply goes under its top-level message, naming its author). */
161
+ openReply(seq: number, replyingTo?: string | null): void
162
+ openEdit(seq: number): void
163
+ closeInline(): void
164
+ submitInline(): Promise<void>
165
+ /** Tick or untick one code of a draft's confirmation. */
166
+ setConfirmed(draft: Draft, code: string, checked: boolean): void
167
+ /** Send the draft again with every confirmed code (the Russmedia confirmation). */
168
+ sendConfirmed(draft: Draft): Promise<void>
169
+ remove(seq: number): Promise<boolean>
170
+ react(target: ReactionTarget, emoji: string): Promise<void>
171
+ setBlocked(memberId: string, blocked: boolean): Promise<void>
172
+ openReport(m: DiscussionMessage): void
173
+ closeReport(): void
174
+ submitReport(): Promise<void>
175
+ beginOnboarding(): void
176
+ cancelOnboarding(): void
177
+ /** Seconds a draft still waits (0 when it may go). */
178
+ waitLeft(draft: Draft): number
179
+ dispose(): void
180
+ }
181
+
182
+ const emptyDraft = (body = ''): Draft => ({ body, sending: false, failure: null, confirm: null, waitUntil: null, clientMsgId: null })
183
+
184
+ const documentVisible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
185
+
186
+ export function createDiscussionController(o: DiscussionControllerOptions): DiscussionController {
187
+ const { client, lang } = o
188
+ const articleId = o.articleId.toLowerCase()
189
+ const now = o.now ?? Date.now
190
+ const store = createDiscussionStore()
191
+ const abort = new AbortController()
192
+ const call: CommunityCallOptions = { signal: abort.signal }
193
+
194
+ const state = reactive<DiscussionUi>({
195
+ phase: 'loading',
196
+ failure: null,
197
+ thread: store.getState(),
198
+ loadingMore: false,
199
+ refreshing: false,
200
+ newMessages: false,
201
+ me: null,
202
+ block: null,
203
+ composer: emptyDraft(),
204
+ inline: null,
205
+ report: null,
206
+ announcement: null,
207
+ announceId: 0,
208
+ clock: now(),
209
+ busy: {},
210
+ refusedAge: o.refusedAge ?? null,
211
+ }) as DiscussionUi
212
+ const unsubscribe = store.subscribe((s) => {
213
+ state.thread = s
214
+ })
215
+ const onboarding = shallowRef<OnboardingModel | null>(null)
216
+
217
+ let poller: DiscussionHeadPoller | null = null
218
+ let inView = true
219
+ let disposed = false
220
+ let lastSync = 0
221
+ let refreshing: Promise<void> | null = null
222
+ let mePromise: Promise<void> | null = null
223
+ let ticker: ReturnType<typeof setInterval> | null = null
224
+ const interval = o.pollMs ?? 20_000
225
+
226
+ function announce(text: TextRef): void {
227
+ state.announcement = text
228
+ state.announceId += 1
229
+ }
230
+
231
+ function hide(): void {
232
+ state.phase = 'hidden'
233
+ poller?.stop()
234
+ poller = null
235
+ }
236
+
237
+ // ───────────────────────── loading ─────────────────────────
238
+
239
+ function ensureMe(): Promise<void> {
240
+ if (state.me) return Promise.resolve()
241
+ mePromise ??= client.me.get(call).then((r) => {
242
+ mePromise = null
243
+ if (r.ok && !disposed) state.me = r.value
244
+ })
245
+ return mePromise
246
+ }
247
+
248
+ /** canPost is false, nothing is needed and no sanction applies: the member's status says whether it is the cutover. */
249
+ function decideBlock(): void {
250
+ const s = store.getState()
251
+ if (s.viewer.canPost || viewerNeeds(s).any || s.viewer.restrictions.length > 0) return
252
+ void ensureMe().then(() => {
253
+ const me = state.me
254
+ if (disposed || !me || store.getState().viewer.canPost) return
255
+ // An active member who may not post: the discussion takes no writes yet (the cutover).
256
+ state.block = !me.member || me.member.status === 'active' ? 'notMoved' : 'restricted'
257
+ })
258
+ }
259
+
260
+ function afterLoad(): void {
261
+ const s = store.getState()
262
+ if (s.viewer.canPost) state.block = null
263
+ if (s.viewer.memberId !== null) void ensureMe()
264
+ decideBlock()
265
+ // Finished elsewhere (another tab): nothing left to onboard.
266
+ if (onboarding.value && !viewerNeeds(s).any && onboarding.value.state.phase !== 'sending') onboarding.value = null
267
+ }
268
+
269
+ function loadFailed(f: CommunityFailure): void {
270
+ if (f.code === 'surfaceOff') return hide()
271
+ if (f.code === 'notFound' && state.phase !== 'ready') {
272
+ warnOnce('not-found', '[blogmcp-community] the discussion of this article-id was not found (no such live article, or it is not readable here)')
273
+ return hide()
274
+ }
275
+ if (state.phase === 'loading' || state.phase === 'failed') {
276
+ state.phase = 'failed'
277
+ state.failure = f
278
+ }
279
+ // A loaded discussion keeps what it shows; the poll tries again.
280
+ }
281
+
282
+ function refresh(): Promise<void> {
283
+ if (disposed) return Promise.resolve()
284
+ refreshing ??= (async () => {
285
+ state.refreshing = true
286
+ const r = await refreshDiscussion(store, client, { articleId, languageCode: lang }, call)
287
+ state.refreshing = false
288
+ if (disposed) return
289
+ lastSync = now()
290
+ if (r.ok) {
291
+ state.phase = 'ready'
292
+ state.failure = null
293
+ state.newMessages = false
294
+ afterLoad()
295
+ } else {
296
+ loadFailed(r)
297
+ }
298
+ })().finally(() => {
299
+ refreshing = null
300
+ })
301
+ return refreshing
302
+ }
303
+
304
+ async function loadMore(): Promise<void> {
305
+ if (state.loadingMore || state.thread.nextCursor === null) return
306
+ state.loadingMore = true
307
+ const r = await loadMoreDiscussion(store, client, { articleId, languageCode: lang }, call)
308
+ state.loadingMore = false
309
+ if (disposed || r.ok) return
310
+ if (r.code === 'surfaceOff') return hide()
311
+ announce(failureText(r, lang))
312
+ }
313
+
314
+ function onHeads(heads: DiscussionHead[]): void {
315
+ for (const head of heads) {
316
+ if (typeof head?.articleId !== 'string' || head.articleId.toLowerCase() !== articleId) continue
317
+ if (state.phase === 'failed') {
318
+ void refresh()
319
+ continue
320
+ }
321
+ const before = store.getState()
322
+ const newer = typeof head.lastSeq === 'number' && head.lastSeq > before.lastSeq
323
+ if (!store.applyHead(head)) continue
324
+ // New messages wait for the reader (the page does not move under them); an edit, a reaction or the
325
+ // reader's own post are folded in at once.
326
+ if (newer && before.loaded) state.newMessages = true
327
+ else void refresh()
328
+ }
329
+ }
330
+
331
+ function startPolling(): void {
332
+ if (poller || interval === 0 || disposed || state.phase === 'hidden') return
333
+ poller = pollDiscussionHead(client, [articleId], {
334
+ intervalMs: interval,
335
+ visible: () => documentVisible() && inView,
336
+ onChange: onHeads,
337
+ onFailure: (f) => {
338
+ if (f.code === 'surfaceOff') hide()
339
+ },
340
+ signal: abort.signal,
341
+ })
342
+ }
343
+
344
+ // ───────────────────────── who may do what ─────────────────────────
345
+
346
+ function mode(): ComposerMode {
347
+ const s = state.thread
348
+ const n = viewerNeeds(s)
349
+ if (n.signIn) return 'signIn'
350
+ if (state.block === 'notMoved') return 'notMoved'
351
+ if (!s.viewer.canPost && s.viewer.restrictions.length > 0) return 'sanction'
352
+ // A refused age band: the refusal stays where the composer was once the reader closed it.
353
+ if (n.any) return state.refusedAge !== null && !onboarding.value ? 'ageRefused' : 'onboarding'
354
+ if (state.block === 'restricted') return 'restricted'
355
+ return s.viewer.canPost ? 'write' : 'pending'
356
+ }
357
+
358
+ const isOwn = (m: Pick<DiscussionMessage, 'author'>): boolean => isOwnMessage(state.thread, m)
359
+ const settled = (m: DiscussionMessage): boolean => !m.pending && m.seq > 0
360
+ const canWrite = (): boolean => mode() === 'write'
361
+
362
+ // ───────────────────────── writes ─────────────────────────
363
+
364
+ /** The viewer as their own messages show them (for the optimistic row), or null when not known yet. */
365
+ function ownAuthor(): KnownAuthor | null {
366
+ const me = state.me
367
+ if (me?.member && me.profile) {
368
+ return { kind: 'member', memberId: me.member.memberId, handle: me.profile.handle, displayName: me.profile.displayName, avatarPreset: me.profile.avatarPreset }
369
+ }
370
+ const viewer = state.thread.viewer.memberId
371
+ if (viewer === null) return null
372
+ for (const m of state.thread.messages) {
373
+ for (const x of [m, ...m.replies]) if (x.author.kind === 'member' && x.author.memberId === viewer) return x.author
374
+ }
375
+ return null
376
+ }
377
+
378
+ function startClock(): void {
379
+ state.clock = now()
380
+ if (ticker) return
381
+ ticker = setInterval(() => {
382
+ state.clock = now()
383
+ let waiting = false
384
+ for (const d of [state.composer, state.inline]) {
385
+ if (!d || d.waitUntil === null) continue
386
+ if (d.waitUntil <= state.clock) {
387
+ d.waitUntil = null
388
+ if (d.failure && (d.failure.code === 'fast' || d.failure.code === 'quota')) d.failure = null
389
+ } else {
390
+ waiting = true
391
+ }
392
+ }
393
+ if (!waiting && ticker) {
394
+ clearInterval(ticker)
395
+ ticker = null
396
+ }
397
+ }, 1000)
398
+ }
399
+
400
+ const waitLeft = (d: Draft): number => (d.waitUntil === null ? 0 : Math.max(0, Math.ceil((d.waitUntil - Math.max(state.clock, now())) / 1000)))
401
+
402
+ /** What a refused write does besides showing its sentence. */
403
+ function refused(d: Draft | null, f: CommunityFailure): void {
404
+ if (d) d.failure = f
405
+ switch (failureEffect(f.code)) {
406
+ case 'confirm':
407
+ if (d && f.codes && f.codes.length > 0) d.confirm = { codes: [...f.codes], checked: [] }
408
+ return
409
+ case 'wait':
410
+ if (d) {
411
+ d.waitUntil = waitUntilOf(f, now())
412
+ startClock()
413
+ }
414
+ return
415
+ case 'onboard':
416
+ beginOnboarding()
417
+ void refresh()
418
+ return
419
+ case 'reload':
420
+ void refresh()
421
+ return
422
+ case 'notMoved':
423
+ state.block = 'notMoved'
424
+ return
425
+ case 'hide':
426
+ return hide()
427
+ default:
428
+ return
429
+ }
430
+ }
431
+
432
+ type Target = { kind: 'new' } | { kind: InlineKind; seq: number }
433
+
434
+ async function send(d: Draft, target: Target, confirmations?: string[]): Promise<void> {
435
+ if (d.sending || waitLeft(d) > 0 || disposed) return
436
+ const body = normalizeBody(d.body)
437
+ if (body === '' || codePointLength(body) > COMMUNITY_MESSAGE_MAX) return
438
+ const n = viewerNeeds(state.thread)
439
+ if (n.signIn) return
440
+ if (n.any) return beginOnboarding()
441
+ const parent = target.kind === 'new' ? null : findMessage(state.thread, target.seq)
442
+ if (target.kind !== 'new' && (!parent || !settled(parent))) return
443
+
444
+ d.sending = true
445
+ d.failure = null
446
+ const author = target.kind === 'edit' ? null : ownAuthor()
447
+ // A post keeps its id until kb-api took it; an edit's id only names its optimistic change.
448
+ const changeId = target.kind === 'edit' ? newClientMsgId() : (d.clientMsgId ??= newClientMsgId())
449
+ if (target.kind === 'edit') store.applyMessage({ type: 'edit', clientMsgId: changeId, seq: target.seq, body })
450
+ else if (author) store.applyMessage({ type: 'post', clientMsgId: changeId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, author, languageCode: lang })
451
+
452
+ const r =
453
+ target.kind === 'edit'
454
+ ? await client.messages.edit({ conversationId: parent!.conversationId, seq: target.seq, body, ...(confirmations ? { confirmations } : {}) }, call)
455
+ : await client.messages.post(
456
+ { articleId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, clientMsgId: changeId, languageCode: lang, ...(confirmations ? { confirmations } : {}) },
457
+ call,
458
+ )
459
+ if (disposed) return
460
+ d.sending = false
461
+ if (r.ok) {
462
+ store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message, warnings: r.value.warnings })
463
+ const held = r.value.message.state === 'held'
464
+ d.body = ''
465
+ d.clientMsgId = null
466
+ d.confirm = null
467
+ d.failure = null
468
+ if (target.kind !== 'new' && state.inline === d) state.inline = null
469
+ announce({ key: held ? 'composer.held' : 'composer.posted' })
470
+ if (target.kind !== 'edit') o.onPosted?.(held ? 'held' : 'visible')
471
+ return
472
+ }
473
+ store.applyMessage({ type: 'rollback', clientMsgId: changeId })
474
+ refused(d, r)
475
+ }
476
+
477
+ function setConfirmed(d: Draft, code: string, checked: boolean): void {
478
+ if (!d.confirm || !d.confirm.codes.includes(code)) return
479
+ const rest = d.confirm.checked.filter((c) => c !== code)
480
+ d.confirm.checked = checked ? [...rest, code] : rest
481
+ }
482
+
483
+ async function sendConfirmed(d: Draft): Promise<void> {
484
+ const confirm = d.confirm
485
+ if (!confirm || !confirm.codes.every((c) => confirm.checked.includes(c))) return
486
+ const target: Target = d === state.composer ? { kind: 'new' } : state.inline === d ? { kind: state.inline.kind, seq: state.inline.seq } : { kind: 'new' }
487
+ await send(d, target, [...confirm.codes])
488
+ }
489
+
490
+ async function remove(seq: number): Promise<boolean> {
491
+ const m = findMessage(state.thread, seq)
492
+ const key = `delete:${seq}`
493
+ if (!m || !settled(m) || state.busy[key]) return false
494
+ state.busy[key] = true
495
+ const changeId = newClientMsgId()
496
+ store.applyMessage({ type: 'delete', clientMsgId: changeId, seq })
497
+ const r = await client.messages.delete({ conversationId: m.conversationId, seq }, call)
498
+ delete state.busy[key]
499
+ if (disposed) return false
500
+ if (r.ok) {
501
+ store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message })
502
+ return true
503
+ }
504
+ store.applyMessage({ type: 'rollback', clientMsgId: changeId })
505
+ announce(failureText(r, lang))
506
+ refused(null, r)
507
+ return false
508
+ }
509
+
510
+ const reactionKey = (t: ReactionTarget): string => ('articleId' in t ? 'react:article' : `react:${t.seq}`)
511
+
512
+ async function react(target: ReactionTarget, emoji: string): Promise<void> {
513
+ const key = reactionKey(target)
514
+ if (!canWrite() || state.busy[key] || !state.thread.reactionSet.includes(emoji)) return
515
+ const before = reactionsFor(state.thread, target)
516
+ const next = before.mine === emoji ? null : emoji
517
+ state.busy[key] = true
518
+ store.applyReaction(target, reactionAfter(before, next))
519
+ const r = await client.reactions.set({ target, emoji: next }, call)
520
+ delete state.busy[key]
521
+ if (disposed) return
522
+ if (r.ok) {
523
+ store.applyReaction(target, r.value.reactions)
524
+ return
525
+ }
526
+ store.applyReaction(target, before)
527
+ announce(failureText(r, lang))
528
+ refused(null, r)
529
+ }
530
+
531
+ async function setBlocked(memberId: string, blocked: boolean): Promise<void> {
532
+ const key = `block:${memberId}`
533
+ if (state.busy[key]) return
534
+ state.busy[key] = true
535
+ const r = await client.blocks.set({ memberId, blocked }, call)
536
+ delete state.busy[key]
537
+ if (disposed) return
538
+ if (r.ok) {
539
+ store.collapse(memberId, r.value.blocked)
540
+ announce({ key: r.value.blocked ? 'discussion.blocked' : 'discussion.unblocked' })
541
+ return
542
+ }
543
+ announce(failureText(r, lang))
544
+ refused(null, r)
545
+ }
546
+
547
+ // ───────────────────────── reports ─────────────────────────
548
+
549
+ function openReport(m: DiscussionMessage): void {
550
+ state.report = { conversationId: m.conversationId, seq: m.seq, category: '', ruleId: '', note: '', rules: null, rulesFailed: false, sending: false, failure: null, done: false }
551
+ const report = state.report
552
+ void client.legal.current({ kind: 'rules', locale: lang }, call).then((r) => {
553
+ if (state.report !== report) return
554
+ if (r.ok) report.rules = communityRulesOf(r.value.bodyJson)
555
+ else report.rulesFailed = true
556
+ })
557
+ }
558
+
559
+ async function submitReport(): Promise<void> {
560
+ const report = state.report
561
+ if (!report || report.sending || report.done || report.category === '') return
562
+ if (report.category === 'rules' && report.ruleId === '') return
563
+ const note = normalizeBody(report.note)
564
+ if (codePointLength(note) > COMMUNITY_REPORT_NOTE_MAX) return
565
+ report.sending = true
566
+ report.failure = null
567
+ const r = await client.messages.report(
568
+ { conversationId: report.conversationId, seq: report.seq, category: report.category, ruleId: report.category === 'rules' ? report.ruleId : null, note: note === '' ? null : note },
569
+ call,
570
+ )
571
+ report.sending = false
572
+ if (disposed) return
573
+ if (r.ok) {
574
+ report.done = true
575
+ announce({ key: 'report.done' })
576
+ return
577
+ }
578
+ report.failure = r
579
+ if (r.code === 'surfaceOff') hide()
580
+ }
581
+
582
+ // ───────────────────────── onboarding ─────────────────────────
583
+
584
+ function beginOnboarding(): void {
585
+ if (onboarding.value || disposed || state.refusedAge !== null) return
586
+ const model = createOnboarding({
587
+ client,
588
+ lang,
589
+ signal: abort.signal,
590
+ onRefused: (minAge) => {
591
+ state.refusedAge = minAge
592
+ o.onAgeRefused?.(minAge)
593
+ },
594
+ onDone: (me) => {
595
+ state.me = me
596
+ onboarding.value = null
597
+ announce({ key: 'onboarding.done' })
598
+ void refresh()
599
+ },
600
+ onSignIn: () => {
601
+ onboarding.value = null
602
+ void refresh()
603
+ },
604
+ })
605
+ onboarding.value = model
606
+ void model.load()
607
+ }
608
+
609
+ return {
610
+ state,
611
+ store,
612
+ onboarding,
613
+ async start() {
614
+ await refresh()
615
+ startPolling()
616
+ },
617
+ refresh,
618
+ loadMore,
619
+ mode,
620
+ needs: () => viewerNeeds(state.thread),
621
+ isOwn,
622
+ canReply: (m) => {
623
+ if (!canWrite() || !settled(m) || m.state !== 'visible' || m.body === null) return false
624
+ if (m.replyToSeq === null) return true
625
+ const parent = findMessage(state.thread, m.replyToSeq)
626
+ return parent !== null && settled(parent) && parent.state === 'visible'
627
+ },
628
+ canEdit: (m) => canWrite() && isOwn(m) && settled(m) && m.body !== null && (m.state === 'visible' || m.state === 'held'),
629
+ canDelete: (m) => state.thread.viewer.memberId !== null && isOwn(m) && settled(m) && m.body !== null && m.state !== 'deleted',
630
+ canReact: canWrite,
631
+ canReport: (m) => !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.body !== null && m.author.kind !== 'system',
632
+ canReportAsNotifier: (m) => viewerNeeds(state.thread).signIn && settled(m) && m.body !== null && m.author.kind !== 'system',
633
+ canBlock: (m) =>
634
+ !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.author.kind === 'member' && m.author.memberId !== null,
635
+ setInView(v) {
636
+ const was = inView
637
+ inView = v
638
+ if (v && !was && poller && now() - lastSync >= interval) poller.now()
639
+ },
640
+ submit: () => send(state.composer, { kind: 'new' }),
641
+ openReply(seq, replyingTo = null) {
642
+ const m = findMessage(state.thread, seq)
643
+ if (!m) return
644
+ // Two levels: a reply to a reply goes under the top-level message.
645
+ const top = m.replyToSeq ?? m.seq
646
+ if (state.inline?.kind === 'reply' && state.inline.seq === top) {
647
+ state.inline.replyingTo = replyingTo
648
+ return
649
+ }
650
+ state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo }
651
+ },
652
+ openEdit(seq) {
653
+ const m = findMessage(state.thread, seq)
654
+ if (!m || m.body === null) return
655
+ state.inline = { ...emptyDraft(m.body), kind: 'edit', seq, replyingTo: null }
656
+ },
657
+ closeInline() {
658
+ state.inline = null
659
+ },
660
+ async submitInline() {
661
+ const d = state.inline
662
+ if (d) await send(d, { kind: d.kind, seq: d.seq })
663
+ },
664
+ setConfirmed,
665
+ sendConfirmed,
666
+ remove,
667
+ react,
668
+ setBlocked,
669
+ openReport,
670
+ closeReport() {
671
+ state.report = null
672
+ },
673
+ submitReport,
674
+ beginOnboarding,
675
+ cancelOnboarding() {
676
+ onboarding.value = null
677
+ },
678
+ waitLeft,
679
+ dispose() {
680
+ if (disposed) return
681
+ disposed = true
682
+ abort.abort()
683
+ poller?.stop()
684
+ poller = null
685
+ unsubscribe()
686
+ if (ticker) clearInterval(ticker)
687
+ ticker = null
688
+ onboarding.value = null
689
+ },
690
+ }
691
+ }