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