@blogmcp/community 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-WadUnaTa.js +6436 -0
- package/dist/core/attrs.d.ts +99 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +175 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +24 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +92 -0
- package/dist/core/outcomes.d.ts +29 -0
- package/dist/core/panel.d.ts +96 -0
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +35 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +47 -0
- package/dist/global.d.ts +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +45 -0
- package/dist/mount.d.ts +105 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +55 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +44 -0
- package/dist/ui/LinkedText.vue.d.ts +6 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +26 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +200 -0
- package/dist/vue.js +201 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +186 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/client.ts +54 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +876 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +112 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +492 -0
- package/src/core/outcomes.ts +86 -0
- package/src/core/panel.ts +446 -0
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +174 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +165 -0
- package/src/global.ts +26 -0
- package/src/index.ts +70 -0
- package/src/mount.ts +234 -0
- package/src/react.ts +97 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +334 -0
- package/src/ui/LinkedText.vue +24 -0
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +141 -0
- package/src/ui/MessageItem.vue +223 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +378 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +174 -0
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +259 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +242 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-discussion>`'s brain, without any view (m1-contracts.md §10.4): the SDK's discussion store fed by
|
|
3
|
+
* `discussions/list` pages and the `discussions/head` poll, the composer (a new message, a reply, an edit)
|
|
4
|
+
* with its optimistic rows, the Russmedia confirmation and the cooldowns, reactions, blocks, reports, and the
|
|
5
|
+
* inline onboarding. The element renders `controller.state`; tests drive it directly over a fake transport.
|
|
6
|
+
*
|
|
7
|
+
* Every request goes through the SDK client (the host's relative base); nothing here builds a path.
|
|
8
|
+
*/
|
|
9
|
+
import { reactive, shallowRef, type ShallowRef } from 'vue'
|
|
10
|
+
import {
|
|
11
|
+
isKnownModuleState,
|
|
12
|
+
COMMUNITY_MESSAGE_MAX,
|
|
13
|
+
COMMUNITY_REPORT_NOTE_MAX,
|
|
14
|
+
communityMentionHandles,
|
|
15
|
+
communityRulesOf,
|
|
16
|
+
createDiscussionStore,
|
|
17
|
+
findMessage,
|
|
18
|
+
isOwnMessage,
|
|
19
|
+
loadMoreDiscussion,
|
|
20
|
+
newClientMsgId,
|
|
21
|
+
pollDiscussionHead,
|
|
22
|
+
reactionAfter,
|
|
23
|
+
reactionsFor,
|
|
24
|
+
refreshDiscussion,
|
|
25
|
+
viewerNeeds,
|
|
26
|
+
type CaseCategory,
|
|
27
|
+
type CommunityCallOptions,
|
|
28
|
+
type CommunityClient,
|
|
29
|
+
type CommunityFailure,
|
|
30
|
+
type CommunityRule,
|
|
31
|
+
type DiscussionHead,
|
|
32
|
+
type DiscussionHeadPoller,
|
|
33
|
+
type DiscussionMessage,
|
|
34
|
+
type DiscussionState,
|
|
35
|
+
type DiscussionStore,
|
|
36
|
+
type KnownAuthor,
|
|
37
|
+
type MyState,
|
|
38
|
+
type ReactionTarget,
|
|
39
|
+
type ViewerNeeds,
|
|
40
|
+
} from '@blogmcp/sdk/community'
|
|
41
|
+
import { warnOnce } from './client'
|
|
42
|
+
import type { Lang, TextRef } from './i18n'
|
|
43
|
+
import { createOnboarding, type OnboardingModel } from './onboarding'
|
|
44
|
+
import { failureEffect, failureText, waitUntilOf } from './outcomes'
|
|
45
|
+
import type { DiscussionRealtime, DiscussionRealtimeSession } from './realtime'
|
|
46
|
+
import { codePointLength, normalizeBody } from './text'
|
|
47
|
+
|
|
48
|
+
export type DiscussionPhase = 'loading' | 'ready' | 'failed' | 'hidden'
|
|
49
|
+
|
|
50
|
+
/** One composer's text and what happened to it. */
|
|
51
|
+
export interface Draft {
|
|
52
|
+
body: string
|
|
53
|
+
sending: boolean
|
|
54
|
+
failure: CommunityFailure | null
|
|
55
|
+
/** CONFIRMATION_REQUIRED: the codes the author confirms before the same text goes again. */
|
|
56
|
+
confirm: { codes: string[]; checked: string[] } | null
|
|
57
|
+
/** A cooldown: the epoch milliseconds until which this write waits. */
|
|
58
|
+
waitUntil: number | null
|
|
59
|
+
/** A post's id, kept until kb-api took it: a retry of a write whose answer was lost cannot post twice. */
|
|
60
|
+
clientMsgId: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type InlineKind = 'reply' | 'edit'
|
|
64
|
+
|
|
65
|
+
/** The one reply or edit open under a message (opening another closes it). */
|
|
66
|
+
export interface InlineDraft extends Draft {
|
|
67
|
+
kind: InlineKind
|
|
68
|
+
/** The message edited, or the TOP-LEVEL message a reply goes under (two levels only). */
|
|
69
|
+
seq: number
|
|
70
|
+
/** A reply to a reply names whom it answers. */
|
|
71
|
+
replyingTo: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ReportDraft {
|
|
75
|
+
conversationId: string
|
|
76
|
+
seq: number
|
|
77
|
+
category: CaseCategory | ''
|
|
78
|
+
ruleId: string
|
|
79
|
+
note: string
|
|
80
|
+
rules: CommunityRule[] | null
|
|
81
|
+
rulesFailed: boolean
|
|
82
|
+
sending: boolean
|
|
83
|
+
failure: CommunityFailure | null
|
|
84
|
+
done: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Where the composer stands: `write`; `signIn` (no session that can write); `onboarding` (the inline
|
|
89
|
+
* onboarding waits); `ageRefused` (the age band declared in it was below the minimum: reading only, the
|
|
90
|
+
* onboarding is not offered again); `sanction` (a restriction, suspension or ban in effect); `notMoved` (the
|
|
91
|
+
* discussion takes no writes yet — one sentence); `restricted` (the member is read-only or deleting their
|
|
92
|
+
* profile); `braked` (M2: the community is paused — `me/get`'s `module.brake`, or a write refused BRAKED: reading
|
|
93
|
+
* goes on, every member write waits — one sentence); `pending` (being decided).
|
|
94
|
+
*
|
|
95
|
+
* A module that is `read_only` changes nothing here (m2-contracts.md §3.1: the discussions under articles keep
|
|
96
|
+
* taking writes; only live updates, push and the Pro surfaces stop) — the discussion polls instead of a socket.
|
|
97
|
+
*/
|
|
98
|
+
export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'braked' | 'pending'
|
|
99
|
+
|
|
100
|
+
export interface DiscussionUi {
|
|
101
|
+
phase: DiscussionPhase
|
|
102
|
+
/** Why the first load failed (`failed`). */
|
|
103
|
+
failure: CommunityFailure | null
|
|
104
|
+
thread: DiscussionState
|
|
105
|
+
loadingMore: boolean
|
|
106
|
+
refreshing: boolean
|
|
107
|
+
/** A head poll saw messages the page does not show yet. */
|
|
108
|
+
newMessages: boolean
|
|
109
|
+
me: MyState | null
|
|
110
|
+
/** What a write or the member's state taught: no writes yet, none for this member, or a pause (BRAKED). */
|
|
111
|
+
block: 'notMoved' | 'restricted' | 'braked' | null
|
|
112
|
+
composer: Draft
|
|
113
|
+
inline: InlineDraft | null
|
|
114
|
+
report: ReportDraft | null
|
|
115
|
+
/** The last outcome to announce (aria-live), and a counter so the same text is announced again. */
|
|
116
|
+
announcement: TextRef | null
|
|
117
|
+
announceId: number
|
|
118
|
+
/** Now, ticking while a cooldown runs. */
|
|
119
|
+
clock: number
|
|
120
|
+
/** Actions on their way, by key (`react:…`, `delete:<seq>`, `block:<memberId>`). */
|
|
121
|
+
busy: Record<string, boolean>
|
|
122
|
+
/**
|
|
123
|
+
* The minimum age a declared band fell below (AGE_BELOW_MINIMUM). kb-api keeps nothing of a refused band
|
|
124
|
+
* (§7.4.2), so the element does: the onboarding is not offered again while it lives.
|
|
125
|
+
*/
|
|
126
|
+
refusedAge: number | null
|
|
127
|
+
/**
|
|
128
|
+
* M2 T9: the onboarding just finished with the art. 9 consent refused (`public_write`): the composer says once,
|
|
129
|
+
* in view, what the refusal leaves (writing in the public discussion). Not kept beyond this element.
|
|
130
|
+
*/
|
|
131
|
+
art9RefusedNote: boolean
|
|
132
|
+
/**
|
|
133
|
+
* M2 (§3.5): the live updates' session fell back to polling (live updates are off here, or the server cut the
|
|
134
|
+
* socket for good — 4501): the discussion says so once and keeps polling; it never opens the socket again itself.
|
|
135
|
+
*/
|
|
136
|
+
realtimeOff: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface DiscussionControllerOptions {
|
|
140
|
+
client: CommunityClient
|
|
141
|
+
articleId: string
|
|
142
|
+
lang: Lang
|
|
143
|
+
/** Head poll interval (default 20 s; 0 = no polling). */
|
|
144
|
+
pollMs?: number
|
|
145
|
+
now?: () => number
|
|
146
|
+
/** The viewer's message was posted (`held`: it waits for review). */
|
|
147
|
+
onPosted?(state: 'visible' | 'held'): void
|
|
148
|
+
/** A refusal the element saw before (a discussion started again in another language keeps it). */
|
|
149
|
+
refusedAge?: number | null
|
|
150
|
+
/** The onboarding's age band was refused: the element keeps it for its next discussions. */
|
|
151
|
+
onAgeRefused?(minAge: number): void
|
|
152
|
+
/**
|
|
153
|
+
* M2 (§3.5, §3.10): the host's live-updates session factory (`createRealtimeSession` of
|
|
154
|
+
* `@blogmcp/sdk/community/realtime`). Absent: the head poll, as before.
|
|
155
|
+
*/
|
|
156
|
+
realtime?: DiscussionRealtime | null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface DiscussionController {
|
|
160
|
+
readonly state: DiscussionUi
|
|
161
|
+
readonly store: DiscussionStore
|
|
162
|
+
readonly onboarding: ShallowRef<OnboardingModel | null>
|
|
163
|
+
/** The first load, then the poll. */
|
|
164
|
+
start(): Promise<void>
|
|
165
|
+
refresh(): Promise<void>
|
|
166
|
+
loadMore(): Promise<void>
|
|
167
|
+
mode(): ComposerMode
|
|
168
|
+
needs(): ViewerNeeds
|
|
169
|
+
isOwn(m: Pick<DiscussionMessage, 'author'>): boolean
|
|
170
|
+
canReply(m: DiscussionMessage): boolean
|
|
171
|
+
canEdit(m: DiscussionMessage): boolean
|
|
172
|
+
canDelete(m: DiscussionMessage): boolean
|
|
173
|
+
canReact(): boolean
|
|
174
|
+
/** A member's report (`messages/report`): any signed-in reader, on someone else's message. */
|
|
175
|
+
canReport(m: DiscussionMessage): boolean
|
|
176
|
+
/** A reader without a session reports through the host's notice page instead (reportLink). */
|
|
177
|
+
canReportAsNotifier(m: DiscussionMessage): boolean
|
|
178
|
+
canBlock(m: DiscussionMessage): boolean
|
|
179
|
+
/** The poll may run (the element is on screen). */
|
|
180
|
+
setInView(inView: boolean): void
|
|
181
|
+
submit(): Promise<void>
|
|
182
|
+
/** Reply under `seq`'s thread (a reply to a reply goes under its top-level message, naming its author). */
|
|
183
|
+
openReply(seq: number, replyingTo?: string | null): void
|
|
184
|
+
openEdit(seq: number): void
|
|
185
|
+
closeInline(): void
|
|
186
|
+
submitInline(): Promise<void>
|
|
187
|
+
/** Tick or untick one code of a draft's confirmation. */
|
|
188
|
+
setConfirmed(draft: Draft, code: string, checked: boolean): void
|
|
189
|
+
/** Send the draft again with every confirmed code (the Russmedia confirmation). */
|
|
190
|
+
sendConfirmed(draft: Draft): Promise<void>
|
|
191
|
+
remove(seq: number): Promise<boolean>
|
|
192
|
+
react(target: ReactionTarget, emoji: string): Promise<void>
|
|
193
|
+
setBlocked(memberId: string, blocked: boolean): Promise<void>
|
|
194
|
+
openReport(m: DiscussionMessage): void
|
|
195
|
+
closeReport(): void
|
|
196
|
+
submitReport(): Promise<void>
|
|
197
|
+
/**
|
|
198
|
+
* The onboarding inline; `offerArt9` asks the art. 9 consent also of a member who refused it (`public_write`, M2 T9) —
|
|
199
|
+
* the give-consent link on their own message held until the consent.
|
|
200
|
+
*/
|
|
201
|
+
beginOnboarding(options?: { offerArt9?: boolean }): void
|
|
202
|
+
cancelOnboarding(): void
|
|
203
|
+
/** Seconds a draft still waits (0 when it may go). */
|
|
204
|
+
waitLeft(draft: Draft): number
|
|
205
|
+
/**
|
|
206
|
+
* M2 T10: the distinct `@handle`s one message of the viewer may carry (me/get's `caps.mentionsPerMessage`), or null
|
|
207
|
+
* when nothing is known to limit it (no member yet, an older kb-api, a module whose mentions notify nobody).
|
|
208
|
+
*/
|
|
209
|
+
mentionCap(): number | null
|
|
210
|
+
dispose(): void
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const emptyDraft = (body = ''): Draft => ({ body, sending: false, failure: null, confirm: null, waitUntil: null, clientMsgId: null })
|
|
214
|
+
|
|
215
|
+
const documentVisible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
|
216
|
+
|
|
217
|
+
export function createDiscussionController(o: DiscussionControllerOptions): DiscussionController {
|
|
218
|
+
const { client, lang } = o
|
|
219
|
+
const articleId = o.articleId.toLowerCase()
|
|
220
|
+
const now = o.now ?? Date.now
|
|
221
|
+
const store = createDiscussionStore()
|
|
222
|
+
const abort = new AbortController()
|
|
223
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
224
|
+
|
|
225
|
+
const state = reactive<DiscussionUi>({
|
|
226
|
+
phase: 'loading',
|
|
227
|
+
failure: null,
|
|
228
|
+
thread: store.getState(),
|
|
229
|
+
loadingMore: false,
|
|
230
|
+
refreshing: false,
|
|
231
|
+
newMessages: false,
|
|
232
|
+
me: null,
|
|
233
|
+
block: null,
|
|
234
|
+
composer: emptyDraft(),
|
|
235
|
+
inline: null,
|
|
236
|
+
report: null,
|
|
237
|
+
announcement: null,
|
|
238
|
+
announceId: 0,
|
|
239
|
+
clock: now(),
|
|
240
|
+
busy: {},
|
|
241
|
+
refusedAge: o.refusedAge ?? null,
|
|
242
|
+
art9RefusedNote: false,
|
|
243
|
+
realtimeOff: false,
|
|
244
|
+
}) as DiscussionUi
|
|
245
|
+
const unsubscribe = store.subscribe((s) => {
|
|
246
|
+
state.thread = s
|
|
247
|
+
})
|
|
248
|
+
const onboarding = shallowRef<OnboardingModel | null>(null)
|
|
249
|
+
/** The open onboarding is the give-consent flow (read with `onboarding`, which it is set beside). */
|
|
250
|
+
let consentFlow = false
|
|
251
|
+
|
|
252
|
+
let poller: DiscussionHeadPoller | null = null
|
|
253
|
+
let session: DiscussionRealtimeSession | null = null
|
|
254
|
+
let inView = true
|
|
255
|
+
let disposed = false
|
|
256
|
+
let lastSync = 0
|
|
257
|
+
let refreshing: Promise<void> | null = null
|
|
258
|
+
let mePromise: Promise<void> | null = null
|
|
259
|
+
let ticker: ReturnType<typeof setInterval> | null = null
|
|
260
|
+
/** The brake's end (its `until`, or a BRAKED refusal's): me/get is read again then. */
|
|
261
|
+
let brakeTimer: ReturnType<typeof setTimeout> | null = null
|
|
262
|
+
/** A BRAKED refusal's own end (epoch ms), while `state.block` is `braked` because of it; null: none given. */
|
|
263
|
+
let refusedBrakeUntil: number | null = null
|
|
264
|
+
const interval = o.pollMs ?? 20_000
|
|
265
|
+
|
|
266
|
+
function announce(text: TextRef): void {
|
|
267
|
+
state.announcement = text
|
|
268
|
+
state.announceId += 1
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function hide(): void {
|
|
272
|
+
state.phase = 'hidden'
|
|
273
|
+
poller?.stop()
|
|
274
|
+
poller = null
|
|
275
|
+
session?.stop()
|
|
276
|
+
session = null
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ───────────────────────── loading ─────────────────────────
|
|
280
|
+
|
|
281
|
+
/** me/get once (`fresh`: again, keeping what is shown until the answer is in — the composer does not flicker). */
|
|
282
|
+
function ensureMe(fresh = false): Promise<void> {
|
|
283
|
+
if (state.me && !fresh) return Promise.resolve()
|
|
284
|
+
mePromise ??= client.me.get(call).then((r) => {
|
|
285
|
+
mePromise = null
|
|
286
|
+
if (!r.ok || disposed) return
|
|
287
|
+
state.me = r.value
|
|
288
|
+
scheduleBrakeEnd()
|
|
289
|
+
})
|
|
290
|
+
return mePromise
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The end of a brake as me/get states it (`module.brake.until`, or `module.until` of a braked module), epoch ms. */
|
|
294
|
+
function brakeEndOf(me: MyState | null): number | null {
|
|
295
|
+
const m = me?.module
|
|
296
|
+
if (!m) return null
|
|
297
|
+
const until = m.brake ? m.brake.until : isKnownModuleState(m.state) && m.state === 'braked' ? m.until : undefined
|
|
298
|
+
const at = typeof until === 'string' ? Date.parse(until) : NaN
|
|
299
|
+
return Number.isFinite(at) ? at : null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* While the discussion is braked, a timer at the brake's end (the later of me/get's `until` and a refusal's): then
|
|
304
|
+
* me/get is read again and the composer comes back when the brake is over. A brake without an end waits for the
|
|
305
|
+
* next refresh (afterLoad reads me/get again while braked).
|
|
306
|
+
*/
|
|
307
|
+
function scheduleBrakeEnd(): void {
|
|
308
|
+
if (brakeTimer) clearTimeout(brakeTimer)
|
|
309
|
+
brakeTimer = null
|
|
310
|
+
if (disposed) return
|
|
311
|
+
const ends: number[] = []
|
|
312
|
+
if (brakedNow()) {
|
|
313
|
+
const at = brakeEndOf(state.me)
|
|
314
|
+
if (at !== null) ends.push(at)
|
|
315
|
+
}
|
|
316
|
+
if (state.block === 'braked' && refusedBrakeUntil !== null) ends.push(refusedBrakeUntil)
|
|
317
|
+
if (ends.length === 0) return
|
|
318
|
+
// A second after the end (clocks differ by a little), within setTimeout's range; an end me/get still reports
|
|
319
|
+
// after it passed (the brake not lifted yet on kb-api's side) is asked again every 30 s, never in a tight loop.
|
|
320
|
+
const left = Math.max(...ends) - now()
|
|
321
|
+
const delay = left > 0 ? Math.min(left + 1000, 2_147_000_000) : 30_000
|
|
322
|
+
brakeTimer = setTimeout(() => {
|
|
323
|
+
brakeTimer = null
|
|
324
|
+
if (disposed) return
|
|
325
|
+
liftRefusedBrake()
|
|
326
|
+
void ensureMe(true)
|
|
327
|
+
}, delay)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A BRAKED refusal's block ends once its own end has passed (or it gave none): me/get decides from then on. */
|
|
331
|
+
function liftRefusedBrake(): void {
|
|
332
|
+
if (state.block !== 'braked') return
|
|
333
|
+
if (refusedBrakeUntil !== null && refusedBrakeUntil > now()) return
|
|
334
|
+
state.block = null
|
|
335
|
+
refusedBrakeUntil = null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* canPost is false, nothing is needed and no sanction applies: a brake (M2 T10: kb-api's canPost is false while one
|
|
340
|
+
* is in effect — me/get, read again, names it, and the composer gives way to the brake's sentence), else the
|
|
341
|
+
* member's status says whether it is the cutover.
|
|
342
|
+
*/
|
|
343
|
+
function decideBlock(): void {
|
|
344
|
+
const s = store.getState()
|
|
345
|
+
if (s.viewer.canPost || viewerNeeds(s).any || s.viewer.restrictions.length > 0) return
|
|
346
|
+
void ensureMe(state.block === null).then(() => {
|
|
347
|
+
const me = state.me
|
|
348
|
+
if (disposed || !me || store.getState().viewer.canPost) return
|
|
349
|
+
if (brakedNow()) return
|
|
350
|
+
// An active member who may not post: the discussion takes no writes yet (the cutover).
|
|
351
|
+
state.block = !me.member || me.member.status === 'active' ? 'notMoved' : 'restricted'
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function afterLoad(): void {
|
|
356
|
+
const s = store.getState()
|
|
357
|
+
// A braked discussion asks me/get again on every refresh: the brake may be over (kb-api's canPost does not know it).
|
|
358
|
+
const wasBraked = state.block === 'braked' || brakedNow()
|
|
359
|
+
if (s.viewer.canPost && state.block !== 'braked') state.block = null
|
|
360
|
+
if (wasBraked) liftRefusedBrake()
|
|
361
|
+
if (s.viewer.memberId !== null) void ensureMe(wasBraked)
|
|
362
|
+
decideBlock()
|
|
363
|
+
// Finished elsewhere (another tab): nothing left to onboard.
|
|
364
|
+
if (onboarding.value && !viewerNeeds(s).any && onboarding.value.state.phase !== 'sending') onboarding.value = null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function loadFailed(f: CommunityFailure): void {
|
|
368
|
+
if (f.code === 'surfaceOff') return hide()
|
|
369
|
+
if (f.code === 'notFound' && state.phase !== 'ready') {
|
|
370
|
+
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
|
+
return hide()
|
|
372
|
+
}
|
|
373
|
+
if (state.phase === 'loading' || state.phase === 'failed') {
|
|
374
|
+
state.phase = 'failed'
|
|
375
|
+
state.failure = f
|
|
376
|
+
}
|
|
377
|
+
// A loaded discussion keeps what it shows; the poll tries again.
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function refresh(): Promise<void> {
|
|
381
|
+
if (disposed) return Promise.resolve()
|
|
382
|
+
refreshing ??= (async () => {
|
|
383
|
+
state.refreshing = true
|
|
384
|
+
const r = await refreshDiscussion(store, client, { articleId, languageCode: lang }, call)
|
|
385
|
+
state.refreshing = false
|
|
386
|
+
if (disposed) return
|
|
387
|
+
lastSync = now()
|
|
388
|
+
if (r.ok) {
|
|
389
|
+
state.phase = 'ready'
|
|
390
|
+
state.failure = null
|
|
391
|
+
state.newMessages = false
|
|
392
|
+
afterLoad()
|
|
393
|
+
} else {
|
|
394
|
+
loadFailed(r)
|
|
395
|
+
}
|
|
396
|
+
})().finally(() => {
|
|
397
|
+
refreshing = null
|
|
398
|
+
})
|
|
399
|
+
return refreshing
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function loadMore(): Promise<void> {
|
|
403
|
+
if (state.loadingMore || state.thread.nextCursor === null) return
|
|
404
|
+
state.loadingMore = true
|
|
405
|
+
const r = await loadMoreDiscussion(store, client, { articleId, languageCode: lang }, call)
|
|
406
|
+
state.loadingMore = false
|
|
407
|
+
if (disposed || r.ok) return
|
|
408
|
+
if (r.code === 'surfaceOff') return hide()
|
|
409
|
+
announce(failureText(r, lang))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function onHeads(heads: DiscussionHead[]): void {
|
|
413
|
+
for (const head of heads) {
|
|
414
|
+
if (typeof head?.articleId !== 'string' || head.articleId.toLowerCase() !== articleId) continue
|
|
415
|
+
if (state.phase === 'failed') {
|
|
416
|
+
void refresh()
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
const before = store.getState()
|
|
420
|
+
const newer = typeof head.lastSeq === 'number' && head.lastSeq > before.lastSeq
|
|
421
|
+
if (!store.applyHead(head)) continue
|
|
422
|
+
// New messages wait for the reader (the page does not move under them); an edit, a reaction or the
|
|
423
|
+
// reader's own post are folded in at once.
|
|
424
|
+
if (newer && before.loaded) state.newMessages = true
|
|
425
|
+
else void refresh()
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function startPolling(): void {
|
|
430
|
+
if (poller || interval === 0 || disposed || state.phase === 'hidden') return
|
|
431
|
+
poller = pollDiscussionHead(client, [articleId], {
|
|
432
|
+
intervalMs: interval,
|
|
433
|
+
visible: () => documentVisible() && inView,
|
|
434
|
+
onChange: onHeads,
|
|
435
|
+
onFailure: (f) => {
|
|
436
|
+
if (f.code === 'surfaceOff') hide()
|
|
437
|
+
},
|
|
438
|
+
signal: abort.signal,
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Live updates (M2, §3.5): the host's session over this element's store, after the first list. Not for an anonymous
|
|
444
|
+
* reader (no host session: the token route refuses it) nor while `me/get` says the module offers none (`read_only`,
|
|
445
|
+
* `dark`, live updates not configured) — the head poll then, and no socket. The session falls back to polling by
|
|
446
|
+
* itself (`realtimeOff`, the server's terminal 4501) and the element never starts it again.
|
|
447
|
+
*/
|
|
448
|
+
async function startRealtime(factory: DiscussionRealtime): Promise<void> {
|
|
449
|
+
if (session || disposed || state.phase === 'hidden') return
|
|
450
|
+
if (viewerNeeds(store.getState()).signIn) return startPolling()
|
|
451
|
+
await ensureMe()
|
|
452
|
+
// The discussion may have been hidden (SURFACE_OFF) while me/get was on its way.
|
|
453
|
+
if (disposed || session || (state.phase as DiscussionPhase) === 'hidden') return
|
|
454
|
+
if (state.me?.module && state.me.module.features.realtime !== true) return startPolling()
|
|
455
|
+
try {
|
|
456
|
+
session = factory({
|
|
457
|
+
client,
|
|
458
|
+
store,
|
|
459
|
+
languageCode: lang,
|
|
460
|
+
visible: () => documentVisible() && inView,
|
|
461
|
+
...(interval > 0 ? { pollIntervalMs: interval } : {}),
|
|
462
|
+
onMode: (mode) => {
|
|
463
|
+
if (disposed) return
|
|
464
|
+
// Said once per fallback: live again (the SDK session reconnected) takes the sentence away.
|
|
465
|
+
if (mode === 'polling' && !state.realtimeOff) state.realtimeOff = true
|
|
466
|
+
else if (mode === 'live' && state.realtimeOff) state.realtimeOff = false
|
|
467
|
+
},
|
|
468
|
+
})
|
|
469
|
+
} catch {
|
|
470
|
+
warnOnce('realtime', '[blogmcp-community] the realtime property is not a session factory (createRealtimeSession of @blogmcp/sdk/community/realtime): the discussion polls')
|
|
471
|
+
session = null
|
|
472
|
+
startPolling()
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The member's community is paused (`me/get`'s module.brake or state, M2 §3.1): every member write is refused. */
|
|
477
|
+
function brakedNow(): boolean {
|
|
478
|
+
const m = state.me?.module
|
|
479
|
+
if (!m) return false
|
|
480
|
+
return m.brake !== undefined || (isKnownModuleState(m.state) && m.state === 'braked')
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ───────────────────────── who may do what ─────────────────────────
|
|
484
|
+
|
|
485
|
+
function mode(): ComposerMode {
|
|
486
|
+
const s = state.thread
|
|
487
|
+
const n = viewerNeeds(s)
|
|
488
|
+
if (n.signIn) return 'signIn'
|
|
489
|
+
if (state.block === 'notMoved') return 'notMoved'
|
|
490
|
+
if (state.block === 'braked' || brakedNow()) return 'braked'
|
|
491
|
+
if (!s.viewer.canPost && s.viewer.restrictions.length > 0) return 'sanction'
|
|
492
|
+
// A refused age band: the refusal stays where the composer was once the reader closed it.
|
|
493
|
+
if (n.any) return state.refusedAge !== null && !onboarding.value ? 'ageRefused' : 'onboarding'
|
|
494
|
+
// The give-consent flow of a member who refused the consent (needs name none): its step where the composer was.
|
|
495
|
+
if (onboarding.value && consentFlow) return 'onboarding'
|
|
496
|
+
if (state.block === 'restricted') return 'restricted'
|
|
497
|
+
return s.viewer.canPost ? 'write' : 'pending'
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** me/get's per-message mention cap, where kb-api applies it (a member, the module live or braked: §3.3). */
|
|
501
|
+
function mentionCap(): number | null {
|
|
502
|
+
const me = state.me
|
|
503
|
+
const cap = me?.caps?.mentionsPerMessage
|
|
504
|
+
const m = me?.module?.state
|
|
505
|
+
if (!me?.member || typeof cap !== 'number' || !Number.isSafeInteger(cap) || cap < 0) return null
|
|
506
|
+
return m === 'live' || m === 'braked' ? cap : null
|
|
507
|
+
}
|
|
508
|
+
/** The draft mentions more distinct handles than the cap (kb-api's count: as typed, whether they exist or not). */
|
|
509
|
+
const overMentionCap = (body: string): boolean => {
|
|
510
|
+
const cap = mentionCap()
|
|
511
|
+
return cap !== null && communityMentionHandles(body).length > cap
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const isOwn = (m: Pick<DiscussionMessage, 'author'>): boolean => isOwnMessage(state.thread, m)
|
|
515
|
+
const settled = (m: DiscussionMessage): boolean => !m.pending && m.seq > 0
|
|
516
|
+
const canWrite = (): boolean => mode() === 'write'
|
|
517
|
+
|
|
518
|
+
// ───────────────────────── writes ─────────────────────────
|
|
519
|
+
|
|
520
|
+
/** The viewer as their own messages show them (for the optimistic row), or null when not known yet. */
|
|
521
|
+
function ownAuthor(): KnownAuthor | null {
|
|
522
|
+
const me = state.me
|
|
523
|
+
if (me?.member && me.profile) {
|
|
524
|
+
return { kind: 'member', memberId: me.member.memberId, handle: me.profile.handle, displayName: me.profile.displayName, avatarPreset: me.profile.avatarPreset }
|
|
525
|
+
}
|
|
526
|
+
const viewer = state.thread.viewer.memberId
|
|
527
|
+
if (viewer === null) return null
|
|
528
|
+
for (const m of state.thread.messages) {
|
|
529
|
+
for (const x of [m, ...m.replies]) if (x.author.kind === 'member' && x.author.memberId === viewer) return x.author
|
|
530
|
+
}
|
|
531
|
+
return null
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function startClock(): void {
|
|
535
|
+
state.clock = now()
|
|
536
|
+
if (ticker) return
|
|
537
|
+
ticker = setInterval(() => {
|
|
538
|
+
state.clock = now()
|
|
539
|
+
let waiting = false
|
|
540
|
+
for (const d of [state.composer, state.inline]) {
|
|
541
|
+
if (!d || d.waitUntil === null) continue
|
|
542
|
+
if (d.waitUntil <= state.clock) {
|
|
543
|
+
d.waitUntil = null
|
|
544
|
+
if (d.failure && (d.failure.code === 'fast' || d.failure.code === 'quota')) d.failure = null
|
|
545
|
+
} else {
|
|
546
|
+
waiting = true
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (!waiting && ticker) {
|
|
550
|
+
clearInterval(ticker)
|
|
551
|
+
ticker = null
|
|
552
|
+
}
|
|
553
|
+
}, 1000)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const waitLeft = (d: Draft): number => (d.waitUntil === null ? 0 : Math.max(0, Math.ceil((d.waitUntil - Math.max(state.clock, now())) / 1000)))
|
|
557
|
+
|
|
558
|
+
/** What a refused write does besides showing its sentence. */
|
|
559
|
+
function refused(d: Draft | null, f: CommunityFailure): void {
|
|
560
|
+
if (d) d.failure = f
|
|
561
|
+
switch (failureEffect(f.code)) {
|
|
562
|
+
case 'confirm':
|
|
563
|
+
if (d && f.codes && f.codes.length > 0) d.confirm = { codes: [...f.codes], checked: [] }
|
|
564
|
+
return
|
|
565
|
+
case 'wait':
|
|
566
|
+
if (d) {
|
|
567
|
+
d.waitUntil = waitUntilOf(f, now())
|
|
568
|
+
if (d.waitUntil !== null) startClock()
|
|
569
|
+
}
|
|
570
|
+
if (f.code === 'braked') {
|
|
571
|
+
// Paused (BRAKED): the composer gives way to the sentence until the brake's end; me/get is read again.
|
|
572
|
+
state.block = 'braked'
|
|
573
|
+
refusedBrakeUntil = waitUntilOf(f, now())
|
|
574
|
+
scheduleBrakeEnd()
|
|
575
|
+
void ensureMe(true)
|
|
576
|
+
}
|
|
577
|
+
return
|
|
578
|
+
case 'onboard':
|
|
579
|
+
beginOnboarding()
|
|
580
|
+
void refresh()
|
|
581
|
+
return
|
|
582
|
+
case 'reload':
|
|
583
|
+
void refresh()
|
|
584
|
+
return
|
|
585
|
+
case 'notMoved':
|
|
586
|
+
state.block = 'notMoved'
|
|
587
|
+
return
|
|
588
|
+
case 'hide':
|
|
589
|
+
// Live updates switched off hide nothing: the discussion polls (no write answers it; a realtime route does).
|
|
590
|
+
return f.code === 'realtimeOff' ? undefined : hide()
|
|
591
|
+
default:
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
type Target = { kind: 'new' } | { kind: InlineKind; seq: number }
|
|
597
|
+
|
|
598
|
+
async function send(d: Draft, target: Target, confirmations?: string[]): Promise<void> {
|
|
599
|
+
if (d.sending || waitLeft(d) > 0 || disposed) return
|
|
600
|
+
const body = normalizeBody(d.body)
|
|
601
|
+
if (body === '' || codePointLength(body) > COMMUNITY_MESSAGE_MAX) return
|
|
602
|
+
const n = viewerNeeds(state.thread)
|
|
603
|
+
if (n.signIn) return
|
|
604
|
+
if (n.any) return beginOnboarding()
|
|
605
|
+
const parent = target.kind === 'new' ? null : findMessage(state.thread, target.seq)
|
|
606
|
+
if (target.kind !== 'new' && (!parent || !settled(parent))) return
|
|
607
|
+
// Too many mentions for the member's level: said before posting (the composer shows it as it is typed); kb-api's
|
|
608
|
+
// MENTION_LIMIT stays the rule.
|
|
609
|
+
if (overMentionCap(body)) {
|
|
610
|
+
d.failure = { ok: false, code: 'mentionLimit' }
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
d.sending = true
|
|
615
|
+
d.failure = null
|
|
616
|
+
const author = target.kind === 'edit' ? null : ownAuthor()
|
|
617
|
+
// A post keeps its id until kb-api took it; an edit's id only names its optimistic change.
|
|
618
|
+
const changeId = target.kind === 'edit' ? newClientMsgId() : (d.clientMsgId ??= newClientMsgId())
|
|
619
|
+
if (target.kind === 'edit') store.applyMessage({ type: 'edit', clientMsgId: changeId, seq: target.seq, body })
|
|
620
|
+
else if (author) store.applyMessage({ type: 'post', clientMsgId: changeId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, author, languageCode: lang })
|
|
621
|
+
|
|
622
|
+
const r =
|
|
623
|
+
target.kind === 'edit'
|
|
624
|
+
? await client.messages.edit({ conversationId: parent!.conversationId, seq: target.seq, body, ...(confirmations ? { confirmations } : {}) }, call)
|
|
625
|
+
: await client.messages.post(
|
|
626
|
+
{ articleId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, clientMsgId: changeId, languageCode: lang, ...(confirmations ? { confirmations } : {}) },
|
|
627
|
+
call,
|
|
628
|
+
)
|
|
629
|
+
if (disposed) return
|
|
630
|
+
d.sending = false
|
|
631
|
+
if (r.ok) {
|
|
632
|
+
store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message, warnings: r.value.warnings })
|
|
633
|
+
const held = r.value.message.state === 'held'
|
|
634
|
+
d.body = ''
|
|
635
|
+
d.clientMsgId = null
|
|
636
|
+
d.confirm = null
|
|
637
|
+
d.failure = null
|
|
638
|
+
if (target.kind !== 'new' && state.inline === d) state.inline = null
|
|
639
|
+
announce({ key: held ? 'composer.held' : 'composer.posted' })
|
|
640
|
+
if (target.kind !== 'edit') o.onPosted?.(held ? 'held' : 'visible')
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
store.applyMessage({ type: 'rollback', clientMsgId: changeId })
|
|
644
|
+
refused(d, r)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function setConfirmed(d: Draft, code: string, checked: boolean): void {
|
|
648
|
+
if (!d.confirm || !d.confirm.codes.includes(code)) return
|
|
649
|
+
const rest = d.confirm.checked.filter((c) => c !== code)
|
|
650
|
+
d.confirm.checked = checked ? [...rest, code] : rest
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function sendConfirmed(d: Draft): Promise<void> {
|
|
654
|
+
const confirm = d.confirm
|
|
655
|
+
if (!confirm || !confirm.codes.every((c) => confirm.checked.includes(c))) return
|
|
656
|
+
const target: Target = d === state.composer ? { kind: 'new' } : state.inline === d ? { kind: state.inline.kind, seq: state.inline.seq } : { kind: 'new' }
|
|
657
|
+
await send(d, target, [...confirm.codes])
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function remove(seq: number): Promise<boolean> {
|
|
661
|
+
const m = findMessage(state.thread, seq)
|
|
662
|
+
const key = `delete:${seq}`
|
|
663
|
+
if (!m || !settled(m) || state.busy[key]) return false
|
|
664
|
+
state.busy[key] = true
|
|
665
|
+
const changeId = newClientMsgId()
|
|
666
|
+
store.applyMessage({ type: 'delete', clientMsgId: changeId, seq })
|
|
667
|
+
const r = await client.messages.delete({ conversationId: m.conversationId, seq }, call)
|
|
668
|
+
delete state.busy[key]
|
|
669
|
+
if (disposed) return false
|
|
670
|
+
if (r.ok) {
|
|
671
|
+
store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message })
|
|
672
|
+
return true
|
|
673
|
+
}
|
|
674
|
+
store.applyMessage({ type: 'rollback', clientMsgId: changeId })
|
|
675
|
+
announce(failureText(r, lang))
|
|
676
|
+
refused(null, r)
|
|
677
|
+
return false
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const reactionKey = (t: ReactionTarget): string => ('articleId' in t ? 'react:article' : `react:${t.seq}`)
|
|
681
|
+
|
|
682
|
+
async function react(target: ReactionTarget, emoji: string): Promise<void> {
|
|
683
|
+
const key = reactionKey(target)
|
|
684
|
+
if (!canWrite() || state.busy[key] || !state.thread.reactionSet.includes(emoji)) return
|
|
685
|
+
const before = reactionsFor(state.thread, target)
|
|
686
|
+
const next = before.mine === emoji ? null : emoji
|
|
687
|
+
state.busy[key] = true
|
|
688
|
+
store.applyReaction(target, reactionAfter(before, next))
|
|
689
|
+
const r = await client.reactions.set({ target, emoji: next }, call)
|
|
690
|
+
delete state.busy[key]
|
|
691
|
+
if (disposed) return
|
|
692
|
+
if (r.ok) {
|
|
693
|
+
store.applyReaction(target, r.value.reactions)
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
store.applyReaction(target, before)
|
|
697
|
+
announce(failureText(r, lang))
|
|
698
|
+
refused(null, r)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function setBlocked(memberId: string, blocked: boolean): Promise<void> {
|
|
702
|
+
const key = `block:${memberId}`
|
|
703
|
+
if (state.busy[key]) return
|
|
704
|
+
state.busy[key] = true
|
|
705
|
+
const r = await client.blocks.set({ memberId, blocked }, call)
|
|
706
|
+
delete state.busy[key]
|
|
707
|
+
if (disposed) return
|
|
708
|
+
if (r.ok) {
|
|
709
|
+
store.collapse(memberId, r.value.blocked)
|
|
710
|
+
announce({ key: r.value.blocked ? 'discussion.blocked' : 'discussion.unblocked' })
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
announce(failureText(r, lang))
|
|
714
|
+
refused(null, r)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ───────────────────────── reports ─────────────────────────
|
|
718
|
+
|
|
719
|
+
function openReport(m: DiscussionMessage): void {
|
|
720
|
+
state.report = { conversationId: m.conversationId, seq: m.seq, category: '', ruleId: '', note: '', rules: null, rulesFailed: false, sending: false, failure: null, done: false }
|
|
721
|
+
const report = state.report
|
|
722
|
+
void client.legal.current({ kind: 'rules', locale: lang }, call).then((r) => {
|
|
723
|
+
if (state.report !== report) return
|
|
724
|
+
if (r.ok) report.rules = communityRulesOf(r.value.bodyJson)
|
|
725
|
+
else report.rulesFailed = true
|
|
726
|
+
})
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function submitReport(): Promise<void> {
|
|
730
|
+
const report = state.report
|
|
731
|
+
if (!report || report.sending || report.done || report.category === '') return
|
|
732
|
+
if (report.category === 'rules' && report.ruleId === '') return
|
|
733
|
+
const note = normalizeBody(report.note)
|
|
734
|
+
if (codePointLength(note) > COMMUNITY_REPORT_NOTE_MAX) return
|
|
735
|
+
report.sending = true
|
|
736
|
+
report.failure = null
|
|
737
|
+
const r = await client.messages.report(
|
|
738
|
+
{ conversationId: report.conversationId, seq: report.seq, category: report.category, ruleId: report.category === 'rules' ? report.ruleId : null, note: note === '' ? null : note },
|
|
739
|
+
call,
|
|
740
|
+
)
|
|
741
|
+
report.sending = false
|
|
742
|
+
if (disposed) return
|
|
743
|
+
if (r.ok) {
|
|
744
|
+
report.done = true
|
|
745
|
+
announce({ key: 'report.done' })
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
report.failure = r
|
|
749
|
+
if (r.code === 'surfaceOff') hide()
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ───────────────────────── onboarding ─────────────────────────
|
|
753
|
+
|
|
754
|
+
function beginOnboarding(options: { offerArt9?: boolean } = {}): void {
|
|
755
|
+
if (onboarding.value || disposed || state.refusedAge !== null) return
|
|
756
|
+
const offerArt9 = options.offerArt9 === true
|
|
757
|
+
consentFlow = offerArt9
|
|
758
|
+
const model = createOnboarding({
|
|
759
|
+
client,
|
|
760
|
+
lang,
|
|
761
|
+
signal: abort.signal,
|
|
762
|
+
...(offerArt9 ? { offerArt9: true } : {}),
|
|
763
|
+
onRefused: (minAge) => {
|
|
764
|
+
state.refusedAge = minAge
|
|
765
|
+
o.onAgeRefused?.(minAge)
|
|
766
|
+
},
|
|
767
|
+
onDone: (me) => {
|
|
768
|
+
state.me = me
|
|
769
|
+
onboarding.value = null
|
|
770
|
+
// A refused art. 9 consent (`public_write`): the member joined, and is told what the refusal leaves.
|
|
771
|
+
state.art9RefusedNote = model.state.art9RefusedSent
|
|
772
|
+
if (offerArt9) {
|
|
773
|
+
// The give-consent flow: announced only when a consent is in force (it also closes with nothing left to ask).
|
|
774
|
+
if (me.acceptances.art9Consent === 'accepted') announce({ key: 'panel.art9Given' })
|
|
775
|
+
} else announce({ key: model.state.art9RefusedSent ? 'onboarding.art9RefusedDone' : 'onboarding.done' })
|
|
776
|
+
void refresh()
|
|
777
|
+
},
|
|
778
|
+
onSignIn: () => {
|
|
779
|
+
onboarding.value = null
|
|
780
|
+
void refresh()
|
|
781
|
+
},
|
|
782
|
+
})
|
|
783
|
+
onboarding.value = model
|
|
784
|
+
void model.load()
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
state,
|
|
789
|
+
store,
|
|
790
|
+
onboarding,
|
|
791
|
+
async start() {
|
|
792
|
+
await refresh()
|
|
793
|
+
if (o.realtime && state.phase === 'ready') await startRealtime(o.realtime)
|
|
794
|
+
else startPolling()
|
|
795
|
+
},
|
|
796
|
+
refresh,
|
|
797
|
+
loadMore,
|
|
798
|
+
mode,
|
|
799
|
+
needs: () => viewerNeeds(state.thread),
|
|
800
|
+
isOwn,
|
|
801
|
+
canReply: (m) => {
|
|
802
|
+
if (!canWrite() || !settled(m) || m.state !== 'visible' || m.body === null) return false
|
|
803
|
+
if (m.replyToSeq === null) return true
|
|
804
|
+
const parent = findMessage(state.thread, m.replyToSeq)
|
|
805
|
+
return parent !== null && settled(parent) && parent.state === 'visible'
|
|
806
|
+
},
|
|
807
|
+
canEdit: (m) => canWrite() && isOwn(m) && settled(m) && m.body !== null && (m.state === 'visible' || m.state === 'held'),
|
|
808
|
+
// Not under a brake: kb-api refuses `messages/delete` BRAKED too (assertNotBraked).
|
|
809
|
+
canDelete: (m) => state.thread.viewer.memberId !== null && mode() !== 'braked' && isOwn(m) && settled(m) && m.body !== null && m.state !== 'deleted',
|
|
810
|
+
canReact: canWrite,
|
|
811
|
+
mentionCap,
|
|
812
|
+
canReport: (m) => !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.body !== null && m.author.kind !== 'system',
|
|
813
|
+
canReportAsNotifier: (m) => viewerNeeds(state.thread).signIn && settled(m) && m.body !== null && m.author.kind !== 'system',
|
|
814
|
+
canBlock: (m) =>
|
|
815
|
+
!viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.author.kind === 'member' && m.author.memberId !== null,
|
|
816
|
+
setInView(v) {
|
|
817
|
+
const was = inView
|
|
818
|
+
inView = v
|
|
819
|
+
if (v && !was && poller && now() - lastSync >= interval) poller.now()
|
|
820
|
+
},
|
|
821
|
+
submit: () => send(state.composer, { kind: 'new' }),
|
|
822
|
+
openReply(seq, replyingTo = null) {
|
|
823
|
+
const m = findMessage(state.thread, seq)
|
|
824
|
+
if (!m) return
|
|
825
|
+
// Two levels: a reply to a reply goes under the top-level message.
|
|
826
|
+
const top = m.replyToSeq ?? m.seq
|
|
827
|
+
if (state.inline?.kind === 'reply' && state.inline.seq === top) {
|
|
828
|
+
state.inline.replyingTo = replyingTo
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo }
|
|
832
|
+
},
|
|
833
|
+
openEdit(seq) {
|
|
834
|
+
const m = findMessage(state.thread, seq)
|
|
835
|
+
if (!m || m.body === null) return
|
|
836
|
+
state.inline = { ...emptyDraft(m.body), kind: 'edit', seq, replyingTo: null }
|
|
837
|
+
},
|
|
838
|
+
closeInline() {
|
|
839
|
+
state.inline = null
|
|
840
|
+
},
|
|
841
|
+
async submitInline() {
|
|
842
|
+
const d = state.inline
|
|
843
|
+
if (d) await send(d, { kind: d.kind, seq: d.seq })
|
|
844
|
+
},
|
|
845
|
+
setConfirmed,
|
|
846
|
+
sendConfirmed,
|
|
847
|
+
remove,
|
|
848
|
+
react,
|
|
849
|
+
setBlocked,
|
|
850
|
+
openReport,
|
|
851
|
+
closeReport() {
|
|
852
|
+
state.report = null
|
|
853
|
+
},
|
|
854
|
+
submitReport,
|
|
855
|
+
beginOnboarding,
|
|
856
|
+
cancelOnboarding() {
|
|
857
|
+
onboarding.value = null
|
|
858
|
+
},
|
|
859
|
+
waitLeft,
|
|
860
|
+
dispose() {
|
|
861
|
+
if (disposed) return
|
|
862
|
+
disposed = true
|
|
863
|
+
abort.abort()
|
|
864
|
+
poller?.stop()
|
|
865
|
+
poller = null
|
|
866
|
+
session?.stop()
|
|
867
|
+
session = null
|
|
868
|
+
unsubscribe()
|
|
869
|
+
if (ticker) clearInterval(ticker)
|
|
870
|
+
ticker = null
|
|
871
|
+
if (brakeTimer) clearTimeout(brakeTimer)
|
|
872
|
+
brakeTimer = null
|
|
873
|
+
onboarding.value = null
|
|
874
|
+
},
|
|
875
|
+
}
|
|
876
|
+
}
|