@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.
- package/README.md +108 -11
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/{mount-DzjwnHyx.js → mount-WadUnaTa.js} +3909 -2070
- package/dist/core/attrs.d.ts +27 -1
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +36 -5
- package/dist/core/format.d.ts +7 -0
- package/dist/core/onboarding.d.ts +42 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/element.d.ts +22 -1
- package/dist/global.d.ts +7 -4
- package/dist/index.d.ts +10 -2
- package/dist/index.js +34 -11
- package/dist/mount.d.ts +40 -0
- package/dist/react.d.ts +14 -3
- package/dist/react.js +24 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- package/dist/ui/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +2 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/vue.d.ts +114 -3
- package/dist/vue.js +109 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +41 -2
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +203 -18
- package/src/core/format.ts +22 -0
- package/src/core/onboarding.ts +165 -18
- package/src/core/outcomes.ts +13 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/element.ts +62 -6
- package/src/global.ts +18 -4
- package/src/index.ts +36 -1
- package/src/mount.ts +108 -1
- package/src/react.ts +47 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- package/src/ui/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +15 -4
- package/src/ui/MessageItem.vue +24 -6
- package/src/ui/MyCommunityView.vue +9 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/styles.css +16 -0
- package/src/vue.ts +129 -3
package/src/core/discussion.ts
CHANGED
|
@@ -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); `
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
286
|
+
if (!r.ok || disposed) return
|
|
287
|
+
state.me = r.value
|
|
288
|
+
scheduleBrakeEnd()
|
|
244
289
|
})
|
|
245
290
|
return mePromise
|
|
246
291
|
}
|
|
247
292
|
|
|
248
|
-
/**
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/format.ts
CHANGED
|
@@ -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
|