@blogmcp/community 0.2.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 (82) hide show
  1. package/README.md +64 -12
  2. package/dist/blogmcp-community.global.js +7 -7
  3. package/dist/chunks/{mount-WadUnaTa.js → mount--2_V2q3M.js} +4724 -1097
  4. package/dist/core/attrs.d.ts +17 -2
  5. package/dist/core/discussion.d.ts +92 -18
  6. package/dist/core/follow.d.ts +26 -0
  7. package/dist/core/forum-category.d.ts +85 -0
  8. package/dist/core/forum-search.d.ts +35 -0
  9. package/dist/core/forum-state.d.ts +30 -0
  10. package/dist/core/forum-words.d.ts +8 -0
  11. package/dist/core/forum.d.ts +21 -0
  12. package/dist/core/moderation.d.ts +80 -0
  13. package/dist/core/onboarding.d.ts +7 -0
  14. package/dist/core/profile.d.ts +6 -1
  15. package/dist/core/realtime.d.ts +13 -2
  16. package/dist/core/topic.d.ts +47 -0
  17. package/dist/element.d.ts +26 -1
  18. package/dist/global.d.ts +7 -6
  19. package/dist/index.d.ts +13 -4
  20. package/dist/index.js +54 -27
  21. package/dist/mount.d.ts +50 -1
  22. package/dist/react.d.ts +17 -4
  23. package/dist/react.js +32 -8
  24. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  25. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  26. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  27. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  28. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  29. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  30. package/dist/ui/ForumView.vue.d.ts +12 -0
  31. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  32. package/dist/ui/MessageComposer.vue.d.ts +6 -2
  33. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  34. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  35. package/dist/ui/TopicRow.vue.d.ts +10 -0
  36. package/dist/ui/TopicView.vue.d.ts +32 -0
  37. package/dist/ui/context.d.ts +7 -1
  38. package/dist/ui/discussion-context.d.ts +11 -3
  39. package/dist/vue.d.ts +164 -5
  40. package/dist/vue.js +134 -8
  41. package/package.json +3 -3
  42. package/src/core/attrs.ts +22 -2
  43. package/src/core/discussion.ts +309 -43
  44. package/src/core/follow.ts +139 -0
  45. package/src/core/forum-category.ts +323 -0
  46. package/src/core/forum-search.ts +153 -0
  47. package/src/core/forum-state.ts +59 -0
  48. package/src/core/forum-words.ts +38 -0
  49. package/src/core/forum.ts +83 -0
  50. package/src/core/moderation.ts +414 -0
  51. package/src/core/onboarding.ts +16 -4
  52. package/src/core/outcomes.ts +10 -0
  53. package/src/core/profile.ts +12 -1
  54. package/src/core/realtime.ts +15 -2
  55. package/src/core/topic.ts +234 -0
  56. package/src/element.ts +63 -1
  57. package/src/global.ts +27 -5
  58. package/src/index.ts +49 -2
  59. package/src/mount.ts +144 -2
  60. package/src/react.ts +45 -2
  61. package/src/ui/FollowButtonView.vue +85 -0
  62. package/src/ui/FollowToggle.vue +39 -0
  63. package/src/ui/ForumCategoryView.vue +243 -0
  64. package/src/ui/ForumModerationView.vue +309 -0
  65. package/src/ui/ForumNotices.vue +23 -0
  66. package/src/ui/ForumSearchView.vue +171 -0
  67. package/src/ui/ForumView.vue +128 -0
  68. package/src/ui/MembersOnly.vue +59 -0
  69. package/src/ui/MessageComposer.vue +51 -9
  70. package/src/ui/MessageItem.vue +12 -1
  71. package/src/ui/MessageMenu.vue +3 -2
  72. package/src/ui/ModeratorDialog.vue +102 -0
  73. package/src/ui/MyCommunityView.vue +3 -0
  74. package/src/ui/NoticeStatusView.vue +15 -2
  75. package/src/ui/NotificationCentreView.vue +37 -3
  76. package/src/ui/ProfileView.vue +15 -4
  77. package/src/ui/TopicRow.vue +70 -0
  78. package/src/ui/TopicView.vue +395 -0
  79. package/src/ui/context.ts +8 -3
  80. package/src/ui/discussion-context.ts +11 -3
  81. package/src/ui/styles.css +14 -0
  82. package/src/vue.ts +145 -3
@@ -4,6 +4,15 @@
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'
@@ -14,14 +23,22 @@ import {
14
23
  communityMentionHandles,
15
24
  communityRulesOf,
16
25
  createDiscussionStore,
26
+ createTopicStore,
17
27
  findMessage,
28
+ forumTopicTitleOf,
18
29
  isOwnMessage,
30
+ isTopicOpen,
19
31
  loadMoreDiscussion,
32
+ loadMoreTopic,
20
33
  newClientMsgId,
21
34
  pollDiscussionHead,
35
+ pollTopicHead,
22
36
  reactionAfter,
23
37
  reactionsFor,
24
38
  refreshDiscussion,
39
+ refreshTopic,
40
+ settleTopicFailure,
41
+ surfaceInfoOf,
25
42
  viewerNeeds,
26
43
  type CaseCategory,
27
44
  type CommunityCallOptions,
@@ -33,19 +50,58 @@ import {
33
50
  type DiscussionMessage,
34
51
  type DiscussionState,
35
52
  type DiscussionStore,
53
+ type DiscussionViewer,
36
54
  type KnownAuthor,
55
+ type KnownBrakeSurface,
56
+ type MessageChange,
37
57
  type MyState,
58
+ type ReactionSummary,
38
59
  type ReactionTarget,
60
+ type RealtimeEvent,
61
+ type RealtimeEventOutcome,
62
+ type Topic,
63
+ type TopicHead,
64
+ type TopicStore,
65
+ type TopicStoreState,
39
66
  type ViewerNeeds,
40
67
  } from '@blogmcp/sdk/community'
41
68
  import { warnOnce } from './client'
42
69
  import type { Lang, TextRef } from './i18n'
43
70
  import { createOnboarding, type OnboardingModel } from './onboarding'
44
71
  import { failureEffect, failureText, waitUntilOf } from './outcomes'
45
- import type { DiscussionRealtime, DiscussionRealtimeSession } from './realtime'
72
+ import type { DiscussionRealtime, DiscussionRealtimeSession, TopicRealtime } from './realtime'
46
73
  import { codePointLength, normalizeBody } from './text'
47
74
 
48
- 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
+ }
49
105
 
50
106
  /** One composer's text and what happened to it. */
51
107
  export interface Draft {
@@ -69,6 +125,11 @@ export interface InlineDraft extends Draft {
69
125
  seq: number
70
126
  /** A reply to a reply names whom it answers. */
71
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
72
133
  }
73
134
 
74
135
  export interface ReportDraft {
@@ -95,20 +156,38 @@ export interface ReportDraft {
95
156
  * A module that is `read_only` changes nothing here (m2-contracts.md §3.1: the discussions under articles keep
96
157
  * taking writes; only live updates, push and the Pro surfaces stop) — the discussion polls instead of a socket.
97
158
  */
98
- export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'braked' | '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'
99
175
 
100
- export interface DiscussionUi {
176
+ export interface ThreadUi<S extends ThreadState> {
101
177
  phase: DiscussionPhase
102
- /** Why the first load failed (`failed`). */
178
+ /** Why the first load failed (`failed`), or why the topic is not shown (`membersOnly`, `notFound`). */
103
179
  failure: CommunityFailure | null
104
- thread: DiscussionState
180
+ thread: S
105
181
  loadingMore: boolean
106
182
  refreshing: boolean
107
183
  /** A head poll saw messages the page does not show yet. */
108
184
  newMessages: boolean
109
185
  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
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
112
191
  composer: Draft
113
192
  inline: InlineDraft | null
114
193
  report: ReportDraft | null
@@ -136,9 +215,12 @@ export interface DiscussionUi {
136
215
  realtimeOff: boolean
137
216
  }
138
217
 
139
- 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 {
140
223
  client: CommunityClient
141
- articleId: string
142
224
  lang: Lang
143
225
  /** Head poll interval (default 20 s; 0 = no polling). */
144
226
  pollMs?: number
@@ -149,6 +231,10 @@ export interface DiscussionControllerOptions {
149
231
  refusedAge?: number | null
150
232
  /** The onboarding's age band was refused: the element keeps it for its next discussions. */
151
233
  onAgeRefused?(minAge: number): void
234
+ }
235
+
236
+ export interface DiscussionControllerOptions extends ThreadControllerOptions {
237
+ articleId: string
152
238
  /**
153
239
  * M2 (§3.5, §3.10): the host's live-updates session factory (`createRealtimeSession` of
154
240
  * `@blogmcp/sdk/community/realtime`). Absent: the head poll, as before.
@@ -156,9 +242,16 @@ export interface DiscussionControllerOptions {
156
242
  realtime?: DiscussionRealtime | null
157
243
  }
158
244
 
159
- export interface DiscussionController {
160
- readonly state: DiscussionUi
161
- readonly store: DiscussionStore
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>
162
255
  readonly onboarding: ShallowRef<OnboardingModel | null>
163
256
  /** The first load, then the poll. */
164
257
  start(): Promise<void>
@@ -196,9 +289,10 @@ export interface DiscussionController {
196
289
  submitReport(): Promise<void>
197
290
  /**
198
291
  * 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.
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`).
200
294
  */
201
- beginOnboarding(options?: { offerArt9?: boolean }): void
295
+ beginOnboarding(options?: OnboardingStart): void
202
296
  cancelOnboarding(): void
203
297
  /** Seconds a draft still waits (0 when it may go). */
204
298
  waitLeft(draft: Draft): number
@@ -207,22 +301,71 @@ export interface DiscussionController {
207
301
  * when nothing is known to limit it (no member yet, an older kb-api, a module whose mentions notify nobody).
208
302
  */
209
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
210
306
  dispose(): void
211
307
  }
212
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
+
213
319
  const emptyDraft = (body = ''): Draft => ({ body, sending: false, failure: null, confirm: null, waitUntil: null, clientMsgId: null })
214
320
 
321
+ const NO_REACTIONS: ReactionSummary = Object.freeze({ counts: Object.freeze({}), mine: null })
322
+
215
323
  const documentVisible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
216
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. */
217
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> } {
218
355
  const { client, lang } = o
219
- 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'
220
361
  const now = o.now ?? Date.now
221
- 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>
222
365
  const abort = new AbortController()
223
366
  const call: CommunityCallOptions = { signal: abort.signal }
224
367
 
225
- const state = reactive<DiscussionUi>({
368
+ const state = reactive<ThreadUi<ThreadState>>({
226
369
  phase: 'loading',
227
370
  failure: null,
228
371
  thread: store.getState(),
@@ -241,7 +384,7 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
241
384
  refusedAge: o.refusedAge ?? null,
242
385
  art9RefusedNote: false,
243
386
  realtimeOff: false,
244
- }) as DiscussionUi
387
+ }) as ThreadUi<ThreadState>
245
388
  const unsubscribe = store.subscribe((s) => {
246
389
  state.thread = s
247
390
  })
@@ -290,13 +433,22 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
290
433
  return mePromise
291
434
  }
292
435
 
293
- /** The end of a brake as me/get states it (`module.brake.until`, or `module.until` of a braked module), epoch ms. */
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
+ */
294
443
  function brakeEndOf(me: MyState | null): number | null {
295
444
  const m = me?.module
296
445
  if (!m) return null
297
446
  const until = m.brake ? m.brake.until : isKnownModuleState(m.state) && m.state === 'braked' ? m.until : undefined
298
447
  const at = typeof until === 'string' ? Date.parse(until) : NaN
299
- return Number.isFinite(at) ? at : null
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
300
452
  }
301
453
 
302
454
  /**
@@ -343,15 +495,25 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
343
495
  function decideBlock(): void {
344
496
  const s = store.getState()
345
497
  if (s.viewer.canPost || viewerNeeds(s).any || s.viewer.restrictions.length > 0) return
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
346
500
  void ensureMe(state.block === null).then(() => {
347
501
  const me = state.me
348
502
  if (disposed || !me || store.getState().viewer.canPost) return
349
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
+ }
350
509
  // An active member who may not post: the discussion takes no writes yet (the cutover).
351
510
  state.block = !me.member || me.member.status === 'active' ? 'notMoved' : 'restricted'
352
511
  })
353
512
  }
354
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
+
355
517
  function afterLoad(): void {
356
518
  const s = store.getState()
357
519
  // A braked discussion asks me/get again on every refresh: the brake may be over (kb-api's canPost does not know it).
@@ -366,6 +528,17 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
366
528
 
367
529
  function loadFailed(f: CommunityFailure): void {
368
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
+ }
369
542
  if (f.code === 'notFound' && state.phase !== 'ready') {
370
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)')
371
544
  return hide()
@@ -377,11 +550,17 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
377
550
  // A loaded discussion keeps what it shows; the poll tries again.
378
551
  }
379
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
+
380
559
  function refresh(): Promise<void> {
381
560
  if (disposed) return Promise.resolve()
382
561
  refreshing ??= (async () => {
383
562
  state.refreshing = true
384
- const r = await refreshDiscussion(store, client, { articleId, languageCode: lang }, call)
563
+ const r = await readPages(false)
385
564
  state.refreshing = false
386
565
  if (disposed) return
387
566
  lastSync = now()
@@ -402,23 +581,33 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
402
581
  async function loadMore(): Promise<void> {
403
582
  if (state.loadingMore || state.thread.nextCursor === null) return
404
583
  state.loadingMore = true
405
- const r = await loadMoreDiscussion(store, client, { articleId, languageCode: lang }, call)
584
+ const r = await readPages(true)
406
585
  state.loadingMore = false
407
586
  if (disposed || r.ok) return
408
587
  if (r.code === 'surfaceOff') return hide()
588
+ if (topicMode && (r.code === 'membersOnly' || r.code === 'notFound')) return loadFailed(r)
409
589
  announce(failureText(r, lang))
410
590
  }
411
591
 
412
- 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 {
413
602
  for (const head of heads) {
414
- if (typeof head?.articleId !== 'string' || head.articleId.toLowerCase() !== articleId) continue
603
+ if (headKey(head) !== threadKey) continue
415
604
  if (state.phase === 'failed') {
416
605
  void refresh()
417
606
  continue
418
607
  }
419
608
  const before = store.getState()
420
609
  const newer = typeof head.lastSeq === 'number' && head.lastSeq > before.lastSeq
421
- if (!store.applyHead(head)) continue
610
+ if (!applyHead(head)) continue
422
611
  // New messages wait for the reader (the page does not move under them); an edit, a reaction or the
423
612
  // reader's own post are folded in at once.
424
613
  if (newer && before.loaded) state.newMessages = true
@@ -428,15 +617,18 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
428
617
 
429
618
  function startPolling(): void {
430
619
  if (poller || interval === 0 || disposed || state.phase === 'hidden') return
431
- poller = pollDiscussionHead(client, [articleId], {
620
+ const options = {
432
621
  intervalMs: interval,
433
622
  visible: () => documentVisible() && inView,
434
623
  onChange: onHeads,
435
- onFailure: (f) => {
624
+ onFailure: (f: CommunityFailure) => {
436
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)
437
628
  },
438
629
  signal: abort.signal,
439
- })
630
+ }
631
+ poller = topicMode ? pollTopicHead(client, [threadKey], options) : pollDiscussionHead(client, [threadKey], options)
440
632
  }
441
633
 
442
634
  /**
@@ -445,7 +637,7 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
445
637
  * `dark`, live updates not configured) — the head poll then, and no socket. The session falls back to polling by
446
638
  * itself (`realtimeOff`, the server's terminal 4501) and the element never starts it again.
447
639
  */
448
- async function startRealtime(factory: DiscussionRealtime): Promise<void> {
640
+ async function startRealtime(factory: AnyRealtime): Promise<void> {
449
641
  if (session || disposed || state.phase === 'hidden') return
450
642
  if (viewerNeeds(store.getState()).signIn) return startPolling()
451
643
  await ensureMe()
@@ -453,9 +645,9 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
453
645
  if (disposed || session || (state.phase as DiscussionPhase) === 'hidden') return
454
646
  if (state.me?.module && state.me.module.features.realtime !== true) return startPolling()
455
647
  try {
456
- session = factory({
648
+ session = (factory as unknown as DiscussionRealtime)({
457
649
  client,
458
- store,
650
+ store: (discussionStore ?? topicStore) as unknown as DiscussionStore,
459
651
  languageCode: lang,
460
652
  visible: () => documentVisible() && inView,
461
653
  ...(interval > 0 ? { pollIntervalMs: interval } : {}),
@@ -473,11 +665,15 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
473
665
  }
474
666
  }
475
667
 
476
- /** The member's community is paused (`me/get`'s module.brake or state, M2 §3.1): every member write is refused. */
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
+ */
477
673
  function brakedNow(): boolean {
478
674
  const m = state.me?.module
479
675
  if (!m) return false
480
- return m.brake !== undefined || (isKnownModuleState(m.state) && m.state === 'braked')
676
+ return m.brake !== undefined || (isKnownModuleState(m.state) && m.state === 'braked') || surfaceBrakeOf(state.me) !== null
481
677
  }
482
678
 
483
679
  // ───────────────────────── who may do what ─────────────────────────
@@ -486,6 +682,12 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
486
682
  const s = state.thread
487
683
  const n = viewerNeeds(s)
488
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
+ }
489
691
  if (state.block === 'notMoved') return 'notMoved'
490
692
  if (state.block === 'braked' || brakedNow()) return 'braked'
491
693
  if (!s.viewer.canPost && s.viewer.restrictions.length > 0) return 'sanction'
@@ -555,9 +757,30 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
555
757
 
556
758
  const waitLeft = (d: Draft): number => (d.waitUntil === null ? 0 : Math.max(0, Math.ceil((d.waitUntil - Math.max(state.clock, now())) / 1000)))
557
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
+
558
777
  /** What a refused write does besides showing its sentence. */
559
778
  function refused(d: Draft | null, f: CommunityFailure): void {
560
779
  if (d) d.failure = f
780
+ if (topicMode && f.code === 'notFound') {
781
+ void settleTopicWrite(d, f)
782
+ return
783
+ }
561
784
  switch (failureEffect(f.code)) {
562
785
  case 'confirm':
563
786
  if (d && f.codes && f.codes.length > 0) d.confirm = { codes: [...f.codes], checked: [] }
@@ -576,7 +799,8 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
576
799
  }
577
800
  return
578
801
  case 'onboard':
579
- 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 } : {})
580
804
  void refresh()
581
805
  return
582
806
  case 'reload':
@@ -610,6 +834,14 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
610
834
  d.failure = { ok: false, code: 'mentionLimit' }
611
835
  return
612
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
+ }
613
845
 
614
846
  d.sending = true
615
847
  d.failure = null
@@ -619,17 +851,23 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
619
851
  if (target.kind === 'edit') store.applyMessage({ type: 'edit', clientMsgId: changeId, seq: target.seq, body })
620
852
  else if (author) store.applyMessage({ type: 'post', clientMsgId: changeId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, author, languageCode: lang })
621
853
 
854
+ const where = topicMode ? { conversationId: threadKey } : { articleId: threadKey }
622
855
  const r =
623
856
  target.kind === 'edit'
624
- ? 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
+ )
625
861
  : await client.messages.post(
626
- { 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 } : {}) },
627
863
  call,
628
864
  )
629
865
  if (disposed) return
630
866
  d.sending = false
631
867
  if (r.ok) {
632
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)
633
871
  const held = r.value.message.state === 'held'
634
872
  d.body = ''
635
873
  d.clientMsgId = null
@@ -679,10 +917,20 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
679
917
 
680
918
  const reactionKey = (t: ReactionTarget): string => ('articleId' in t ? 'react:article' : `react:${t.seq}`)
681
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
+
682
928
  async function react(target: ReactionTarget, emoji: string): Promise<void> {
683
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
684
932
  if (!canWrite() || state.busy[key] || !state.thread.reactionSet.includes(emoji)) return
685
- const before = reactionsFor(state.thread, target)
933
+ const before = summaryOf(target)
686
934
  const next = before.mine === emoji ? null : emoji
687
935
  state.busy[key] = true
688
936
  store.applyReaction(target, reactionAfter(before, next))
@@ -751,15 +999,18 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
751
999
 
752
1000
  // ───────────────────────── onboarding ─────────────────────────
753
1001
 
754
- function beginOnboarding(options: { offerArt9?: boolean } = {}): void {
1002
+ function beginOnboarding(options: OnboardingStart = {}): void {
755
1003
  if (onboarding.value || disposed || state.refusedAge !== null) return
756
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))
757
1007
  consentFlow = offerArt9
758
1008
  const model = createOnboarding({
759
1009
  client,
760
1010
  lang,
761
1011
  signal: abort.signal,
762
1012
  ...(offerArt9 ? { offerArt9: true } : {}),
1013
+ ...(forceArt9 ? { forceArt9: true } : {}),
763
1014
  onRefused: (minAge) => {
764
1015
  state.refusedAge = minAge
765
1016
  o.onAgeRefused?.(minAge)
@@ -784,13 +1035,25 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
784
1035
  void model.load()
785
1036
  }
786
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
+
787
1050
  return {
788
1051
  state,
789
1052
  store,
790
1053
  onboarding,
791
1054
  async start() {
792
1055
  await refresh()
793
- if (o.realtime && state.phase === 'ready') await startRealtime(o.realtime)
1056
+ if (realtime && state.phase === 'ready') await startRealtime(realtime)
794
1057
  else startPolling()
795
1058
  },
796
1059
  refresh,
@@ -807,8 +1070,9 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
807
1070
  canEdit: (m) => canWrite() && isOwn(m) && settled(m) && m.body !== null && (m.state === 'visible' || m.state === 'held'),
808
1071
  // Not under a brake: kb-api refuses `messages/delete` BRAKED too (assertNotBraked).
809
1072
  canDelete: (m) => state.thread.viewer.memberId !== null && mode() !== 'braked' && isOwn(m) && settled(m) && m.body !== null && m.state !== 'deleted',
810
- canReact: canWrite,
1073
+ canReact: canReactHere,
811
1074
  mentionCap,
1075
+ editsTitle,
812
1076
  canReport: (m) => !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.body !== null && m.author.kind !== 'system',
813
1077
  canReportAsNotifier: (m) => viewerNeeds(state.thread).signIn && settled(m) && m.body !== null && m.author.kind !== 'system',
814
1078
  canBlock: (m) =>
@@ -828,12 +1092,14 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
828
1092
  state.inline.replyingTo = replyingTo
829
1093
  return
830
1094
  }
831
- state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo }
1095
+ state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo, title: null }
832
1096
  },
833
1097
  openEdit(seq) {
834
1098
  const m = findMessage(state.thread, seq)
835
1099
  if (!m || m.body === null) return
836
- 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 }
837
1103
  },
838
1104
  closeInline() {
839
1105
  state.inline = null