@blogmcp/community 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +108 -11
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/{mount-DzjwnHyx.js → mount-WadUnaTa.js} +3909 -2070
  4. package/dist/core/attrs.d.ts +27 -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 +36 -5
  9. package/dist/core/format.d.ts +7 -0
  10. package/dist/core/onboarding.d.ts +42 -2
  11. package/dist/core/outcomes.d.ts +2 -1
  12. package/dist/core/panel.d.ts +17 -1
  13. package/dist/core/profile.d.ts +40 -0
  14. package/dist/core/push.d.ts +63 -0
  15. package/dist/core/realtime.d.ts +35 -0
  16. package/dist/core/settings.d.ts +53 -0
  17. package/dist/core/text.d.ts +13 -0
  18. package/dist/element.d.ts +22 -1
  19. package/dist/global.d.ts +7 -4
  20. package/dist/index.d.ts +10 -2
  21. package/dist/index.js +34 -11
  22. package/dist/mount.d.ts +40 -0
  23. package/dist/react.d.ts +14 -3
  24. package/dist/react.js +24 -4
  25. package/dist/ui/BadgeView.vue.d.ts +13 -0
  26. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  27. package/dist/ui/LinkedText.vue.d.ts +1 -0
  28. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  29. package/dist/ui/MessageComposer.vue.d.ts +2 -0
  30. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  31. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  32. package/dist/ui/ProfileView.vue.d.ts +13 -0
  33. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  34. package/dist/vue.d.ts +114 -3
  35. package/dist/vue.js +109 -5
  36. package/package.json +3 -3
  37. package/src/core/attrs.ts +41 -2
  38. package/src/core/badge.ts +120 -0
  39. package/src/core/centre.ts +136 -0
  40. package/src/core/digest-token.ts +26 -0
  41. package/src/core/discussion.ts +203 -18
  42. package/src/core/format.ts +22 -0
  43. package/src/core/onboarding.ts +165 -18
  44. package/src/core/outcomes.ts +13 -1
  45. package/src/core/panel.ts +39 -4
  46. package/src/core/profile.ts +144 -0
  47. package/src/core/push.ts +174 -0
  48. package/src/core/realtime.ts +39 -0
  49. package/src/core/settings.ts +363 -0
  50. package/src/core/text.ts +32 -0
  51. package/src/element.ts +62 -6
  52. package/src/global.ts +18 -4
  53. package/src/index.ts +36 -1
  54. package/src/mount.ts +108 -1
  55. package/src/react.ts +47 -3
  56. package/src/ui/BadgeView.vue +51 -0
  57. package/src/ui/DiscussionView.vue +43 -3
  58. package/src/ui/LinkedText.vue +11 -5
  59. package/src/ui/MemberSettingsView.vue +175 -0
  60. package/src/ui/MessageComposer.vue +15 -4
  61. package/src/ui/MessageItem.vue +24 -6
  62. package/src/ui/MyCommunityView.vue +9 -0
  63. package/src/ui/NotificationCentreView.vue +159 -0
  64. package/src/ui/OnboardingSteps.vue +17 -6
  65. package/src/ui/ProfileView.vue +91 -0
  66. package/src/ui/WelcomeView.vue +72 -0
  67. package/src/ui/styles.css +16 -0
  68. package/src/vue.ts +129 -3
@@ -8,8 +8,10 @@
8
8
  */
9
9
  import { reactive, shallowRef, type ShallowRef } from 'vue'
10
10
  import {
11
+ isKnownModuleState,
11
12
  COMMUNITY_MESSAGE_MAX,
12
13
  COMMUNITY_REPORT_NOTE_MAX,
14
+ communityMentionHandles,
13
15
  communityRulesOf,
14
16
  createDiscussionStore,
15
17
  findMessage,
@@ -40,6 +42,7 @@ import { warnOnce } from './client'
40
42
  import type { Lang, TextRef } from './i18n'
41
43
  import { createOnboarding, type OnboardingModel } from './onboarding'
42
44
  import { failureEffect, failureText, waitUntilOf } from './outcomes'
45
+ import type { DiscussionRealtime, DiscussionRealtimeSession } from './realtime'
43
46
  import { codePointLength, normalizeBody } from './text'
44
47
 
45
48
  export type DiscussionPhase = 'loading' | 'ready' | 'failed' | 'hidden'
@@ -86,9 +89,13 @@ export interface ReportDraft {
86
89
  * onboarding waits); `ageRefused` (the age band declared in it was below the minimum: reading only, the
87
90
  * onboarding is not offered again); `sanction` (a restriction, suspension or ban in effect); `notMoved` (the
88
91
  * discussion takes no writes yet — one sentence); `restricted` (the member is read-only or deleting their
89
- * profile); `pending` (being decided).
92
+ * profile); `braked` (M2: the community is paused — `me/get`'s `module.brake`, or a write refused BRAKED: reading
93
+ * goes on, every member write waits — one sentence); `pending` (being decided).
94
+ *
95
+ * A module that is `read_only` changes nothing here (m2-contracts.md §3.1: the discussions under articles keep
96
+ * taking writes; only live updates, push and the Pro surfaces stop) — the discussion polls instead of a socket.
90
97
  */
91
- export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'pending'
98
+ export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'braked' | 'pending'
92
99
 
93
100
  export interface DiscussionUi {
94
101
  phase: DiscussionPhase
@@ -100,8 +107,8 @@ export interface DiscussionUi {
100
107
  /** A head poll saw messages the page does not show yet. */
101
108
  newMessages: boolean
102
109
  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
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
105
112
  composer: Draft
106
113
  inline: InlineDraft | null
107
114
  report: ReportDraft | null
@@ -117,6 +124,16 @@ export interface DiscussionUi {
117
124
  * (§7.4.2), so the element does: the onboarding is not offered again while it lives.
118
125
  */
119
126
  refusedAge: number | null
127
+ /**
128
+ * M2 T9: the onboarding just finished with the art. 9 consent refused (`public_write`): the composer says once,
129
+ * in view, what the refusal leaves (writing in the public discussion). Not kept beyond this element.
130
+ */
131
+ art9RefusedNote: boolean
132
+ /**
133
+ * M2 (§3.5): the live updates' session fell back to polling (live updates are off here, or the server cut the
134
+ * socket for good — 4501): the discussion says so once and keeps polling; it never opens the socket again itself.
135
+ */
136
+ realtimeOff: boolean
120
137
  }
121
138
 
122
139
  export interface DiscussionControllerOptions {
@@ -132,6 +149,11 @@ export interface DiscussionControllerOptions {
132
149
  refusedAge?: number | null
133
150
  /** The onboarding's age band was refused: the element keeps it for its next discussions. */
134
151
  onAgeRefused?(minAge: number): void
152
+ /**
153
+ * M2 (§3.5, §3.10): the host's live-updates session factory (`createRealtimeSession` of
154
+ * `@blogmcp/sdk/community/realtime`). Absent: the head poll, as before.
155
+ */
156
+ realtime?: DiscussionRealtime | null
135
157
  }
136
158
 
137
159
  export interface DiscussionController {
@@ -172,10 +194,19 @@ export interface DiscussionController {
172
194
  openReport(m: DiscussionMessage): void
173
195
  closeReport(): void
174
196
  submitReport(): Promise<void>
175
- beginOnboarding(): void
197
+ /**
198
+ * The onboarding inline; `offerArt9` asks the art. 9 consent also of a member who refused it (`public_write`, M2 T9) —
199
+ * the give-consent link on their own message held until the consent.
200
+ */
201
+ beginOnboarding(options?: { offerArt9?: boolean }): void
176
202
  cancelOnboarding(): void
177
203
  /** Seconds a draft still waits (0 when it may go). */
178
204
  waitLeft(draft: Draft): number
205
+ /**
206
+ * M2 T10: the distinct `@handle`s one message of the viewer may carry (me/get's `caps.mentionsPerMessage`), or null
207
+ * when nothing is known to limit it (no member yet, an older kb-api, a module whose mentions notify nobody).
208
+ */
209
+ mentionCap(): number | null
179
210
  dispose(): void
180
211
  }
181
212
 
@@ -208,19 +239,28 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
208
239
  clock: now(),
209
240
  busy: {},
210
241
  refusedAge: o.refusedAge ?? null,
242
+ art9RefusedNote: false,
243
+ realtimeOff: false,
211
244
  }) as DiscussionUi
212
245
  const unsubscribe = store.subscribe((s) => {
213
246
  state.thread = s
214
247
  })
215
248
  const onboarding = shallowRef<OnboardingModel | null>(null)
249
+ /** The open onboarding is the give-consent flow (read with `onboarding`, which it is set beside). */
250
+ let consentFlow = false
216
251
 
217
252
  let poller: DiscussionHeadPoller | null = null
253
+ let session: DiscussionRealtimeSession | null = null
218
254
  let inView = true
219
255
  let disposed = false
220
256
  let lastSync = 0
221
257
  let refreshing: Promise<void> | null = null
222
258
  let mePromise: Promise<void> | null = null
223
259
  let ticker: ReturnType<typeof setInterval> | null = null
260
+ /** The brake's end (its `until`, or a BRAKED refusal's): me/get is read again then. */
261
+ let brakeTimer: ReturnType<typeof setTimeout> | null = null
262
+ /** A BRAKED refusal's own end (epoch ms), while `state.block` is `braked` because of it; null: none given. */
263
+ let refusedBrakeUntil: number | null = null
224
264
  const interval = o.pollMs ?? 20_000
225
265
 
226
266
  function announce(text: TextRef): void {
@@ -232,26 +272,81 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
232
272
  state.phase = 'hidden'
233
273
  poller?.stop()
234
274
  poller = null
275
+ session?.stop()
276
+ session = null
235
277
  }
236
278
 
237
279
  // ───────────────────────── loading ─────────────────────────
238
280
 
239
- function ensureMe(): Promise<void> {
240
- if (state.me) return Promise.resolve()
281
+ /** me/get once (`fresh`: again, keeping what is shown until the answer is in — the composer does not flicker). */
282
+ function ensureMe(fresh = false): Promise<void> {
283
+ if (state.me && !fresh) return Promise.resolve()
241
284
  mePromise ??= client.me.get(call).then((r) => {
242
285
  mePromise = null
243
- if (r.ok && !disposed) state.me = r.value
286
+ if (!r.ok || disposed) return
287
+ state.me = r.value
288
+ scheduleBrakeEnd()
244
289
  })
245
290
  return mePromise
246
291
  }
247
292
 
248
- /** canPost is false, nothing is needed and no sanction applies: the member's status says whether it is the cutover. */
293
+ /** The end of a brake as me/get states it (`module.brake.until`, or `module.until` of a braked module), epoch ms. */
294
+ function brakeEndOf(me: MyState | null): number | null {
295
+ const m = me?.module
296
+ if (!m) return null
297
+ const until = m.brake ? m.brake.until : isKnownModuleState(m.state) && m.state === 'braked' ? m.until : undefined
298
+ const at = typeof until === 'string' ? Date.parse(until) : NaN
299
+ return Number.isFinite(at) ? at : null
300
+ }
301
+
302
+ /**
303
+ * While the discussion is braked, a timer at the brake's end (the later of me/get's `until` and a refusal's): then
304
+ * me/get is read again and the composer comes back when the brake is over. A brake without an end waits for the
305
+ * next refresh (afterLoad reads me/get again while braked).
306
+ */
307
+ function scheduleBrakeEnd(): void {
308
+ if (brakeTimer) clearTimeout(brakeTimer)
309
+ brakeTimer = null
310
+ if (disposed) return
311
+ const ends: number[] = []
312
+ if (brakedNow()) {
313
+ const at = brakeEndOf(state.me)
314
+ if (at !== null) ends.push(at)
315
+ }
316
+ if (state.block === 'braked' && refusedBrakeUntil !== null) ends.push(refusedBrakeUntil)
317
+ if (ends.length === 0) return
318
+ // A second after the end (clocks differ by a little), within setTimeout's range; an end me/get still reports
319
+ // after it passed (the brake not lifted yet on kb-api's side) is asked again every 30 s, never in a tight loop.
320
+ const left = Math.max(...ends) - now()
321
+ const delay = left > 0 ? Math.min(left + 1000, 2_147_000_000) : 30_000
322
+ brakeTimer = setTimeout(() => {
323
+ brakeTimer = null
324
+ if (disposed) return
325
+ liftRefusedBrake()
326
+ void ensureMe(true)
327
+ }, delay)
328
+ }
329
+
330
+ /** A BRAKED refusal's block ends once its own end has passed (or it gave none): me/get decides from then on. */
331
+ function liftRefusedBrake(): void {
332
+ if (state.block !== 'braked') return
333
+ if (refusedBrakeUntil !== null && refusedBrakeUntil > now()) return
334
+ state.block = null
335
+ refusedBrakeUntil = null
336
+ }
337
+
338
+ /**
339
+ * canPost is false, nothing is needed and no sanction applies: a brake (M2 T10: kb-api's canPost is false while one
340
+ * is in effect — me/get, read again, names it, and the composer gives way to the brake's sentence), else the
341
+ * member's status says whether it is the cutover.
342
+ */
249
343
  function decideBlock(): void {
250
344
  const s = store.getState()
251
345
  if (s.viewer.canPost || viewerNeeds(s).any || s.viewer.restrictions.length > 0) return
252
- void ensureMe().then(() => {
346
+ void ensureMe(state.block === null).then(() => {
253
347
  const me = state.me
254
348
  if (disposed || !me || store.getState().viewer.canPost) return
349
+ if (brakedNow()) return
255
350
  // An active member who may not post: the discussion takes no writes yet (the cutover).
256
351
  state.block = !me.member || me.member.status === 'active' ? 'notMoved' : 'restricted'
257
352
  })
@@ -259,8 +354,11 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
259
354
 
260
355
  function afterLoad(): void {
261
356
  const s = store.getState()
262
- if (s.viewer.canPost) state.block = null
263
- if (s.viewer.memberId !== null) void ensureMe()
357
+ // A braked discussion asks me/get again on every refresh: the brake may be over (kb-api's canPost does not know it).
358
+ const wasBraked = state.block === 'braked' || brakedNow()
359
+ if (s.viewer.canPost && state.block !== 'braked') state.block = null
360
+ if (wasBraked) liftRefusedBrake()
361
+ if (s.viewer.memberId !== null) void ensureMe(wasBraked)
264
362
  decideBlock()
265
363
  // Finished elsewhere (another tab): nothing left to onboard.
266
364
  if (onboarding.value && !viewerNeeds(s).any && onboarding.value.state.phase !== 'sending') onboarding.value = null
@@ -341,6 +439,47 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
341
439
  })
342
440
  }
343
441
 
442
+ /**
443
+ * Live updates (M2, §3.5): the host's session over this element's store, after the first list. Not for an anonymous
444
+ * reader (no host session: the token route refuses it) nor while `me/get` says the module offers none (`read_only`,
445
+ * `dark`, live updates not configured) — the head poll then, and no socket. The session falls back to polling by
446
+ * itself (`realtimeOff`, the server's terminal 4501) and the element never starts it again.
447
+ */
448
+ async function startRealtime(factory: DiscussionRealtime): Promise<void> {
449
+ if (session || disposed || state.phase === 'hidden') return
450
+ if (viewerNeeds(store.getState()).signIn) return startPolling()
451
+ await ensureMe()
452
+ // The discussion may have been hidden (SURFACE_OFF) while me/get was on its way.
453
+ if (disposed || session || (state.phase as DiscussionPhase) === 'hidden') return
454
+ if (state.me?.module && state.me.module.features.realtime !== true) return startPolling()
455
+ try {
456
+ session = factory({
457
+ client,
458
+ store,
459
+ languageCode: lang,
460
+ visible: () => documentVisible() && inView,
461
+ ...(interval > 0 ? { pollIntervalMs: interval } : {}),
462
+ onMode: (mode) => {
463
+ if (disposed) return
464
+ // Said once per fallback: live again (the SDK session reconnected) takes the sentence away.
465
+ if (mode === 'polling' && !state.realtimeOff) state.realtimeOff = true
466
+ else if (mode === 'live' && state.realtimeOff) state.realtimeOff = false
467
+ },
468
+ })
469
+ } catch {
470
+ warnOnce('realtime', '[blogmcp-community] the realtime property is not a session factory (createRealtimeSession of @blogmcp/sdk/community/realtime): the discussion polls')
471
+ session = null
472
+ startPolling()
473
+ }
474
+ }
475
+
476
+ /** The member's community is paused (`me/get`'s module.brake or state, M2 §3.1): every member write is refused. */
477
+ function brakedNow(): boolean {
478
+ const m = state.me?.module
479
+ if (!m) return false
480
+ return m.brake !== undefined || (isKnownModuleState(m.state) && m.state === 'braked')
481
+ }
482
+
344
483
  // ───────────────────────── who may do what ─────────────────────────
345
484
 
346
485
  function mode(): ComposerMode {
@@ -348,13 +487,30 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
348
487
  const n = viewerNeeds(s)
349
488
  if (n.signIn) return 'signIn'
350
489
  if (state.block === 'notMoved') return 'notMoved'
490
+ if (state.block === 'braked' || brakedNow()) return 'braked'
351
491
  if (!s.viewer.canPost && s.viewer.restrictions.length > 0) return 'sanction'
352
492
  // A refused age band: the refusal stays where the composer was once the reader closed it.
353
493
  if (n.any) return state.refusedAge !== null && !onboarding.value ? 'ageRefused' : 'onboarding'
494
+ // The give-consent flow of a member who refused the consent (needs name none): its step where the composer was.
495
+ if (onboarding.value && consentFlow) return 'onboarding'
354
496
  if (state.block === 'restricted') return 'restricted'
355
497
  return s.viewer.canPost ? 'write' : 'pending'
356
498
  }
357
499
 
500
+ /** me/get's per-message mention cap, where kb-api applies it (a member, the module live or braked: §3.3). */
501
+ function mentionCap(): number | null {
502
+ const me = state.me
503
+ const cap = me?.caps?.mentionsPerMessage
504
+ const m = me?.module?.state
505
+ if (!me?.member || typeof cap !== 'number' || !Number.isSafeInteger(cap) || cap < 0) return null
506
+ return m === 'live' || m === 'braked' ? cap : null
507
+ }
508
+ /** The draft mentions more distinct handles than the cap (kb-api's count: as typed, whether they exist or not). */
509
+ const overMentionCap = (body: string): boolean => {
510
+ const cap = mentionCap()
511
+ return cap !== null && communityMentionHandles(body).length > cap
512
+ }
513
+
358
514
  const isOwn = (m: Pick<DiscussionMessage, 'author'>): boolean => isOwnMessage(state.thread, m)
359
515
  const settled = (m: DiscussionMessage): boolean => !m.pending && m.seq > 0
360
516
  const canWrite = (): boolean => mode() === 'write'
@@ -409,7 +565,14 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
409
565
  case 'wait':
410
566
  if (d) {
411
567
  d.waitUntil = waitUntilOf(f, now())
412
- startClock()
568
+ if (d.waitUntil !== null) startClock()
569
+ }
570
+ if (f.code === 'braked') {
571
+ // Paused (BRAKED): the composer gives way to the sentence until the brake's end; me/get is read again.
572
+ state.block = 'braked'
573
+ refusedBrakeUntil = waitUntilOf(f, now())
574
+ scheduleBrakeEnd()
575
+ void ensureMe(true)
413
576
  }
414
577
  return
415
578
  case 'onboard':
@@ -423,7 +586,8 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
423
586
  state.block = 'notMoved'
424
587
  return
425
588
  case 'hide':
426
- return hide()
589
+ // Live updates switched off hide nothing: the discussion polls (no write answers it; a realtime route does).
590
+ return f.code === 'realtimeOff' ? undefined : hide()
427
591
  default:
428
592
  return
429
593
  }
@@ -440,6 +604,12 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
440
604
  if (n.any) return beginOnboarding()
441
605
  const parent = target.kind === 'new' ? null : findMessage(state.thread, target.seq)
442
606
  if (target.kind !== 'new' && (!parent || !settled(parent))) return
607
+ // Too many mentions for the member's level: said before posting (the composer shows it as it is typed); kb-api's
608
+ // MENTION_LIMIT stays the rule.
609
+ if (overMentionCap(body)) {
610
+ d.failure = { ok: false, code: 'mentionLimit' }
611
+ return
612
+ }
443
613
 
444
614
  d.sending = true
445
615
  d.failure = null
@@ -581,12 +751,15 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
581
751
 
582
752
  // ───────────────────────── onboarding ─────────────────────────
583
753
 
584
- function beginOnboarding(): void {
754
+ function beginOnboarding(options: { offerArt9?: boolean } = {}): void {
585
755
  if (onboarding.value || disposed || state.refusedAge !== null) return
756
+ const offerArt9 = options.offerArt9 === true
757
+ consentFlow = offerArt9
586
758
  const model = createOnboarding({
587
759
  client,
588
760
  lang,
589
761
  signal: abort.signal,
762
+ ...(offerArt9 ? { offerArt9: true } : {}),
590
763
  onRefused: (minAge) => {
591
764
  state.refusedAge = minAge
592
765
  o.onAgeRefused?.(minAge)
@@ -594,7 +767,12 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
594
767
  onDone: (me) => {
595
768
  state.me = me
596
769
  onboarding.value = null
597
- announce({ key: 'onboarding.done' })
770
+ // A refused art. 9 consent (`public_write`): the member joined, and is told what the refusal leaves.
771
+ state.art9RefusedNote = model.state.art9RefusedSent
772
+ if (offerArt9) {
773
+ // The give-consent flow: announced only when a consent is in force (it also closes with nothing left to ask).
774
+ if (me.acceptances.art9Consent === 'accepted') announce({ key: 'panel.art9Given' })
775
+ } else announce({ key: model.state.art9RefusedSent ? 'onboarding.art9RefusedDone' : 'onboarding.done' })
598
776
  void refresh()
599
777
  },
600
778
  onSignIn: () => {
@@ -612,7 +790,8 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
612
790
  onboarding,
613
791
  async start() {
614
792
  await refresh()
615
- startPolling()
793
+ if (o.realtime && state.phase === 'ready') await startRealtime(o.realtime)
794
+ else startPolling()
616
795
  },
617
796
  refresh,
618
797
  loadMore,
@@ -626,8 +805,10 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
626
805
  return parent !== null && settled(parent) && parent.state === 'visible'
627
806
  },
628
807
  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',
808
+ // Not under a brake: kb-api refuses `messages/delete` BRAKED too (assertNotBraked).
809
+ canDelete: (m) => state.thread.viewer.memberId !== null && mode() !== 'braked' && isOwn(m) && settled(m) && m.body !== null && m.state !== 'deleted',
630
810
  canReact: canWrite,
811
+ mentionCap,
631
812
  canReport: (m) => !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.body !== null && m.author.kind !== 'system',
632
813
  canReportAsNotifier: (m) => viewerNeeds(state.thread).signIn && settled(m) && m.body !== null && m.author.kind !== 'system',
633
814
  canBlock: (m) =>
@@ -682,9 +863,13 @@ export function createDiscussionController(o: DiscussionControllerOptions): Disc
682
863
  abort.abort()
683
864
  poller?.stop()
684
865
  poller = null
866
+ session?.stop()
867
+ session = null
685
868
  unsubscribe()
686
869
  if (ticker) clearInterval(ticker)
687
870
  ticker = null
871
+ if (brakeTimer) clearTimeout(brakeTimer)
872
+ brakeTimer = null
688
873
  onboarding.value = null
689
874
  },
690
875
  }
@@ -31,6 +31,28 @@ export function formatDate(lang: Lang, iso: string | null | undefined, withTime
31
31
  return format(lang, withTime)?.format(new Date(at)) ?? iso
32
32
  }
33
33
 
34
+ const months = new Map<Lang, Intl.DateTimeFormat | null>()
35
+
36
+ /**
37
+ * A month as `profile/get`'s `memberSince` gives it (`YYYY-MM`, UTC) in the reader's language, in the form a date
38
+ * takes inside a sentence („Členem od října 2026", "Member since October 2026"): Intl formats the first of the month
39
+ * and the day is left out — a month on its own would be the nominative („říjen"). The value as it came when Intl has
40
+ * no words, '' for anything that is no month.
41
+ */
42
+ export function formatMonth(lang: Lang, ym: string | null | undefined): string {
43
+ if (typeof ym !== 'string' || !/^\d{4}-(?:0[1-9]|1[0-2])$/.test(ym)) return ''
44
+ const f = cached(months, lang, (locale) => new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }))
45
+ try {
46
+ const parts = f?.formatToParts(new Date(`${ym}-01T00:00:00.000Z`))
47
+ if (!parts) return ym
48
+ const day = parts.findIndex((p) => p.type === 'day')
49
+ const kept = parts.filter((_p, i) => i !== day && !(i === day + 1 && parts[i]!.type === 'literal'))
50
+ return kept.map((p) => p.value).join('').trim() || ym
51
+ } catch {
52
+ return ym
53
+ }
54
+ }
55
+
34
56
  /** Whether an ISO timestamp lies in the past (an appeal window, a sanction's end). */
35
57
  export function isPast(iso: string | null | undefined, now: number = Date.now()): boolean {
36
58
  if (typeof iso !== 'string') return false