@blogmcp/community 0.0.0-stage → 0.1.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 +146 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-DzjwnHyx.js +4597 -0
- package/dist/core/attrs.d.ts +73 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/discussion.d.ts +144 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +17 -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 +52 -0
- package/dist/core/outcomes.d.ts +28 -0
- package/dist/core/panel.d.ts +80 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/text.d.ts +22 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +26 -0
- package/dist/global.d.ts +8 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +22 -0
- package/dist/mount.d.ts +65 -0
- package/dist/react.d.ts +17 -0
- package/dist/react.js +35 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +33 -0
- package/dist/ui/LinkedText.vue.d.ts +5 -0
- package/dist/ui/MessageComposer.vue.d.ts +24 -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/OnboardingSteps.vue.d.ts +11 -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/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 +89 -0
- package/dist/vue.js +97 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +147 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/client.ts +54 -0
- package/src/core/discussion.ts +691 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +90 -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 +345 -0
- package/src/core/outcomes.ts +74 -0
- package/src/core/panel.ts +411 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/text.ts +142 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +109 -0
- package/src/global.ts +12 -0
- package/src/index.ts +35 -0
- package/src/mount.ts +127 -0
- package/src/react.ts +53 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +294 -0
- package/src/ui/LinkedText.vue +18 -0
- package/src/ui/MessageComposer.vue +130 -0
- package/src/ui/MessageItem.vue +205 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +369 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/OnboardingSteps.vue +163 -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/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +243 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +116 -0
|
@@ -0,0 +1,691 @@
|
|
|
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
|
+
COMMUNITY_MESSAGE_MAX,
|
|
12
|
+
COMMUNITY_REPORT_NOTE_MAX,
|
|
13
|
+
communityRulesOf,
|
|
14
|
+
createDiscussionStore,
|
|
15
|
+
findMessage,
|
|
16
|
+
isOwnMessage,
|
|
17
|
+
loadMoreDiscussion,
|
|
18
|
+
newClientMsgId,
|
|
19
|
+
pollDiscussionHead,
|
|
20
|
+
reactionAfter,
|
|
21
|
+
reactionsFor,
|
|
22
|
+
refreshDiscussion,
|
|
23
|
+
viewerNeeds,
|
|
24
|
+
type CaseCategory,
|
|
25
|
+
type CommunityCallOptions,
|
|
26
|
+
type CommunityClient,
|
|
27
|
+
type CommunityFailure,
|
|
28
|
+
type CommunityRule,
|
|
29
|
+
type DiscussionHead,
|
|
30
|
+
type DiscussionHeadPoller,
|
|
31
|
+
type DiscussionMessage,
|
|
32
|
+
type DiscussionState,
|
|
33
|
+
type DiscussionStore,
|
|
34
|
+
type KnownAuthor,
|
|
35
|
+
type MyState,
|
|
36
|
+
type ReactionTarget,
|
|
37
|
+
type ViewerNeeds,
|
|
38
|
+
} from '@blogmcp/sdk/community'
|
|
39
|
+
import { warnOnce } from './client'
|
|
40
|
+
import type { Lang, TextRef } from './i18n'
|
|
41
|
+
import { createOnboarding, type OnboardingModel } from './onboarding'
|
|
42
|
+
import { failureEffect, failureText, waitUntilOf } from './outcomes'
|
|
43
|
+
import { codePointLength, normalizeBody } from './text'
|
|
44
|
+
|
|
45
|
+
export type DiscussionPhase = 'loading' | 'ready' | 'failed' | 'hidden'
|
|
46
|
+
|
|
47
|
+
/** One composer's text and what happened to it. */
|
|
48
|
+
export interface Draft {
|
|
49
|
+
body: string
|
|
50
|
+
sending: boolean
|
|
51
|
+
failure: CommunityFailure | null
|
|
52
|
+
/** CONFIRMATION_REQUIRED: the codes the author confirms before the same text goes again. */
|
|
53
|
+
confirm: { codes: string[]; checked: string[] } | null
|
|
54
|
+
/** A cooldown: the epoch milliseconds until which this write waits. */
|
|
55
|
+
waitUntil: number | null
|
|
56
|
+
/** A post's id, kept until kb-api took it: a retry of a write whose answer was lost cannot post twice. */
|
|
57
|
+
clientMsgId: string | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type InlineKind = 'reply' | 'edit'
|
|
61
|
+
|
|
62
|
+
/** The one reply or edit open under a message (opening another closes it). */
|
|
63
|
+
export interface InlineDraft extends Draft {
|
|
64
|
+
kind: InlineKind
|
|
65
|
+
/** The message edited, or the TOP-LEVEL message a reply goes under (two levels only). */
|
|
66
|
+
seq: number
|
|
67
|
+
/** A reply to a reply names whom it answers. */
|
|
68
|
+
replyingTo: string | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ReportDraft {
|
|
72
|
+
conversationId: string
|
|
73
|
+
seq: number
|
|
74
|
+
category: CaseCategory | ''
|
|
75
|
+
ruleId: string
|
|
76
|
+
note: string
|
|
77
|
+
rules: CommunityRule[] | null
|
|
78
|
+
rulesFailed: boolean
|
|
79
|
+
sending: boolean
|
|
80
|
+
failure: CommunityFailure | null
|
|
81
|
+
done: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where the composer stands: `write`; `signIn` (no session that can write); `onboarding` (the inline
|
|
86
|
+
* onboarding waits); `ageRefused` (the age band declared in it was below the minimum: reading only, the
|
|
87
|
+
* onboarding is not offered again); `sanction` (a restriction, suspension or ban in effect); `notMoved` (the
|
|
88
|
+
* discussion takes no writes yet — one sentence); `restricted` (the member is read-only or deleting their
|
|
89
|
+
* profile); `pending` (being decided).
|
|
90
|
+
*/
|
|
91
|
+
export type ComposerMode = 'write' | 'signIn' | 'onboarding' | 'ageRefused' | 'sanction' | 'notMoved' | 'restricted' | 'pending'
|
|
92
|
+
|
|
93
|
+
export interface DiscussionUi {
|
|
94
|
+
phase: DiscussionPhase
|
|
95
|
+
/** Why the first load failed (`failed`). */
|
|
96
|
+
failure: CommunityFailure | null
|
|
97
|
+
thread: DiscussionState
|
|
98
|
+
loadingMore: boolean
|
|
99
|
+
refreshing: boolean
|
|
100
|
+
/** A head poll saw messages the page does not show yet. */
|
|
101
|
+
newMessages: boolean
|
|
102
|
+
me: MyState | null
|
|
103
|
+
/** What a write or the member's state taught: no writes yet, or none for this member. */
|
|
104
|
+
block: 'notMoved' | 'restricted' | null
|
|
105
|
+
composer: Draft
|
|
106
|
+
inline: InlineDraft | null
|
|
107
|
+
report: ReportDraft | null
|
|
108
|
+
/** The last outcome to announce (aria-live), and a counter so the same text is announced again. */
|
|
109
|
+
announcement: TextRef | null
|
|
110
|
+
announceId: number
|
|
111
|
+
/** Now, ticking while a cooldown runs. */
|
|
112
|
+
clock: number
|
|
113
|
+
/** Actions on their way, by key (`react:…`, `delete:<seq>`, `block:<memberId>`). */
|
|
114
|
+
busy: Record<string, boolean>
|
|
115
|
+
/**
|
|
116
|
+
* The minimum age a declared band fell below (AGE_BELOW_MINIMUM). kb-api keeps nothing of a refused band
|
|
117
|
+
* (§7.4.2), so the element does: the onboarding is not offered again while it lives.
|
|
118
|
+
*/
|
|
119
|
+
refusedAge: number | null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DiscussionControllerOptions {
|
|
123
|
+
client: CommunityClient
|
|
124
|
+
articleId: string
|
|
125
|
+
lang: Lang
|
|
126
|
+
/** Head poll interval (default 20 s; 0 = no polling). */
|
|
127
|
+
pollMs?: number
|
|
128
|
+
now?: () => number
|
|
129
|
+
/** The viewer's message was posted (`held`: it waits for review). */
|
|
130
|
+
onPosted?(state: 'visible' | 'held'): void
|
|
131
|
+
/** A refusal the element saw before (a discussion started again in another language keeps it). */
|
|
132
|
+
refusedAge?: number | null
|
|
133
|
+
/** The onboarding's age band was refused: the element keeps it for its next discussions. */
|
|
134
|
+
onAgeRefused?(minAge: number): void
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface DiscussionController {
|
|
138
|
+
readonly state: DiscussionUi
|
|
139
|
+
readonly store: DiscussionStore
|
|
140
|
+
readonly onboarding: ShallowRef<OnboardingModel | null>
|
|
141
|
+
/** The first load, then the poll. */
|
|
142
|
+
start(): Promise<void>
|
|
143
|
+
refresh(): Promise<void>
|
|
144
|
+
loadMore(): Promise<void>
|
|
145
|
+
mode(): ComposerMode
|
|
146
|
+
needs(): ViewerNeeds
|
|
147
|
+
isOwn(m: Pick<DiscussionMessage, 'author'>): boolean
|
|
148
|
+
canReply(m: DiscussionMessage): boolean
|
|
149
|
+
canEdit(m: DiscussionMessage): boolean
|
|
150
|
+
canDelete(m: DiscussionMessage): boolean
|
|
151
|
+
canReact(): boolean
|
|
152
|
+
/** A member's report (`messages/report`): any signed-in reader, on someone else's message. */
|
|
153
|
+
canReport(m: DiscussionMessage): boolean
|
|
154
|
+
/** A reader without a session reports through the host's notice page instead (reportLink). */
|
|
155
|
+
canReportAsNotifier(m: DiscussionMessage): boolean
|
|
156
|
+
canBlock(m: DiscussionMessage): boolean
|
|
157
|
+
/** The poll may run (the element is on screen). */
|
|
158
|
+
setInView(inView: boolean): void
|
|
159
|
+
submit(): Promise<void>
|
|
160
|
+
/** Reply under `seq`'s thread (a reply to a reply goes under its top-level message, naming its author). */
|
|
161
|
+
openReply(seq: number, replyingTo?: string | null): void
|
|
162
|
+
openEdit(seq: number): void
|
|
163
|
+
closeInline(): void
|
|
164
|
+
submitInline(): Promise<void>
|
|
165
|
+
/** Tick or untick one code of a draft's confirmation. */
|
|
166
|
+
setConfirmed(draft: Draft, code: string, checked: boolean): void
|
|
167
|
+
/** Send the draft again with every confirmed code (the Russmedia confirmation). */
|
|
168
|
+
sendConfirmed(draft: Draft): Promise<void>
|
|
169
|
+
remove(seq: number): Promise<boolean>
|
|
170
|
+
react(target: ReactionTarget, emoji: string): Promise<void>
|
|
171
|
+
setBlocked(memberId: string, blocked: boolean): Promise<void>
|
|
172
|
+
openReport(m: DiscussionMessage): void
|
|
173
|
+
closeReport(): void
|
|
174
|
+
submitReport(): Promise<void>
|
|
175
|
+
beginOnboarding(): void
|
|
176
|
+
cancelOnboarding(): void
|
|
177
|
+
/** Seconds a draft still waits (0 when it may go). */
|
|
178
|
+
waitLeft(draft: Draft): number
|
|
179
|
+
dispose(): void
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const emptyDraft = (body = ''): Draft => ({ body, sending: false, failure: null, confirm: null, waitUntil: null, clientMsgId: null })
|
|
183
|
+
|
|
184
|
+
const documentVisible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
|
185
|
+
|
|
186
|
+
export function createDiscussionController(o: DiscussionControllerOptions): DiscussionController {
|
|
187
|
+
const { client, lang } = o
|
|
188
|
+
const articleId = o.articleId.toLowerCase()
|
|
189
|
+
const now = o.now ?? Date.now
|
|
190
|
+
const store = createDiscussionStore()
|
|
191
|
+
const abort = new AbortController()
|
|
192
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
193
|
+
|
|
194
|
+
const state = reactive<DiscussionUi>({
|
|
195
|
+
phase: 'loading',
|
|
196
|
+
failure: null,
|
|
197
|
+
thread: store.getState(),
|
|
198
|
+
loadingMore: false,
|
|
199
|
+
refreshing: false,
|
|
200
|
+
newMessages: false,
|
|
201
|
+
me: null,
|
|
202
|
+
block: null,
|
|
203
|
+
composer: emptyDraft(),
|
|
204
|
+
inline: null,
|
|
205
|
+
report: null,
|
|
206
|
+
announcement: null,
|
|
207
|
+
announceId: 0,
|
|
208
|
+
clock: now(),
|
|
209
|
+
busy: {},
|
|
210
|
+
refusedAge: o.refusedAge ?? null,
|
|
211
|
+
}) as DiscussionUi
|
|
212
|
+
const unsubscribe = store.subscribe((s) => {
|
|
213
|
+
state.thread = s
|
|
214
|
+
})
|
|
215
|
+
const onboarding = shallowRef<OnboardingModel | null>(null)
|
|
216
|
+
|
|
217
|
+
let poller: DiscussionHeadPoller | null = null
|
|
218
|
+
let inView = true
|
|
219
|
+
let disposed = false
|
|
220
|
+
let lastSync = 0
|
|
221
|
+
let refreshing: Promise<void> | null = null
|
|
222
|
+
let mePromise: Promise<void> | null = null
|
|
223
|
+
let ticker: ReturnType<typeof setInterval> | null = null
|
|
224
|
+
const interval = o.pollMs ?? 20_000
|
|
225
|
+
|
|
226
|
+
function announce(text: TextRef): void {
|
|
227
|
+
state.announcement = text
|
|
228
|
+
state.announceId += 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hide(): void {
|
|
232
|
+
state.phase = 'hidden'
|
|
233
|
+
poller?.stop()
|
|
234
|
+
poller = null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ───────────────────────── loading ─────────────────────────
|
|
238
|
+
|
|
239
|
+
function ensureMe(): Promise<void> {
|
|
240
|
+
if (state.me) return Promise.resolve()
|
|
241
|
+
mePromise ??= client.me.get(call).then((r) => {
|
|
242
|
+
mePromise = null
|
|
243
|
+
if (r.ok && !disposed) state.me = r.value
|
|
244
|
+
})
|
|
245
|
+
return mePromise
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** canPost is false, nothing is needed and no sanction applies: the member's status says whether it is the cutover. */
|
|
249
|
+
function decideBlock(): void {
|
|
250
|
+
const s = store.getState()
|
|
251
|
+
if (s.viewer.canPost || viewerNeeds(s).any || s.viewer.restrictions.length > 0) return
|
|
252
|
+
void ensureMe().then(() => {
|
|
253
|
+
const me = state.me
|
|
254
|
+
if (disposed || !me || store.getState().viewer.canPost) return
|
|
255
|
+
// An active member who may not post: the discussion takes no writes yet (the cutover).
|
|
256
|
+
state.block = !me.member || me.member.status === 'active' ? 'notMoved' : 'restricted'
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function afterLoad(): void {
|
|
261
|
+
const s = store.getState()
|
|
262
|
+
if (s.viewer.canPost) state.block = null
|
|
263
|
+
if (s.viewer.memberId !== null) void ensureMe()
|
|
264
|
+
decideBlock()
|
|
265
|
+
// Finished elsewhere (another tab): nothing left to onboard.
|
|
266
|
+
if (onboarding.value && !viewerNeeds(s).any && onboarding.value.state.phase !== 'sending') onboarding.value = null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function loadFailed(f: CommunityFailure): void {
|
|
270
|
+
if (f.code === 'surfaceOff') return hide()
|
|
271
|
+
if (f.code === 'notFound' && state.phase !== 'ready') {
|
|
272
|
+
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
|
+
return hide()
|
|
274
|
+
}
|
|
275
|
+
if (state.phase === 'loading' || state.phase === 'failed') {
|
|
276
|
+
state.phase = 'failed'
|
|
277
|
+
state.failure = f
|
|
278
|
+
}
|
|
279
|
+
// A loaded discussion keeps what it shows; the poll tries again.
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function refresh(): Promise<void> {
|
|
283
|
+
if (disposed) return Promise.resolve()
|
|
284
|
+
refreshing ??= (async () => {
|
|
285
|
+
state.refreshing = true
|
|
286
|
+
const r = await refreshDiscussion(store, client, { articleId, languageCode: lang }, call)
|
|
287
|
+
state.refreshing = false
|
|
288
|
+
if (disposed) return
|
|
289
|
+
lastSync = now()
|
|
290
|
+
if (r.ok) {
|
|
291
|
+
state.phase = 'ready'
|
|
292
|
+
state.failure = null
|
|
293
|
+
state.newMessages = false
|
|
294
|
+
afterLoad()
|
|
295
|
+
} else {
|
|
296
|
+
loadFailed(r)
|
|
297
|
+
}
|
|
298
|
+
})().finally(() => {
|
|
299
|
+
refreshing = null
|
|
300
|
+
})
|
|
301
|
+
return refreshing
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function loadMore(): Promise<void> {
|
|
305
|
+
if (state.loadingMore || state.thread.nextCursor === null) return
|
|
306
|
+
state.loadingMore = true
|
|
307
|
+
const r = await loadMoreDiscussion(store, client, { articleId, languageCode: lang }, call)
|
|
308
|
+
state.loadingMore = false
|
|
309
|
+
if (disposed || r.ok) return
|
|
310
|
+
if (r.code === 'surfaceOff') return hide()
|
|
311
|
+
announce(failureText(r, lang))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function onHeads(heads: DiscussionHead[]): void {
|
|
315
|
+
for (const head of heads) {
|
|
316
|
+
if (typeof head?.articleId !== 'string' || head.articleId.toLowerCase() !== articleId) continue
|
|
317
|
+
if (state.phase === 'failed') {
|
|
318
|
+
void refresh()
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
const before = store.getState()
|
|
322
|
+
const newer = typeof head.lastSeq === 'number' && head.lastSeq > before.lastSeq
|
|
323
|
+
if (!store.applyHead(head)) continue
|
|
324
|
+
// New messages wait for the reader (the page does not move under them); an edit, a reaction or the
|
|
325
|
+
// reader's own post are folded in at once.
|
|
326
|
+
if (newer && before.loaded) state.newMessages = true
|
|
327
|
+
else void refresh()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function startPolling(): void {
|
|
332
|
+
if (poller || interval === 0 || disposed || state.phase === 'hidden') return
|
|
333
|
+
poller = pollDiscussionHead(client, [articleId], {
|
|
334
|
+
intervalMs: interval,
|
|
335
|
+
visible: () => documentVisible() && inView,
|
|
336
|
+
onChange: onHeads,
|
|
337
|
+
onFailure: (f) => {
|
|
338
|
+
if (f.code === 'surfaceOff') hide()
|
|
339
|
+
},
|
|
340
|
+
signal: abort.signal,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ───────────────────────── who may do what ─────────────────────────
|
|
345
|
+
|
|
346
|
+
function mode(): ComposerMode {
|
|
347
|
+
const s = state.thread
|
|
348
|
+
const n = viewerNeeds(s)
|
|
349
|
+
if (n.signIn) return 'signIn'
|
|
350
|
+
if (state.block === 'notMoved') return 'notMoved'
|
|
351
|
+
if (!s.viewer.canPost && s.viewer.restrictions.length > 0) return 'sanction'
|
|
352
|
+
// A refused age band: the refusal stays where the composer was once the reader closed it.
|
|
353
|
+
if (n.any) return state.refusedAge !== null && !onboarding.value ? 'ageRefused' : 'onboarding'
|
|
354
|
+
if (state.block === 'restricted') return 'restricted'
|
|
355
|
+
return s.viewer.canPost ? 'write' : 'pending'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const isOwn = (m: Pick<DiscussionMessage, 'author'>): boolean => isOwnMessage(state.thread, m)
|
|
359
|
+
const settled = (m: DiscussionMessage): boolean => !m.pending && m.seq > 0
|
|
360
|
+
const canWrite = (): boolean => mode() === 'write'
|
|
361
|
+
|
|
362
|
+
// ───────────────────────── writes ─────────────────────────
|
|
363
|
+
|
|
364
|
+
/** The viewer as their own messages show them (for the optimistic row), or null when not known yet. */
|
|
365
|
+
function ownAuthor(): KnownAuthor | null {
|
|
366
|
+
const me = state.me
|
|
367
|
+
if (me?.member && me.profile) {
|
|
368
|
+
return { kind: 'member', memberId: me.member.memberId, handle: me.profile.handle, displayName: me.profile.displayName, avatarPreset: me.profile.avatarPreset }
|
|
369
|
+
}
|
|
370
|
+
const viewer = state.thread.viewer.memberId
|
|
371
|
+
if (viewer === null) return null
|
|
372
|
+
for (const m of state.thread.messages) {
|
|
373
|
+
for (const x of [m, ...m.replies]) if (x.author.kind === 'member' && x.author.memberId === viewer) return x.author
|
|
374
|
+
}
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function startClock(): void {
|
|
379
|
+
state.clock = now()
|
|
380
|
+
if (ticker) return
|
|
381
|
+
ticker = setInterval(() => {
|
|
382
|
+
state.clock = now()
|
|
383
|
+
let waiting = false
|
|
384
|
+
for (const d of [state.composer, state.inline]) {
|
|
385
|
+
if (!d || d.waitUntil === null) continue
|
|
386
|
+
if (d.waitUntil <= state.clock) {
|
|
387
|
+
d.waitUntil = null
|
|
388
|
+
if (d.failure && (d.failure.code === 'fast' || d.failure.code === 'quota')) d.failure = null
|
|
389
|
+
} else {
|
|
390
|
+
waiting = true
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (!waiting && ticker) {
|
|
394
|
+
clearInterval(ticker)
|
|
395
|
+
ticker = null
|
|
396
|
+
}
|
|
397
|
+
}, 1000)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const waitLeft = (d: Draft): number => (d.waitUntil === null ? 0 : Math.max(0, Math.ceil((d.waitUntil - Math.max(state.clock, now())) / 1000)))
|
|
401
|
+
|
|
402
|
+
/** What a refused write does besides showing its sentence. */
|
|
403
|
+
function refused(d: Draft | null, f: CommunityFailure): void {
|
|
404
|
+
if (d) d.failure = f
|
|
405
|
+
switch (failureEffect(f.code)) {
|
|
406
|
+
case 'confirm':
|
|
407
|
+
if (d && f.codes && f.codes.length > 0) d.confirm = { codes: [...f.codes], checked: [] }
|
|
408
|
+
return
|
|
409
|
+
case 'wait':
|
|
410
|
+
if (d) {
|
|
411
|
+
d.waitUntil = waitUntilOf(f, now())
|
|
412
|
+
startClock()
|
|
413
|
+
}
|
|
414
|
+
return
|
|
415
|
+
case 'onboard':
|
|
416
|
+
beginOnboarding()
|
|
417
|
+
void refresh()
|
|
418
|
+
return
|
|
419
|
+
case 'reload':
|
|
420
|
+
void refresh()
|
|
421
|
+
return
|
|
422
|
+
case 'notMoved':
|
|
423
|
+
state.block = 'notMoved'
|
|
424
|
+
return
|
|
425
|
+
case 'hide':
|
|
426
|
+
return hide()
|
|
427
|
+
default:
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
type Target = { kind: 'new' } | { kind: InlineKind; seq: number }
|
|
433
|
+
|
|
434
|
+
async function send(d: Draft, target: Target, confirmations?: string[]): Promise<void> {
|
|
435
|
+
if (d.sending || waitLeft(d) > 0 || disposed) return
|
|
436
|
+
const body = normalizeBody(d.body)
|
|
437
|
+
if (body === '' || codePointLength(body) > COMMUNITY_MESSAGE_MAX) return
|
|
438
|
+
const n = viewerNeeds(state.thread)
|
|
439
|
+
if (n.signIn) return
|
|
440
|
+
if (n.any) return beginOnboarding()
|
|
441
|
+
const parent = target.kind === 'new' ? null : findMessage(state.thread, target.seq)
|
|
442
|
+
if (target.kind !== 'new' && (!parent || !settled(parent))) return
|
|
443
|
+
|
|
444
|
+
d.sending = true
|
|
445
|
+
d.failure = null
|
|
446
|
+
const author = target.kind === 'edit' ? null : ownAuthor()
|
|
447
|
+
// A post keeps its id until kb-api took it; an edit's id only names its optimistic change.
|
|
448
|
+
const changeId = target.kind === 'edit' ? newClientMsgId() : (d.clientMsgId ??= newClientMsgId())
|
|
449
|
+
if (target.kind === 'edit') store.applyMessage({ type: 'edit', clientMsgId: changeId, seq: target.seq, body })
|
|
450
|
+
else if (author) store.applyMessage({ type: 'post', clientMsgId: changeId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, author, languageCode: lang })
|
|
451
|
+
|
|
452
|
+
const r =
|
|
453
|
+
target.kind === 'edit'
|
|
454
|
+
? await client.messages.edit({ conversationId: parent!.conversationId, seq: target.seq, body, ...(confirmations ? { confirmations } : {}) }, call)
|
|
455
|
+
: await client.messages.post(
|
|
456
|
+
{ articleId, body, replyToSeq: target.kind === 'reply' ? target.seq : null, clientMsgId: changeId, languageCode: lang, ...(confirmations ? { confirmations } : {}) },
|
|
457
|
+
call,
|
|
458
|
+
)
|
|
459
|
+
if (disposed) return
|
|
460
|
+
d.sending = false
|
|
461
|
+
if (r.ok) {
|
|
462
|
+
store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message, warnings: r.value.warnings })
|
|
463
|
+
const held = r.value.message.state === 'held'
|
|
464
|
+
d.body = ''
|
|
465
|
+
d.clientMsgId = null
|
|
466
|
+
d.confirm = null
|
|
467
|
+
d.failure = null
|
|
468
|
+
if (target.kind !== 'new' && state.inline === d) state.inline = null
|
|
469
|
+
announce({ key: held ? 'composer.held' : 'composer.posted' })
|
|
470
|
+
if (target.kind !== 'edit') o.onPosted?.(held ? 'held' : 'visible')
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
store.applyMessage({ type: 'rollback', clientMsgId: changeId })
|
|
474
|
+
refused(d, r)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function setConfirmed(d: Draft, code: string, checked: boolean): void {
|
|
478
|
+
if (!d.confirm || !d.confirm.codes.includes(code)) return
|
|
479
|
+
const rest = d.confirm.checked.filter((c) => c !== code)
|
|
480
|
+
d.confirm.checked = checked ? [...rest, code] : rest
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function sendConfirmed(d: Draft): Promise<void> {
|
|
484
|
+
const confirm = d.confirm
|
|
485
|
+
if (!confirm || !confirm.codes.every((c) => confirm.checked.includes(c))) return
|
|
486
|
+
const target: Target = d === state.composer ? { kind: 'new' } : state.inline === d ? { kind: state.inline.kind, seq: state.inline.seq } : { kind: 'new' }
|
|
487
|
+
await send(d, target, [...confirm.codes])
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function remove(seq: number): Promise<boolean> {
|
|
491
|
+
const m = findMessage(state.thread, seq)
|
|
492
|
+
const key = `delete:${seq}`
|
|
493
|
+
if (!m || !settled(m) || state.busy[key]) return false
|
|
494
|
+
state.busy[key] = true
|
|
495
|
+
const changeId = newClientMsgId()
|
|
496
|
+
store.applyMessage({ type: 'delete', clientMsgId: changeId, seq })
|
|
497
|
+
const r = await client.messages.delete({ conversationId: m.conversationId, seq }, call)
|
|
498
|
+
delete state.busy[key]
|
|
499
|
+
if (disposed) return false
|
|
500
|
+
if (r.ok) {
|
|
501
|
+
store.applyMessage({ type: 'confirm', clientMsgId: changeId, message: r.value.message })
|
|
502
|
+
return true
|
|
503
|
+
}
|
|
504
|
+
store.applyMessage({ type: 'rollback', clientMsgId: changeId })
|
|
505
|
+
announce(failureText(r, lang))
|
|
506
|
+
refused(null, r)
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const reactionKey = (t: ReactionTarget): string => ('articleId' in t ? 'react:article' : `react:${t.seq}`)
|
|
511
|
+
|
|
512
|
+
async function react(target: ReactionTarget, emoji: string): Promise<void> {
|
|
513
|
+
const key = reactionKey(target)
|
|
514
|
+
if (!canWrite() || state.busy[key] || !state.thread.reactionSet.includes(emoji)) return
|
|
515
|
+
const before = reactionsFor(state.thread, target)
|
|
516
|
+
const next = before.mine === emoji ? null : emoji
|
|
517
|
+
state.busy[key] = true
|
|
518
|
+
store.applyReaction(target, reactionAfter(before, next))
|
|
519
|
+
const r = await client.reactions.set({ target, emoji: next }, call)
|
|
520
|
+
delete state.busy[key]
|
|
521
|
+
if (disposed) return
|
|
522
|
+
if (r.ok) {
|
|
523
|
+
store.applyReaction(target, r.value.reactions)
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
store.applyReaction(target, before)
|
|
527
|
+
announce(failureText(r, lang))
|
|
528
|
+
refused(null, r)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function setBlocked(memberId: string, blocked: boolean): Promise<void> {
|
|
532
|
+
const key = `block:${memberId}`
|
|
533
|
+
if (state.busy[key]) return
|
|
534
|
+
state.busy[key] = true
|
|
535
|
+
const r = await client.blocks.set({ memberId, blocked }, call)
|
|
536
|
+
delete state.busy[key]
|
|
537
|
+
if (disposed) return
|
|
538
|
+
if (r.ok) {
|
|
539
|
+
store.collapse(memberId, r.value.blocked)
|
|
540
|
+
announce({ key: r.value.blocked ? 'discussion.blocked' : 'discussion.unblocked' })
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
announce(failureText(r, lang))
|
|
544
|
+
refused(null, r)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ───────────────────────── reports ─────────────────────────
|
|
548
|
+
|
|
549
|
+
function openReport(m: DiscussionMessage): void {
|
|
550
|
+
state.report = { conversationId: m.conversationId, seq: m.seq, category: '', ruleId: '', note: '', rules: null, rulesFailed: false, sending: false, failure: null, done: false }
|
|
551
|
+
const report = state.report
|
|
552
|
+
void client.legal.current({ kind: 'rules', locale: lang }, call).then((r) => {
|
|
553
|
+
if (state.report !== report) return
|
|
554
|
+
if (r.ok) report.rules = communityRulesOf(r.value.bodyJson)
|
|
555
|
+
else report.rulesFailed = true
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function submitReport(): Promise<void> {
|
|
560
|
+
const report = state.report
|
|
561
|
+
if (!report || report.sending || report.done || report.category === '') return
|
|
562
|
+
if (report.category === 'rules' && report.ruleId === '') return
|
|
563
|
+
const note = normalizeBody(report.note)
|
|
564
|
+
if (codePointLength(note) > COMMUNITY_REPORT_NOTE_MAX) return
|
|
565
|
+
report.sending = true
|
|
566
|
+
report.failure = null
|
|
567
|
+
const r = await client.messages.report(
|
|
568
|
+
{ conversationId: report.conversationId, seq: report.seq, category: report.category, ruleId: report.category === 'rules' ? report.ruleId : null, note: note === '' ? null : note },
|
|
569
|
+
call,
|
|
570
|
+
)
|
|
571
|
+
report.sending = false
|
|
572
|
+
if (disposed) return
|
|
573
|
+
if (r.ok) {
|
|
574
|
+
report.done = true
|
|
575
|
+
announce({ key: 'report.done' })
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
report.failure = r
|
|
579
|
+
if (r.code === 'surfaceOff') hide()
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ───────────────────────── onboarding ─────────────────────────
|
|
583
|
+
|
|
584
|
+
function beginOnboarding(): void {
|
|
585
|
+
if (onboarding.value || disposed || state.refusedAge !== null) return
|
|
586
|
+
const model = createOnboarding({
|
|
587
|
+
client,
|
|
588
|
+
lang,
|
|
589
|
+
signal: abort.signal,
|
|
590
|
+
onRefused: (minAge) => {
|
|
591
|
+
state.refusedAge = minAge
|
|
592
|
+
o.onAgeRefused?.(minAge)
|
|
593
|
+
},
|
|
594
|
+
onDone: (me) => {
|
|
595
|
+
state.me = me
|
|
596
|
+
onboarding.value = null
|
|
597
|
+
announce({ key: 'onboarding.done' })
|
|
598
|
+
void refresh()
|
|
599
|
+
},
|
|
600
|
+
onSignIn: () => {
|
|
601
|
+
onboarding.value = null
|
|
602
|
+
void refresh()
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
onboarding.value = model
|
|
606
|
+
void model.load()
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
state,
|
|
611
|
+
store,
|
|
612
|
+
onboarding,
|
|
613
|
+
async start() {
|
|
614
|
+
await refresh()
|
|
615
|
+
startPolling()
|
|
616
|
+
},
|
|
617
|
+
refresh,
|
|
618
|
+
loadMore,
|
|
619
|
+
mode,
|
|
620
|
+
needs: () => viewerNeeds(state.thread),
|
|
621
|
+
isOwn,
|
|
622
|
+
canReply: (m) => {
|
|
623
|
+
if (!canWrite() || !settled(m) || m.state !== 'visible' || m.body === null) return false
|
|
624
|
+
if (m.replyToSeq === null) return true
|
|
625
|
+
const parent = findMessage(state.thread, m.replyToSeq)
|
|
626
|
+
return parent !== null && settled(parent) && parent.state === 'visible'
|
|
627
|
+
},
|
|
628
|
+
canEdit: (m) => canWrite() && isOwn(m) && settled(m) && m.body !== null && (m.state === 'visible' || m.state === 'held'),
|
|
629
|
+
canDelete: (m) => state.thread.viewer.memberId !== null && isOwn(m) && settled(m) && m.body !== null && m.state !== 'deleted',
|
|
630
|
+
canReact: canWrite,
|
|
631
|
+
canReport: (m) => !viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.body !== null && m.author.kind !== 'system',
|
|
632
|
+
canReportAsNotifier: (m) => viewerNeeds(state.thread).signIn && settled(m) && m.body !== null && m.author.kind !== 'system',
|
|
633
|
+
canBlock: (m) =>
|
|
634
|
+
!viewerNeeds(state.thread).signIn && !isOwn(m) && settled(m) && m.author.kind === 'member' && m.author.memberId !== null,
|
|
635
|
+
setInView(v) {
|
|
636
|
+
const was = inView
|
|
637
|
+
inView = v
|
|
638
|
+
if (v && !was && poller && now() - lastSync >= interval) poller.now()
|
|
639
|
+
},
|
|
640
|
+
submit: () => send(state.composer, { kind: 'new' }),
|
|
641
|
+
openReply(seq, replyingTo = null) {
|
|
642
|
+
const m = findMessage(state.thread, seq)
|
|
643
|
+
if (!m) return
|
|
644
|
+
// Two levels: a reply to a reply goes under the top-level message.
|
|
645
|
+
const top = m.replyToSeq ?? m.seq
|
|
646
|
+
if (state.inline?.kind === 'reply' && state.inline.seq === top) {
|
|
647
|
+
state.inline.replyingTo = replyingTo
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
state.inline = { ...emptyDraft(), kind: 'reply', seq: top, replyingTo }
|
|
651
|
+
},
|
|
652
|
+
openEdit(seq) {
|
|
653
|
+
const m = findMessage(state.thread, seq)
|
|
654
|
+
if (!m || m.body === null) return
|
|
655
|
+
state.inline = { ...emptyDraft(m.body), kind: 'edit', seq, replyingTo: null }
|
|
656
|
+
},
|
|
657
|
+
closeInline() {
|
|
658
|
+
state.inline = null
|
|
659
|
+
},
|
|
660
|
+
async submitInline() {
|
|
661
|
+
const d = state.inline
|
|
662
|
+
if (d) await send(d, { kind: d.kind, seq: d.seq })
|
|
663
|
+
},
|
|
664
|
+
setConfirmed,
|
|
665
|
+
sendConfirmed,
|
|
666
|
+
remove,
|
|
667
|
+
react,
|
|
668
|
+
setBlocked,
|
|
669
|
+
openReport,
|
|
670
|
+
closeReport() {
|
|
671
|
+
state.report = null
|
|
672
|
+
},
|
|
673
|
+
submitReport,
|
|
674
|
+
beginOnboarding,
|
|
675
|
+
cancelOnboarding() {
|
|
676
|
+
onboarding.value = null
|
|
677
|
+
},
|
|
678
|
+
waitLeft,
|
|
679
|
+
dispose() {
|
|
680
|
+
if (disposed) return
|
|
681
|
+
disposed = true
|
|
682
|
+
abort.abort()
|
|
683
|
+
poller?.stop()
|
|
684
|
+
poller = null
|
|
685
|
+
unsubscribe()
|
|
686
|
+
if (ticker) clearInterval(ticker)
|
|
687
|
+
ticker = null
|
|
688
|
+
onboarding.value = null
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
}
|