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