@blogmcp/community 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. package/dist/chunks/mount-DzjwnHyx.js +0 -4597
@@ -4,22 +4,41 @@
4
4
  * with its optimistic rows, the Russmedia confirmation and the cooldowns, reactions, blocks, reports, and the
5
5
  * inline onboarding. The element renders `controller.state`; tests drive it directly over a fake transport.
6
6
  *
7
+ * M3 (m3-contracts.md §5.3): the same brain under `<blogmcp-topic>` — a forum topic IS a discussion (its opening post is
8
+ * seq 1, its replies two levels deep), so createTopicThreadController runs every rule above over the SDK's topic store,
9
+ * `forum/topics/get` and `forum/topics/head`, posts by the topic's conversation id, and adds what a topic has of its
10
+ * own: a viewer the forum is not open to (`membersOnly`, with its `needs`) and a topic the viewer may not see
11
+ * (`notFound`) as states of the element, a write's `notFound` read again (settleTopicFailure: the forum's gate answers
12
+ * every refusal of a topic's id that way, A11), a closed topic, the forum's own brake beside the module's, the title of
13
+ * the opening post in its edit, and the art. 9 consent every forum write needs (U14). The article's own reactions are
14
+ * an article's only.
15
+ *
7
16
  * Every request goes through the SDK client (the host's relative base); nothing here builds a path.
8
17
  */
9
18
  import { reactive, shallowRef, type ShallowRef } from 'vue'
10
19
  import {
20
+ isKnownModuleState,
11
21
  COMMUNITY_MESSAGE_MAX,
12
22
  COMMUNITY_REPORT_NOTE_MAX,
23
+ communityMentionHandles,
13
24
  communityRulesOf,
14
25
  createDiscussionStore,
26
+ createTopicStore,
15
27
  findMessage,
28
+ forumTopicTitleOf,
16
29
  isOwnMessage,
30
+ isTopicOpen,
17
31
  loadMoreDiscussion,
32
+ loadMoreTopic,
18
33
  newClientMsgId,
19
34
  pollDiscussionHead,
35
+ pollTopicHead,
20
36
  reactionAfter,
21
37
  reactionsFor,
22
38
  refreshDiscussion,
39
+ refreshTopic,
40
+ settleTopicFailure,
41
+ surfaceInfoOf,
23
42
  viewerNeeds,
24
43
  type CaseCategory,
25
44
  type CommunityCallOptions,
@@ -31,18 +50,58 @@ import {
31
50
  type DiscussionMessage,
32
51
  type DiscussionState,
33
52
  type DiscussionStore,
53
+ type DiscussionViewer,
34
54
  type KnownAuthor,
55
+ type KnownBrakeSurface,
56
+ type MessageChange,
35
57
  type MyState,
58
+ type ReactionSummary,
36
59
  type ReactionTarget,
60
+ type RealtimeEvent,
61
+ type RealtimeEventOutcome,
62
+ type Topic,
63
+ type TopicHead,
64
+ type TopicStore,
65
+ type TopicStoreState,
37
66
  type ViewerNeeds,
38
67
  } from '@blogmcp/sdk/community'
39
68
  import { warnOnce } from './client'
40
69
  import type { Lang, TextRef } from './i18n'
41
70
  import { createOnboarding, type OnboardingModel } from './onboarding'
42
71
  import { failureEffect, failureText, waitUntilOf } from './outcomes'
72
+ import type { DiscussionRealtime, DiscussionRealtimeSession, TopicRealtime } from './realtime'
43
73
  import { codePointLength, normalizeBody } from './text'
44
74
 
45
- export type DiscussionPhase = 'loading' | 'ready' | 'failed' | 'hidden'
75
+ /**
76
+ * `membersOnly` (M3, a topic): the forum is not open to this viewer — `state.failure.needs` says what a member session
77
+ * still lacks (none: a visitor, sign in); `notFound` (M3, a topic): the topic is not there for this viewer.
78
+ */
79
+ export type DiscussionPhase = 'loading' | 'ready' | 'failed' | 'hidden' | 'membersOnly' | 'notFound'
80
+
81
+ /** What a discussion and a topic have in common, as the controller and the views read them (the SDK's two states). */
82
+ export interface ThreadState {
83
+ readonly conversationId: string | null
84
+ readonly count: number
85
+ readonly lastSeq: number
86
+ readonly lastChangeAt: string | null
87
+ readonly messages: readonly DiscussionMessage[]
88
+ readonly nextCursor: number | null
89
+ readonly reactionSet: readonly string[]
90
+ readonly preset: string | null
91
+ readonly viewer: DiscussionViewer
92
+ readonly loaded: boolean
93
+ readonly stale: boolean
94
+ }
95
+
96
+ /** The store under a thread, as far as the controller uses it: a discussion's, or a topic's. */
97
+ interface ThreadStore<S extends ThreadState> {
98
+ getState(): S
99
+ subscribe(listener: (state: S) => void): () => void
100
+ applyMessage(change: MessageChange): void
101
+ applyReaction(target: ReactionTarget, reactions: ReactionSummary): void
102
+ collapse(memberId: string, blocked: boolean): void
103
+ applyEvent(event: RealtimeEvent): RealtimeEventOutcome
104
+ }
46
105
 
47
106
  /** One composer's text and what happened to it. */
48
107
  export interface Draft {
@@ -66,6 +125,11 @@ export interface InlineDraft extends Draft {
66
125
  seq: number
67
126
  /** A reply to a reply names whom it answers. */
68
127
  replyingTo: string | null
128
+ /**
129
+ * M3: the topic's title, on its author's edit of the opening post (seq 1) — sent with the body when it changed (one
130
+ * edit of the opening post, §2.5); null everywhere else.
131
+ */
132
+ title: string | null
69
133
  }
70
134
 
71
135
  export interface ReportDraft {
@@ -86,22 +150,44 @@ export interface ReportDraft {
86
150
  * onboarding waits); `ageRefused` (the age band declared in it was below the minimum: reading only, the
87
151
  * onboarding is not offered again); `sanction` (a restriction, suspension or ban in effect); `notMoved` (the
88
152
  * discussion takes no writes yet — one sentence); `restricted` (the member is read-only or deleting their
89
- * profile); `pending` (being decided).
153
+ * profile); `braked` (M2: the community is paused — `me/get`'s `module.brake`, or a write refused BRAKED: reading
154
+ * goes on, every member write waits — one sentence); `pending` (being decided).
155
+ *
156
+ * A module that is `read_only` changes nothing here (m2-contracts.md §3.1: the discussions under articles keep
157
+ * taking writes; only live updates, push and the Pro surfaces stop) — the discussion polls instead of a socket.
90
158
  */
91
- export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'pending'
159
+ export type ComposerMode =
160
+ | 'write'
161
+ | 'signIn'
162
+ | 'onboarding'
163
+ | 'ageRefused'
164
+ | 'sanction'
165
+ | 'notMoved'
166
+ | 'restricted'
167
+ | 'braked'
168
+ | 'pending'
169
+ /** M3, a topic: closed (`locked`, `archived`, or a state this SDK does not know) — the composer gives way to a sentence. */
170
+ | 'locked'
171
+ /** M3, a topic: the module is `read_only` — the forum is one of its features and takes no writes meanwhile. */
172
+ | 'readOnly'
173
+ /** M3, a topic: the viewer reads only (the team at the keyboard: it writes through the KB admin) — nothing is shown. */
174
+ | 'none'
92
175
 
93
- export interface DiscussionUi {
176
+ export interface ThreadUi<S extends ThreadState> {
94
177
  phase: DiscussionPhase
95
- /** Why the first load failed (`failed`). */
178
+ /** Why the first load failed (`failed`), or why the topic is not shown (`membersOnly`, `notFound`). */
96
179
  failure: CommunityFailure | null
97
- thread: DiscussionState
180
+ thread: S
98
181
  loadingMore: boolean
99
182
  refreshing: boolean
100
183
  /** A head poll saw messages the page does not show yet. */
101
184
  newMessages: boolean
102
185
  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
186
+ /**
187
+ * What a write or the member's state taught: no writes yet, none for this member, or a pause (BRAKED); M3, a topic:
188
+ * the module is `read_only` (the forum is a Pro surface).
189
+ */
190
+ block: 'notMoved' | 'restricted' | 'braked' | 'readOnly' | null
105
191
  composer: Draft
106
192
  inline: InlineDraft | null
107
193
  report: ReportDraft | null
@@ -117,11 +203,24 @@ export interface DiscussionUi {
117
203
  * (§7.4.2), so the element does: the onboarding is not offered again while it lives.
118
204
  */
119
205
  refusedAge: number | null
206
+ /**
207
+ * M2 T9: the onboarding just finished with the art. 9 consent refused (`public_write`): the composer says once,
208
+ * in view, what the refusal leaves (writing in the public discussion). Not kept beyond this element.
209
+ */
210
+ art9RefusedNote: boolean
211
+ /**
212
+ * M2 (§3.5): the live updates' session fell back to polling (live updates are off here, or the server cut the
213
+ * socket for good — 4501): the discussion says so once and keeps polling; it never opens the socket again itself.
214
+ */
215
+ realtimeOff: boolean
120
216
  }
121
217
 
122
- export interface DiscussionControllerOptions {
218
+ /** `<blogmcp-discussion>`'s state: an article's discussion. */
219
+ export type DiscussionUi = ThreadUi<DiscussionState>
220
+
221
+ /** What both controllers take besides what their thread is (an article, a topic). */
222
+ interface ThreadControllerOptions {
123
223
  client: CommunityClient
124
- articleId: string
125
224
  lang: Lang
126
225
  /** Head poll interval (default 20 s; 0 = no polling). */
127
226
  pollMs?: number
@@ -134,9 +233,25 @@ export interface DiscussionControllerOptions {
134
233
  onAgeRefused?(minAge: number): void
135
234
  }
136
235
 
137
- export interface DiscussionController {
138
- readonly state: DiscussionUi
139
- readonly store: DiscussionStore
236
+ export interface DiscussionControllerOptions extends ThreadControllerOptions {
237
+ articleId: string
238
+ /**
239
+ * M2 (§3.5, §3.10): the host's live-updates session factory (`createRealtimeSession` of
240
+ * `@blogmcp/sdk/community/realtime`). Absent: the head poll, as before.
241
+ */
242
+ realtime?: DiscussionRealtime | null
243
+ }
244
+
245
+ /** M3: `<blogmcp-topic>`'s thread — a forum topic by its conversation id. */
246
+ export interface TopicThreadControllerOptions extends ThreadControllerOptions {
247
+ conversationId: string
248
+ /** The host's live-updates session factory over the topic's store (`createRealtimeSession`). Absent: the head poll. */
249
+ realtime?: TopicRealtime | null
250
+ }
251
+
252
+ /** What a thread's controller does — the article's discussion's and a topic's alike; the views read it. */
253
+ export interface ThreadController<S extends ThreadState = ThreadState> {
254
+ readonly state: ThreadUi<S>
140
255
  readonly onboarding: ShallowRef<OnboardingModel | null>
141
256
  /** The first load, then the poll. */
142
257
  start(): Promise<void>
@@ -172,26 +287,85 @@ export interface DiscussionController {
172
287
  openReport(m: DiscussionMessage): void
173
288
  closeReport(): void
174
289
  submitReport(): Promise<void>
175
- beginOnboarding(): void
290
+ /**
291
+ * The onboarding inline; `offerArt9` asks the art. 9 consent also of a member who refused it (`public_write`, M2 T9) —
292
+ * the give-consent link on their own message held until the consent. M3, a topic: the art. 9 step is asked before a
293
+ * write whatever the tenant asks elsewhere (`forceArt9`, implied), but not to read the forum (`toRead`).
294
+ */
295
+ beginOnboarding(options?: OnboardingStart): void
176
296
  cancelOnboarding(): void
177
297
  /** Seconds a draft still waits (0 when it may go). */
178
298
  waitLeft(draft: Draft): number
299
+ /**
300
+ * M2 T10: the distinct `@handle`s one message of the viewer may carry (me/get's `caps.mentionsPerMessage`), or null
301
+ * when nothing is known to limit it (no member yet, an older kb-api, a module whose mentions notify nobody).
302
+ */
303
+ mentionCap(): number | null
304
+ /** M3: the edit of this message carries the topic's title (its author's opening post of a topic). */
305
+ editsTitle(m: Pick<DiscussionMessage, 'seq' | 'author'>): boolean
179
306
  dispose(): void
180
307
  }
181
308
 
309
+ /** `<blogmcp-discussion>`'s controller: an article's discussion over the SDK's discussion store. */
310
+ export interface DiscussionController extends ThreadController<DiscussionState> {
311
+ readonly store: DiscussionStore
312
+ }
313
+
314
+ /** M3: `<blogmcp-topic>`'s thread over the SDK's topic store (its header is `state.thread.topic`). */
315
+ export interface TopicThreadController extends ThreadController<TopicStoreState> {
316
+ readonly store: TopicStore
317
+ }
318
+
182
319
  const emptyDraft = (body = ''): Draft => ({ body, sending: false, failure: null, confirm: null, waitUntil: null, clientMsgId: null })
183
320
 
321
+ const NO_REACTIONS: ReactionSummary = Object.freeze({ counts: Object.freeze({}), mine: null })
322
+
184
323
  const documentVisible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
185
324
 
325
+ /** The opening post of a topic (its title travels with it). */
326
+ const OPENING_SEQ = 1
327
+
328
+ /** How the inline onboarding starts (beginOnboarding). */
329
+ export interface OnboardingStart {
330
+ /** The give-consent flow (M2 T9): the art. 9 step for a member who refused it, no refusal offered. */
331
+ offerArt9?: boolean
332
+ /** M3: the art. 9 step whatever the tenant asks elsewhere — a forum write's refusal named it (U14). */
333
+ forceArt9?: boolean
334
+ /** M3, a topic: the onboarding the forum asks only to be read (the members-only state) — no art. 9 step forced. */
335
+ toRead?: boolean
336
+ }
337
+
338
+ /** What the thread is: an article's discussion, or a forum topic. */
339
+ type Subject = { readonly kind: 'article'; readonly id: string } | { readonly kind: 'topic'; readonly id: string }
340
+
341
+ /** `<blogmcp-discussion>`'s controller over one article's discussion. */
186
342
  export function createDiscussionController(o: DiscussionControllerOptions): DiscussionController {
343
+ return createThread(o, { kind: 'article', id: o.articleId.toLowerCase() }, o.realtime ?? null) as unknown as DiscussionController
344
+ }
345
+
346
+ /** M3: `<blogmcp-topic>`'s thread over one forum topic (m3-contracts.md §3.1–§3.3, §5.3). */
347
+ export function createTopicThreadController(o: TopicThreadControllerOptions): TopicThreadController {
348
+ return createThread(o, { kind: 'topic', id: o.conversationId.toLowerCase() }, o.realtime ?? null) as unknown as TopicThreadController
349
+ }
350
+
351
+ /** A session factory of either kind, as the thread calls it (the public options type each one). */
352
+ type AnyRealtime = (options: never) => DiscussionRealtimeSession
353
+
354
+ function createThread(o: ThreadControllerOptions, subject: Subject, realtime: AnyRealtime | null): ThreadController<ThreadState> & { store: ThreadStore<ThreadState> } {
187
355
  const { client, lang } = o
188
- const articleId = o.articleId.toLowerCase()
356
+ const topicMode = subject.kind === 'topic'
357
+ /** An article's id or a topic's conversation id, lower case: what the heads and the posts name. */
358
+ const threadKey = subject.id
359
+ /** The surface whose own brake pauses this thread's writes (beside the module's, which pauses both). */
360
+ const surface: KnownBrakeSurface = topicMode ? 'forum' : 'article_discussions'
189
361
  const now = o.now ?? Date.now
190
- const store = createDiscussionStore()
362
+ const discussionStore = topicMode ? null : createDiscussionStore()
363
+ const topicStore = topicMode ? createTopicStore() : null
364
+ const store = (discussionStore ?? topicStore) as unknown as ThreadStore<ThreadState>
191
365
  const abort = new AbortController()
192
366
  const call: CommunityCallOptions = { signal: abort.signal }
193
367
 
194
- const state = reactive<DiscussionUi>({
368
+ const state = reactive<ThreadUi<ThreadState>>({
195
369
  phase: 'loading',
196
370
  failure: null,
197
371
  thread: store.getState(),
@@ -208,19 +382,28 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
208
382
  clock: now(),
209
383
  busy: {},
210
384
  refusedAge: o.refusedAge ?? null,
211
- }) as DiscussionUi
385
+ art9RefusedNote: false,
386
+ realtimeOff: false,
387
+ }) as ThreadUi<ThreadState>
212
388
  const unsubscribe = store.subscribe((s) => {
213
389
  state.thread = s
214
390
  })
215
391
  const onboarding = shallowRef<OnboardingModel | null>(null)
392
+ /** The open onboarding is the give-consent flow (read with `onboarding`, which it is set beside). */
393
+ let consentFlow = false
216
394
 
217
395
  let poller: DiscussionHeadPoller | null = null
396
+ let session: DiscussionRealtimeSession | null = null
218
397
  let inView = true
219
398
  let disposed = false
220
399
  let lastSync = 0
221
400
  let refreshing: Promise<void> | null = null
222
401
  let mePromise: Promise<void> | null = null
223
402
  let ticker: ReturnType<typeof setInterval> | null = null
403
+ /** The brake's end (its `until`, or a BRAKED refusal's): me/get is read again then. */
404
+ let brakeTimer: ReturnType<typeof setTimeout> | null = null
405
+ /** A BRAKED refusal's own end (epoch ms), while `state.block` is `braked` because of it; null: none given. */
406
+ let refusedBrakeUntil: number | null = null
224
407
  const interval = o.pollMs ?? 20_000
225
408
 
226
409
  function announce(text: TextRef): void {
@@ -232,35 +415,112 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
232
415
  state.phase = 'hidden'
233
416
  poller?.stop()
234
417
  poller = null
418
+ session?.stop()
419
+ session = null
235
420
  }
236
421
 
237
422
  // ───────────────────────── loading ─────────────────────────
238
423
 
239
- function ensureMe(): Promise<void> {
240
- if (state.me) return Promise.resolve()
424
+ /** me/get once (`fresh`: again, keeping what is shown until the answer is in — the composer does not flicker). */
425
+ function ensureMe(fresh = false): Promise<void> {
426
+ if (state.me && !fresh) return Promise.resolve()
241
427
  mePromise ??= client.me.get(call).then((r) => {
242
428
  mePromise = null
243
- if (r.ok && !disposed) state.me = r.value
429
+ if (!r.ok || disposed) return
430
+ state.me = r.value
431
+ scheduleBrakeEnd()
244
432
  })
245
433
  return mePromise
246
434
  }
247
435
 
248
- /** canPost is false, nothing is needed and no sanction applies: the member's status says whether it is the cutover. */
436
+ /** This thread's surface's own brake as me/get states it (M3, §4.9: it always ends), or null. */
437
+ const surfaceBrakeOf = (me: MyState | null) => surfaceInfoOf(me?.module, surface)?.brake ?? null
438
+
439
+ /**
440
+ * The end of a brake as me/get states it (`module.brake.until`, or `module.until` of a braked module — and M3 this
441
+ * thread's surface's own brake, the later of the two), epoch ms.
442
+ */
443
+ function brakeEndOf(me: MyState | null): number | null {
444
+ const m = me?.module
445
+ if (!m) return null
446
+ const until = m.brake ? m.brake.until : isKnownModuleState(m.state) && m.state === 'braked' ? m.until : undefined
447
+ const at = typeof until === 'string' ? Date.parse(until) : NaN
448
+ const own = surfaceBrakeOf(me)
449
+ const ownAt = own ? Date.parse(own.until) : NaN
450
+ const ends = [at, ownAt].filter((x) => Number.isFinite(x))
451
+ return ends.length > 0 ? Math.max(...ends) : null
452
+ }
453
+
454
+ /**
455
+ * While the discussion is braked, a timer at the brake's end (the later of me/get's `until` and a refusal's): then
456
+ * me/get is read again and the composer comes back when the brake is over. A brake without an end waits for the
457
+ * next refresh (afterLoad reads me/get again while braked).
458
+ */
459
+ function scheduleBrakeEnd(): void {
460
+ if (brakeTimer) clearTimeout(brakeTimer)
461
+ brakeTimer = null
462
+ if (disposed) return
463
+ const ends: number[] = []
464
+ if (brakedNow()) {
465
+ const at = brakeEndOf(state.me)
466
+ if (at !== null) ends.push(at)
467
+ }
468
+ if (state.block === 'braked' && refusedBrakeUntil !== null) ends.push(refusedBrakeUntil)
469
+ if (ends.length === 0) return
470
+ // A second after the end (clocks differ by a little), within setTimeout's range; an end me/get still reports
471
+ // after it passed (the brake not lifted yet on kb-api's side) is asked again every 30 s, never in a tight loop.
472
+ const left = Math.max(...ends) - now()
473
+ const delay = left > 0 ? Math.min(left + 1000, 2_147_000_000) : 30_000
474
+ brakeTimer = setTimeout(() => {
475
+ brakeTimer = null
476
+ if (disposed) return
477
+ liftRefusedBrake()
478
+ void ensureMe(true)
479
+ }, delay)
480
+ }
481
+
482
+ /** A BRAKED refusal's block ends once its own end has passed (or it gave none): me/get decides from then on. */
483
+ function liftRefusedBrake(): void {
484
+ if (state.block !== 'braked') return
485
+ if (refusedBrakeUntil !== null && refusedBrakeUntil > now()) return
486
+ state.block = null
487
+ refusedBrakeUntil = null
488
+ }
489
+
490
+ /**
491
+ * canPost is false, nothing is needed and no sanction applies: a brake (M2 T10: kb-api's canPost is false while one
492
+ * is in effect — me/get, read again, names it, and the composer gives way to the brake's sentence), else the
493
+ * member's status says whether it is the cutover.
494
+ */
249
495
  function decideBlock(): void {
250
496
  const s = store.getState()
251
497
  if (s.viewer.canPost || viewerNeeds(s).any || s.viewer.restrictions.length > 0) return
252
- void ensureMe().then(() => {
498
+ // M3, a topic: a closed topic or a reader who writes elsewhere (the team) says so by itself (mode()).
499
+ if (topicMode && (s.viewer.memberId === null || !topicIsOpen())) return
500
+ void ensureMe(state.block === null).then(() => {
253
501
  const me = state.me
254
502
  if (disposed || !me || store.getState().viewer.canPost) return
503
+ if (brakedNow()) return
504
+ if (topicMode) {
505
+ // The forum is a feature of the module: `read_only` closes it (§3.1 step 5); else the member is read-only.
506
+ state.block = isKnownModuleState(me.module?.state) && me.module?.state === 'read_only' ? 'readOnly' : me.member && me.member.status !== 'active' ? 'restricted' : null
507
+ return
508
+ }
255
509
  // An active member who may not post: the discussion takes no writes yet (the cutover).
256
510
  state.block = !me.member || me.member.status === 'active' ? 'notMoved' : 'restricted'
257
511
  })
258
512
  }
259
513
 
514
+ /** M3, a topic: it takes new messages (`open`; a closed or unknown state does not). Always for an article. */
515
+ const topicIsOpen = (): boolean => !topicMode || isTopicOpen(topicStore!.getState().topic)
516
+
260
517
  function afterLoad(): void {
261
518
  const s = store.getState()
262
- if (s.viewer.canPost) state.block = null
263
- if (s.viewer.memberId !== null) void ensureMe()
519
+ // A braked discussion asks me/get again on every refresh: the brake may be over (kb-api's canPost does not know it).
520
+ const wasBraked = state.block === 'braked' || brakedNow()
521
+ if (s.viewer.canPost && state.block !== 'braked') state.block = null
522
+ if (wasBraked) liftRefusedBrake()
523
+ if (s.viewer.memberId !== null) void ensureMe(wasBraked)
264
524
  decideBlock()
265
525
  // Finished elsewhere (another tab): nothing left to onboard.
266
526
  if (onboarding.value && !viewerNeeds(s).any && onboarding.value.state.phase !== 'sending') onboarding.value = null
@@ -268,6 +528,17 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
268
528
 
269
529
  function loadFailed(f: CommunityFailure): void {
270
530
  if (f.code === 'surfaceOff') return hide()
531
+ if (topicMode && (f.code === 'membersOnly' || f.code === 'notFound')) {
532
+ // M3: the forum is not open to this viewer (the members-only sentence, with its sign-in or onboarding), or the
533
+ // topic is not there for them — whatever was shown before (a topic can stop being readable while it is open).
534
+ poller?.stop()
535
+ poller = null
536
+ session?.stop()
537
+ session = null
538
+ state.phase = f.code
539
+ state.failure = f
540
+ return
541
+ }
271
542
  if (f.code === 'notFound' && state.phase !== 'ready') {
272
543
  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
544
  return hide()
@@ -279,11 +550,17 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
279
550
  // A loaded discussion keeps what it shows; the poll tries again.
280
551
  }
281
552
 
553
+ /** The thread's pages, from the first (refresh) or after the loaded ones (load more). */
554
+ const readPages = (more: boolean) =>
555
+ topicMode
556
+ ? (more ? loadMoreTopic : refreshTopic)(topicStore!, client, { conversationId: threadKey, languageCode: lang }, call)
557
+ : (more ? loadMoreDiscussion : refreshDiscussion)(discussionStore!, client, { articleId: threadKey, languageCode: lang }, call)
558
+
282
559
  function refresh(): Promise<void> {
283
560
  if (disposed) return Promise.resolve()
284
561
  refreshing ??= (async () => {
285
562
  state.refreshing = true
286
- const r = await refreshDiscussion(store, client, { articleId, languageCode: lang }, call)
563
+ const r = await readPages(false)
287
564
  state.refreshing = false
288
565
  if (disposed) return
289
566
  lastSync = now()
@@ -304,23 +581,33 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
304
581
  async function loadMore(): Promise<void> {
305
582
  if (state.loadingMore || state.thread.nextCursor === null) return
306
583
  state.loadingMore = true
307
- const r = await loadMoreDiscussion(store, client, { articleId, languageCode: lang }, call)
584
+ const r = await readPages(true)
308
585
  state.loadingMore = false
309
586
  if (disposed || r.ok) return
310
587
  if (r.code === 'surfaceOff') return hide()
588
+ if (topicMode && (r.code === 'membersOnly' || r.code === 'notFound')) return loadFailed(r)
311
589
  announce(failureText(r, lang))
312
590
  }
313
591
 
314
- function onHeads(heads: DiscussionHead[]): void {
592
+ /** A head's thread (an article's id, or a topic's conversation id), lower case; null for none. */
593
+ const headKey = (head: DiscussionHead | TopicHead | null | undefined): string | null => {
594
+ const id = topicMode ? (head as TopicHead | null | undefined)?.conversationId : (head as DiscussionHead | null | undefined)?.articleId
595
+ return typeof id === 'string' ? id.toLowerCase() : null
596
+ }
597
+
598
+ const applyHead = (head: DiscussionHead | TopicHead): boolean =>
599
+ topicMode ? topicStore!.applyHead(head as TopicHead) : discussionStore!.applyHead(head as DiscussionHead)
600
+
601
+ function onHeads(heads: ReadonlyArray<DiscussionHead | TopicHead>): void {
315
602
  for (const head of heads) {
316
- if (typeof head?.articleId !== 'string' || head.articleId.toLowerCase() !== articleId) continue
603
+ if (headKey(head) !== threadKey) continue
317
604
  if (state.phase === 'failed') {
318
605
  void refresh()
319
606
  continue
320
607
  }
321
608
  const before = store.getState()
322
609
  const newer = typeof head.lastSeq === 'number' && head.lastSeq > before.lastSeq
323
- if (!store.applyHead(head)) continue
610
+ if (!applyHead(head)) continue
324
611
  // New messages wait for the reader (the page does not move under them); an edit, a reaction or the
325
612
  // reader's own post are folded in at once.
326
613
  if (newer && before.loaded) state.newMessages = true
@@ -330,15 +617,63 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
330
617
 
331
618
  function startPolling(): void {
332
619
  if (poller || interval === 0 || disposed || state.phase === 'hidden') return
333
- poller = pollDiscussionHead(client, [articleId], {
620
+ const options = {
334
621
  intervalMs: interval,
335
622
  visible: () => documentVisible() && inView,
336
623
  onChange: onHeads,
337
- onFailure: (f) => {
624
+ onFailure: (f: CommunityFailure) => {
338
625
  if (f.code === 'surfaceOff') hide()
626
+ // M3: the forum closed to this viewer meanwhile (pollTopicHead stops on it): the members-only sentence.
627
+ else if (topicMode && f.code === 'membersOnly') loadFailed(f)
339
628
  },
340
629
  signal: abort.signal,
341
- })
630
+ }
631
+ poller = topicMode ? pollTopicHead(client, [threadKey], options) : pollDiscussionHead(client, [threadKey], options)
632
+ }
633
+
634
+ /**
635
+ * Live updates (M2, §3.5): the host's session over this element's store, after the first list. Not for an anonymous
636
+ * reader (no host session: the token route refuses it) nor while `me/get` says the module offers none (`read_only`,
637
+ * `dark`, live updates not configured) — the head poll then, and no socket. The session falls back to polling by
638
+ * itself (`realtimeOff`, the server's terminal 4501) and the element never starts it again.
639
+ */
640
+ async function startRealtime(factory: AnyRealtime): Promise<void> {
641
+ if (session || disposed || state.phase === 'hidden') return
642
+ if (viewerNeeds(store.getState()).signIn) return startPolling()
643
+ await ensureMe()
644
+ // The discussion may have been hidden (SURFACE_OFF) while me/get was on its way.
645
+ if (disposed || session || (state.phase as DiscussionPhase) === 'hidden') return
646
+ if (state.me?.module && state.me.module.features.realtime !== true) return startPolling()
647
+ try {
648
+ session = (factory as unknown as DiscussionRealtime)({
649
+ client,
650
+ store: (discussionStore ?? topicStore) as unknown as DiscussionStore,
651
+ languageCode: lang,
652
+ visible: () => documentVisible() && inView,
653
+ ...(interval > 0 ? { pollIntervalMs: interval } : {}),
654
+ onMode: (mode) => {
655
+ if (disposed) return
656
+ // Said once per fallback: live again (the SDK session reconnected) takes the sentence away.
657
+ if (mode === 'polling' && !state.realtimeOff) state.realtimeOff = true
658
+ else if (mode === 'live' && state.realtimeOff) state.realtimeOff = false
659
+ },
660
+ })
661
+ } catch {
662
+ warnOnce('realtime', '[blogmcp-community] the realtime property is not a session factory (createRealtimeSession of @blogmcp/sdk/community/realtime): the discussion polls')
663
+ session = null
664
+ startPolling()
665
+ }
666
+ }
667
+
668
+ /**
669
+ * The member's community is paused (`me/get`'s module.brake or state, M2 §3.1): every member write is refused — and
670
+ * M3 (§4.9) this thread's surface's own brake pauses its writes too (the forum's for a topic, the article
671
+ * discussions' for an article), never the other surface's.
672
+ */
673
+ function brakedNow(): boolean {
674
+ const m = state.me?.module
675
+ if (!m) return false
676
+ return m.brake !== undefined || (isKnownModuleState(m.state) && m.state === 'braked') || surfaceBrakeOf(state.me) !== null
342
677
  }
343
678
 
344
679
  // ───────────────────────── who may do what ─────────────────────────
@@ -347,14 +682,37 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
347
682
  const s = state.thread
348
683
  const n = viewerNeeds(s)
349
684
  if (n.signIn) return 'signIn'
685
+ if (topicMode) {
686
+ // M3: the team at the keyboard reads and writes through the KB admin; a closed topic takes no message (§1.1).
687
+ if (s.viewer.memberId === null) return 'none'
688
+ if (!topicIsOpen()) return 'locked'
689
+ if (state.block === 'readOnly') return 'readOnly'
690
+ }
350
691
  if (state.block === 'notMoved') return 'notMoved'
692
+ if (state.block === 'braked' || brakedNow()) return 'braked'
351
693
  if (!s.viewer.canPost && s.viewer.restrictions.length > 0) return 'sanction'
352
694
  // A refused age band: the refusal stays where the composer was once the reader closed it.
353
695
  if (n.any) return state.refusedAge !== null && !onboarding.value ? 'ageRefused' : 'onboarding'
696
+ // The give-consent flow of a member who refused the consent (needs name none): its step where the composer was.
697
+ if (onboarding.value && consentFlow) return 'onboarding'
354
698
  if (state.block === 'restricted') return 'restricted'
355
699
  return s.viewer.canPost ? 'write' : 'pending'
356
700
  }
357
701
 
702
+ /** me/get's per-message mention cap, where kb-api applies it (a member, the module live or braked: §3.3). */
703
+ function mentionCap(): number | null {
704
+ const me = state.me
705
+ const cap = me?.caps?.mentionsPerMessage
706
+ const m = me?.module?.state
707
+ if (!me?.member || typeof cap !== 'number' || !Number.isSafeInteger(cap) || cap < 0) return null
708
+ return m === 'live' || m === 'braked' ? cap : null
709
+ }
710
+ /** The draft mentions more distinct handles than the cap (kb-api's count: as typed, whether they exist or not). */
711
+ const overMentionCap = (body: string): boolean => {
712
+ const cap = mentionCap()
713
+ return cap !== null && communityMentionHandles(body).length > cap
714
+ }
715
+
358
716
  const isOwn = (m: Pick<DiscussionMessage, 'author'>): boolean => isOwnMessage(state.thread, m)
359
717
  const settled = (m: DiscussionMessage): boolean => !m.pending && m.seq > 0
360
718
  const canWrite = (): boolean => mode() === 'write'
@@ -399,9 +757,30 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
399
757
 
400
758
  const waitLeft = (d: Draft): number => (d.waitUntil === null ? 0 : Math.max(0, Math.ceil((d.waitUntil - Math.max(state.clock, now())) / 1000)))
401
759
 
760
+ /**
761
+ * M3 (§3.1, A11): a write addressed by the topic's id that comes back `notFound` may be any refusal of the forum's
762
+ * gate — the topic is read again (settleTopicFailure), and a refusal of that read is the element's state (the
763
+ * members-only sentence, the topic gone, nothing for SURFACE_OFF); a topic that reads fine leaves the write's own
764
+ * `notFound` with its draft (the message it named is gone).
765
+ */
766
+ async function settleTopicWrite(d: Draft | null, f: CommunityFailure): Promise<void> {
767
+ const settled = await settleTopicFailure(topicStore!, client, { conversationId: threadKey, languageCode: lang }, f, call)
768
+ if (disposed) return
769
+ if (settled === f) {
770
+ afterLoad()
771
+ return
772
+ }
773
+ if (d && d.failure === f) d.failure = settled
774
+ loadFailed(settled)
775
+ }
776
+
402
777
  /** What a refused write does besides showing its sentence. */
403
778
  function refused(d: Draft | null, f: CommunityFailure): void {
404
779
  if (d) d.failure = f
780
+ if (topicMode && f.code === 'notFound') {
781
+ void settleTopicWrite(d, f)
782
+ return
783
+ }
405
784
  switch (failureEffect(f.code)) {
406
785
  case 'confirm':
407
786
  if (d && f.codes && f.codes.length > 0) d.confirm = { codes: [...f.codes], checked: [] }
@@ -409,11 +788,19 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
409
788
  case 'wait':
410
789
  if (d) {
411
790
  d.waitUntil = waitUntilOf(f, now())
412
- startClock()
791
+ if (d.waitUntil !== null) startClock()
792
+ }
793
+ if (f.code === 'braked') {
794
+ // Paused (BRAKED): the composer gives way to the sentence until the brake's end; me/get is read again.
795
+ state.block = 'braked'
796
+ refusedBrakeUntil = waitUntilOf(f, now())
797
+ scheduleBrakeEnd()
798
+ void ensureMe(true)
413
799
  }
414
800
  return
415
801
  case 'onboard':
416
- beginOnboarding()
802
+ // M3: a forum write needs the art. 9 consent whatever the tenant asks elsewhere (U14): its refusal names it.
803
+ beginOnboarding(f.needs?.includes('art9_consent') ? { forceArt9: true } : {})
417
804
  void refresh()
418
805
  return
419
806
  case 'reload':
@@ -423,7 +810,8 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
423
810
  state.block = 'notMoved'
424
811
  return
425
812
  case 'hide':
426
- return hide()
813
+ // Live updates switched off hide nothing: the discussion polls (no write answers it; a realtime route does).
814
+ return f.code === 'realtimeOff' ? undefined : hide()
427
815
  default:
428
816
  return
429
817
  }
@@ -440,6 +828,20 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
440
828
  if (n.any) return beginOnboarding()
441
829
  const parent = target.kind === 'new' ? null : findMessage(state.thread, target.seq)
442
830
  if (target.kind !== 'new' && (!parent || !settled(parent))) return
831
+ // Too many mentions for the member's level: said before posting (the composer shows it as it is typed); kb-api's
832
+ // MENTION_LIMIT stays the rule.
833
+ if (overMentionCap(body)) {
834
+ d.failure = { ok: false, code: 'mentionLimit' }
835
+ return
836
+ }
837
+ // M3: the opening post's edit carries the title when it changed — one the schema takes (1..200 UTF-16 units).
838
+ let title: string | undefined
839
+ const inlineTitle = target.kind === 'edit' && state.inline === d ? state.inline.title : null
840
+ if (inlineTitle !== null) {
841
+ const next = forumTopicTitleOf(inlineTitle)
842
+ if (next === null) return
843
+ if (next !== topicStore?.getState().topic?.title) title = next
844
+ }
443
845
 
444
846
  d.sending = true
445
847
  d.failure = null
@@ -449,17 +851,23 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
449
851
  if (target.kind === 'edit') store.applyMessage({ type: 'edit', clientMsgId: changeId, seq: target.seq, body })
450
852
  else if (author) store.applyMessage({ type: 'post', clientMsgId: changeId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, author, languageCode: lang })
451
853
 
854
+ const where = topicMode ? { conversationId: threadKey } : { articleId: threadKey }
452
855
  const r =
453
856
  target.kind === 'edit'
454
- ? await client.messages.edit({ conversationId: parent!.conversationId, seq: target.seq, body, ...(confirmations ? { confirmations } : {}) }, call)
857
+ ? await client.messages.edit(
858
+ { conversationId: parent!.conversationId, seq: target.seq, body, ...(title !== undefined ? { title } : {}), ...(confirmations ? { confirmations } : {}) },
859
+ call,
860
+ )
455
861
  : await client.messages.post(
456
- { articleId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, clientMsgId: changeId, languageCode: lang, ...(confirmations ? { confirmations } : {}) },
862
+ { ...where, body, replyToSeq: target.kind === 'reply' ? target.seq : null, clientMsgId: changeId, languageCode: lang, ...(confirmations ? { confirmations } : {}) },
457
863
  call,
458
864
  )
459
865
  if (disposed) return
460
866
  d.sending = false
461
867
  if (r.ok) {
462
868
  store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message, warnings: r.value.warnings })
869
+ // M3: a title edit answers the topic with its new title (§3.3).
870
+ if (r.value.topic && topicStore) topicStore.applyTopic(r.value.topic as Topic)
463
871
  const held = r.value.message.state === 'held'
464
872
  d.body = ''
465
873
  d.clientMsgId = null
@@ -509,10 +917,20 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
509
917
 
510
918
  const reactionKey = (t: ReactionTarget): string => ('articleId' in t ? 'react:article' : `react:${t.seq}`)
511
919
 
920
+ /** A target's summary before a reaction: the article's own (an article's only), or a message's. */
921
+ const summaryOf = (target: ReactionTarget): ReactionSummary =>
922
+ topicMode
923
+ ? 'articleId' in target
924
+ ? NO_REACTIONS
925
+ : (findMessage(state.thread, target.seq)?.reactions ?? NO_REACTIONS)
926
+ : reactionsFor(state.thread as DiscussionState, target)
927
+
512
928
  async function react(target: ReactionTarget, emoji: string): Promise<void> {
513
929
  const key = reactionKey(target)
930
+ // A topic has no reactions of its own (the opening post's are seq 1's).
931
+ if (topicMode && 'articleId' in target) return
514
932
  if (!canWrite() || state.busy[key] || !state.thread.reactionSet.includes(emoji)) return
515
- const before = reactionsFor(state.thread, target)
933
+ const before = summaryOf(target)
516
934
  const next = before.mine === emoji ? null : emoji
517
935
  state.busy[key] = true
518
936
  store.applyReaction(target, reactionAfter(before, next))
@@ -581,12 +999,18 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
581
999
 
582
1000
  // ───────────────────────── onboarding ─────────────────────────
583
1001
 
584
- function beginOnboarding(): void {
1002
+ function beginOnboarding(options: OnboardingStart = {}): void {
585
1003
  if (onboarding.value || disposed || state.refusedAge !== null) return
1004
+ const offerArt9 = options.offerArt9 === true
1005
+ // M3: before a forum write the art. 9 consent is asked whatever the tenant asks elsewhere (U14); not to read.
1006
+ const forceArt9 = !offerArt9 && (options.forceArt9 === true || (topicMode && options.toRead !== true))
1007
+ consentFlow = offerArt9
586
1008
  const model = createOnboarding({
587
1009
  client,
588
1010
  lang,
589
1011
  signal: abort.signal,
1012
+ ...(offerArt9 ? { offerArt9: true } : {}),
1013
+ ...(forceArt9 ? { forceArt9: true } : {}),
590
1014
  onRefused: (minAge) => {
591
1015
  state.refusedAge = minAge
592
1016
  o.onAgeRefused?.(minAge)
@@ -594,7 +1018,12 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
594
1018
  onDone: (me) => {
595
1019
  state.me = me
596
1020
  onboarding.value = null
597
- announce({ key: 'onboarding.done' })
1021
+ // A refused art. 9 consent (`public_write`): the member joined, and is told what the refusal leaves.
1022
+ state.art9RefusedNote = model.state.art9RefusedSent
1023
+ if (offerArt9) {
1024
+ // The give-consent flow: announced only when a consent is in force (it also closes with nothing left to ask).
1025
+ if (me.acceptances.art9Consent === 'accepted') announce({ key: 'panel.art9Given' })
1026
+ } else announce({ key: model.state.art9RefusedSent ? 'onboarding.art9RefusedDone' : 'onboarding.done' })
598
1027
  void refresh()
599
1028
  },
600
1029
  onSignIn: () => {
@@ -606,13 +1035,26 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
606
1035
  void model.load()
607
1036
  }
608
1037
 
1038
+ /** M3, a topic: reactions on the messages there go on in a closed topic (§1.1) — the write's other gates still apply. */
1039
+ const canReactHere = (): boolean => {
1040
+ const m = mode()
1041
+ if (m === 'write') return true
1042
+ if (!topicMode || m !== 'locked') return false
1043
+ const s = state.thread
1044
+ return s.viewer.memberId !== null && !viewerNeeds(s).any && s.viewer.restrictions.length === 0 && !brakedNow() && state.block === null
1045
+ }
1046
+
1047
+ /** M3: the author's own opening post of a topic: its edit carries the title. */
1048
+ const editsTitle = (m: Pick<DiscussionMessage, 'seq' | 'author'>): boolean => topicMode && m.seq === OPENING_SEQ && isOwn(m)
1049
+
609
1050
  return {
610
1051
  state,
611
1052
  store,
612
1053
  onboarding,
613
1054
  async start() {
614
1055
  await refresh()
615
- startPolling()
1056
+ if (realtime && state.phase === 'ready') await startRealtime(realtime)
1057
+ else startPolling()
616
1058
  },
617
1059
  refresh,
618
1060
  loadMore,
@@ -626,8 +1068,11 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
626
1068
  return parent !== null && settled(parent) && parent.state === 'visible'
627
1069
  },
628
1070
  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,
1071
+ // Not under a brake: kb-api refuses `messages/delete` BRAKED too (assertNotBraked).
1072
+ canDelete: (m) => state.thread.viewer.memberId !== null && mode() !== 'braked' && isOwn(m) && settled(m) && m.body !== null && m.state !== 'deleted',
1073
+ canReact: canReactHere,
1074
+ mentionCap,
1075
+ editsTitle,
631
1076
  canReport: (m) => !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.body !== null && m.author.kind !== 'system',
632
1077
  canReportAsNotifier: (m) => viewerNeeds(state.thread).signIn && settled(m) && m.body !== null && m.author.kind !== 'system',
633
1078
  canBlock: (m) =>
@@ -647,12 +1092,14 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
647
1092
  state.inline.replyingTo = replyingTo
648
1093
  return
649
1094
  }
650
- state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo }
1095
+ state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo, title: null }
651
1096
  },
652
1097
  openEdit(seq) {
653
1098
  const m = findMessage(state.thread, seq)
654
1099
  if (!m || m.body === null) return
655
- state.inline = { ...emptyDraft(m.body), kind: 'edit', seq, replyingTo: null }
1100
+ // M3: the opening post's edit carries the topic's title (as it is now; '' when it is withheld or gone).
1101
+ const title = editsTitle(m) ? (topicStore?.getState().topic?.title ?? '') : null
1102
+ state.inline = { ...emptyDraft(m.body), kind: 'edit', seq, replyingTo: null, title }
656
1103
  },
657
1104
  closeInline() {
658
1105
  state.inline = null
@@ -682,9 +1129,13 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
682
1129
  abort.abort()
683
1130
  poller?.stop()
684
1131
  poller = null
1132
+ session?.stop()
1133
+ session = null
685
1134
  unsubscribe()
686
1135
  if (ticker) clearInterval(ticker)
687
1136
  ticker = null
1137
+ if (brakeTimer) clearTimeout(brakeTimer)
1138
+ brakeTimer = null
688
1139
  onboarding.value = null
689
1140
  },
690
1141
  }