@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,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-my-community>`'s brain (m1-contracts.md §7.4, §8.3, §10.4; community.md §5): the reader's
|
|
3
|
+
* „Moje komunita" — the statements of reasons about their content (the art. 17(3) fields, the appeal within
|
|
4
|
+
* its window, an open appeal's state), their reports, their notifications, the art. 9 consent's withdrawal,
|
|
5
|
+
* their blocks, the export (a JSON download) and the profile's deletion with its grace and cancel.
|
|
6
|
+
*
|
|
7
|
+
* With a decision id it shows that one statement (the host's statement page). With a notifier's receipt it
|
|
8
|
+
* shows that notice's status and appeal — `notices/status`, `notices/appeal`: no session needed for that.
|
|
9
|
+
*/
|
|
10
|
+
import { reactive } from 'vue'
|
|
11
|
+
import {
|
|
12
|
+
COMMUNITY_APPEAL_MAX,
|
|
13
|
+
isKnownNotificationCode,
|
|
14
|
+
type AppealItem,
|
|
15
|
+
type BlockEntry,
|
|
16
|
+
type CommunityCallOptions,
|
|
17
|
+
type CommunityClient,
|
|
18
|
+
type CommunityFailure,
|
|
19
|
+
type DecisionSummary,
|
|
20
|
+
type MemberExport,
|
|
21
|
+
type MyState,
|
|
22
|
+
type NoticeStatus,
|
|
23
|
+
type NotificationItem,
|
|
24
|
+
type OwnProfile,
|
|
25
|
+
type ReportItem,
|
|
26
|
+
type Statement,
|
|
27
|
+
} from '@blogmcp/sdk/community'
|
|
28
|
+
import type { TextRef } from './i18n'
|
|
29
|
+
import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName } from './names'
|
|
30
|
+
import { codePointLength, normalizeBody } from './text'
|
|
31
|
+
|
|
32
|
+
export type LoadPhase = 'idle' | 'loading' | 'ready' | 'failed'
|
|
33
|
+
|
|
34
|
+
export interface AppealDraft {
|
|
35
|
+
body: string
|
|
36
|
+
sending: boolean
|
|
37
|
+
failure: CommunityFailure | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProfileDraft {
|
|
41
|
+
displayName: string
|
|
42
|
+
handle: string
|
|
43
|
+
avatarPreset: string | null
|
|
44
|
+
sending: boolean
|
|
45
|
+
failure: CommunityFailure | null
|
|
46
|
+
fields: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PanelUi {
|
|
50
|
+
/** The member's part: `signIn` when the session cannot act as a member (or there is none). */
|
|
51
|
+
phase: LoadPhase | 'signIn'
|
|
52
|
+
failure: CommunityFailure | null
|
|
53
|
+
me: MyState | null
|
|
54
|
+
notifications: NotificationItem[]
|
|
55
|
+
decisions: DecisionSummary[]
|
|
56
|
+
appeals: AppealItem[]
|
|
57
|
+
reports: ReportItem[]
|
|
58
|
+
blocks: BlockEntry[]
|
|
59
|
+
/** The statement the page opens (`decision-id`). */
|
|
60
|
+
statement: { phase: LoadPhase | 'signIn'; value: Statement | null; failure: CommunityFailure | null; appeal: AppealDraft }
|
|
61
|
+
/** The notifier's part (`#t=` in the address). `missing`: a `#t=` that holds no receipt. */
|
|
62
|
+
notice: { phase: LoadPhase | 'missing'; value: NoticeStatus | null; failure: CommunityFailure | null; appeal: AppealDraft }
|
|
63
|
+
profile: ProfileDraft | null
|
|
64
|
+
/** The profile's deletion: its date once requested here (kb-api's MyState does not carry it). */
|
|
65
|
+
purgeAfter: string | null
|
|
66
|
+
exporting: boolean
|
|
67
|
+
busy: Record<string, boolean>
|
|
68
|
+
announcement: TextRef | null
|
|
69
|
+
announceId: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PanelController {
|
|
73
|
+
readonly state: PanelUi
|
|
74
|
+
/** The member's sections (me/get and the lists). */
|
|
75
|
+
loadMember(): Promise<void>
|
|
76
|
+
loadStatement(decisionId: string): Promise<void>
|
|
77
|
+
loadNotice(receipt: string | null): Promise<void>
|
|
78
|
+
unreadCount(): number
|
|
79
|
+
markAllRead(): Promise<void>
|
|
80
|
+
fileAppeal(): Promise<void>
|
|
81
|
+
withdrawAppeal(): Promise<void>
|
|
82
|
+
fileNoticeAppeal(): Promise<void>
|
|
83
|
+
withdrawNoticeAppeal(): Promise<void>
|
|
84
|
+
withdrawConsent(): Promise<boolean>
|
|
85
|
+
unblock(memberId: string): Promise<void>
|
|
86
|
+
editProfile(): void
|
|
87
|
+
cancelProfile(): void
|
|
88
|
+
saveProfile(): Promise<void>
|
|
89
|
+
/** `me/export` as a JSON file the browser saves (`save` does the download; tests pass their own). */
|
|
90
|
+
exportData(save: (data: MemberExport) => void): Promise<void>
|
|
91
|
+
erase(): Promise<boolean>
|
|
92
|
+
cancelErase(): Promise<void>
|
|
93
|
+
/** The member asked for the deletion (here or before). */
|
|
94
|
+
erasing(): boolean
|
|
95
|
+
appealLength(draft: AppealDraft): number
|
|
96
|
+
canAppealWith(draft: AppealDraft): boolean
|
|
97
|
+
dispose(): void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface PanelOptions {
|
|
101
|
+
client: CommunityClient
|
|
102
|
+
lang: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const appealDraft = (): AppealDraft => ({ body: '', sending: false, failure: null })
|
|
106
|
+
|
|
107
|
+
export function createPanel(o: PanelOptions): PanelController {
|
|
108
|
+
const { client } = o
|
|
109
|
+
const abort = new AbortController()
|
|
110
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
111
|
+
const state = reactive<PanelUi>({
|
|
112
|
+
phase: 'idle',
|
|
113
|
+
failure: null,
|
|
114
|
+
me: null,
|
|
115
|
+
notifications: [],
|
|
116
|
+
decisions: [],
|
|
117
|
+
appeals: [],
|
|
118
|
+
reports: [],
|
|
119
|
+
blocks: [],
|
|
120
|
+
statement: { phase: 'idle', value: null, failure: null, appeal: appealDraft() },
|
|
121
|
+
notice: { phase: 'idle', value: null, failure: null, appeal: appealDraft() },
|
|
122
|
+
profile: null,
|
|
123
|
+
purgeAfter: null,
|
|
124
|
+
exporting: false,
|
|
125
|
+
busy: {},
|
|
126
|
+
announcement: null,
|
|
127
|
+
announceId: 0,
|
|
128
|
+
}) as PanelUi
|
|
129
|
+
let disposed = false
|
|
130
|
+
|
|
131
|
+
const announce = (text: TextRef): void => {
|
|
132
|
+
state.announcement = text
|
|
133
|
+
state.announceId += 1
|
|
134
|
+
}
|
|
135
|
+
const failureRef = (f: CommunityFailure): TextRef => ({ key: `error.${f.code}` as TextRef['key'] })
|
|
136
|
+
|
|
137
|
+
async function loadMember(): Promise<void> {
|
|
138
|
+
state.phase = 'loading'
|
|
139
|
+
state.failure = null
|
|
140
|
+
const me = await client.me.get(call)
|
|
141
|
+
if (disposed) return
|
|
142
|
+
if (!me.ok) {
|
|
143
|
+
if (me.code === 'signIn') {
|
|
144
|
+
state.phase = 'signIn'
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
state.phase = 'failed'
|
|
148
|
+
state.failure = me
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
state.me = me.value
|
|
152
|
+
if (me.value.needs.includes('sign_in')) {
|
|
153
|
+
state.phase = 'signIn'
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (!me.value.member) {
|
|
157
|
+
// Signed in, never joined: nothing of theirs to list yet.
|
|
158
|
+
state.phase = 'ready'
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const [notifications, decisions, appeals, reports, blocks] = await Promise.all([
|
|
162
|
+
client.me.notifications.list({}, call),
|
|
163
|
+
client.me.decisions.list({}, call),
|
|
164
|
+
client.me.appeals.list(call),
|
|
165
|
+
client.me.reports.list(call),
|
|
166
|
+
client.blocks.list(call),
|
|
167
|
+
])
|
|
168
|
+
if (disposed) return
|
|
169
|
+
if (notifications.ok) state.notifications = notifications.value.items.filter((n) => isKnownNotificationCode(n.code))
|
|
170
|
+
if (decisions.ok) state.decisions = [...decisions.value.items]
|
|
171
|
+
if (appeals.ok) state.appeals = [...appeals.value.items]
|
|
172
|
+
if (reports.ok) state.reports = [...reports.value.items]
|
|
173
|
+
if (blocks.ok) state.blocks = [...blocks.value.blocks]
|
|
174
|
+
const first = [notifications, decisions, appeals, reports, blocks].find((r) => !r.ok)
|
|
175
|
+
if (first && !first.ok) announce(failureRef(first))
|
|
176
|
+
state.phase = 'ready'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The statement the page shows now, and its load: a host that reuses the element for another decision-id
|
|
180
|
+
// (client-side navigation) must never see the late answer for the one before (nor appeal that one there).
|
|
181
|
+
let statementId: string | null = null
|
|
182
|
+
let statementLoad = 0
|
|
183
|
+
|
|
184
|
+
async function loadStatement(decisionId: string): Promise<void> {
|
|
185
|
+
const ticket = ++statementLoad
|
|
186
|
+
if (decisionId !== statementId) {
|
|
187
|
+
// Another decision: nothing of the one before stays on its page — its text, the appeal typed under it.
|
|
188
|
+
statementId = decisionId
|
|
189
|
+
state.statement.value = null
|
|
190
|
+
state.statement.appeal = appealDraft()
|
|
191
|
+
}
|
|
192
|
+
state.statement.phase = 'loading'
|
|
193
|
+
state.statement.failure = null
|
|
194
|
+
const r = await client.me.decisions.get({ decisionId }, call)
|
|
195
|
+
if (disposed || ticket !== statementLoad) return
|
|
196
|
+
if (r.ok) {
|
|
197
|
+
state.statement.value = r.value
|
|
198
|
+
state.statement.phase = 'ready'
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
state.statement.failure = r
|
|
202
|
+
state.statement.phase = r.code === 'signIn' ? 'signIn' : 'failed'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function loadNotice(receipt: string | null): Promise<void> {
|
|
206
|
+
if (receipt === null) {
|
|
207
|
+
state.notice.phase = 'missing'
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
state.notice.phase = 'loading'
|
|
211
|
+
state.notice.failure = null
|
|
212
|
+
const r = await client.notices.status({ receipt }, call)
|
|
213
|
+
if (disposed) return
|
|
214
|
+
if (r.ok) {
|
|
215
|
+
state.notice.value = r.value
|
|
216
|
+
state.notice.phase = 'ready'
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
state.notice.failure = r
|
|
220
|
+
state.notice.phase = 'failed'
|
|
221
|
+
}
|
|
222
|
+
// The receipt is the notifier's capability: kept in this closure only, never in the state a view renders.
|
|
223
|
+
let noticeReceipt: string | null = null
|
|
224
|
+
|
|
225
|
+
const appealLength = (d: AppealDraft): number => codePointLength(normalizeBody(d.body))
|
|
226
|
+
const canAppealWith = (d: AppealDraft): boolean => !d.sending && appealLength(d) > 0 && appealLength(d) <= COMMUNITY_APPEAL_MAX
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
state,
|
|
230
|
+
loadMember,
|
|
231
|
+
loadStatement,
|
|
232
|
+
async loadNotice(receipt) {
|
|
233
|
+
noticeReceipt = receipt
|
|
234
|
+
await loadNotice(receipt)
|
|
235
|
+
},
|
|
236
|
+
unreadCount: () => state.notifications.filter((n) => n.readAt === null).length,
|
|
237
|
+
async markAllRead() {
|
|
238
|
+
const ids = state.notifications.filter((n) => n.readAt === null).map((n) => n.id)
|
|
239
|
+
if (ids.length === 0 || state.busy.markRead) return
|
|
240
|
+
state.busy.markRead = true
|
|
241
|
+
const r = await client.me.notifications.markRead({ ids }, call)
|
|
242
|
+
delete state.busy.markRead
|
|
243
|
+
if (disposed) return
|
|
244
|
+
if (!r.ok) return announce(failureRef(r))
|
|
245
|
+
const at = new Date().toISOString()
|
|
246
|
+
state.notifications = state.notifications.map((n) => (ids.includes(n.id) ? { ...n, readAt: at } : n))
|
|
247
|
+
},
|
|
248
|
+
async fileAppeal() {
|
|
249
|
+
const s = state.statement
|
|
250
|
+
const d = s.appeal
|
|
251
|
+
if (!s.value || !s.value.canAppeal || !canAppealWith(d)) return
|
|
252
|
+
const decisionId = s.value.decisionId
|
|
253
|
+
d.sending = true
|
|
254
|
+
d.failure = null
|
|
255
|
+
const r = await client.me.appeals.file({ decisionId, body: normalizeBody(d.body) }, call)
|
|
256
|
+
d.sending = false
|
|
257
|
+
// The page moved on to another statement meanwhile: the answer is that one's no more.
|
|
258
|
+
if (disposed || decisionId !== statementId) return
|
|
259
|
+
if (!r.ok) {
|
|
260
|
+
d.failure = r
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
d.body = ''
|
|
264
|
+
announce({ key: 'statement.appealSent' })
|
|
265
|
+
await loadStatement(decisionId)
|
|
266
|
+
},
|
|
267
|
+
async withdrawAppeal() {
|
|
268
|
+
const s = state.statement
|
|
269
|
+
const open = s.value?.appeal
|
|
270
|
+
if (!s.value || !open || open.status !== 'open' || state.busy.withdraw) return
|
|
271
|
+
const decisionId = s.value.decisionId
|
|
272
|
+
state.busy.withdraw = true
|
|
273
|
+
const r = await client.me.appeals.withdraw({ appealId: open.appealId }, call)
|
|
274
|
+
delete state.busy.withdraw
|
|
275
|
+
if (disposed || decisionId !== statementId) return
|
|
276
|
+
if (!r.ok) return announce(failureRef(r))
|
|
277
|
+
announce({ key: 'statement.appealWithdrawn' })
|
|
278
|
+
await loadStatement(decisionId)
|
|
279
|
+
},
|
|
280
|
+
async fileNoticeAppeal() {
|
|
281
|
+
const n = state.notice
|
|
282
|
+
const d = n.appeal
|
|
283
|
+
if (!n.value || !n.value.appeal.canAppeal || noticeReceipt === null || !canAppealWith(d)) return
|
|
284
|
+
d.sending = true
|
|
285
|
+
d.failure = null
|
|
286
|
+
const r = await client.notices.appeal({ receipt: noticeReceipt, body: normalizeBody(d.body) }, call)
|
|
287
|
+
d.sending = false
|
|
288
|
+
if (disposed) return
|
|
289
|
+
if (!r.ok) {
|
|
290
|
+
d.failure = r
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
d.body = ''
|
|
294
|
+
announce({ key: 'notice.appealSent' })
|
|
295
|
+
await loadNotice(noticeReceipt)
|
|
296
|
+
},
|
|
297
|
+
async withdrawNoticeAppeal() {
|
|
298
|
+
if (!state.notice.value?.appeal.open || noticeReceipt === null || state.busy.noticeWithdraw) return
|
|
299
|
+
state.busy.noticeWithdraw = true
|
|
300
|
+
const r = await client.notices.withdrawAppeal({ receipt: noticeReceipt }, call)
|
|
301
|
+
delete state.busy.noticeWithdraw
|
|
302
|
+
if (disposed) return
|
|
303
|
+
if (!r.ok) return announce(failureRef(r))
|
|
304
|
+
announce({ key: 'statement.appealWithdrawn' })
|
|
305
|
+
await loadNotice(noticeReceipt)
|
|
306
|
+
},
|
|
307
|
+
async withdrawConsent() {
|
|
308
|
+
if (state.busy.consent) return false
|
|
309
|
+
state.busy.consent = true
|
|
310
|
+
const r = await client.me.consents.withdraw({ kind: 'art9_consent' }, call)
|
|
311
|
+
delete state.busy.consent
|
|
312
|
+
if (disposed) return false
|
|
313
|
+
if (!r.ok) {
|
|
314
|
+
announce(failureRef(r))
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
if (state.me) state.me = { ...state.me, acceptances: { ...state.me.acceptances, art9Consent: r.value.state as MyState['acceptances']['rules'] } }
|
|
318
|
+
announce({ key: 'panel.art9Withdrawn' })
|
|
319
|
+
return true
|
|
320
|
+
},
|
|
321
|
+
async unblock(memberId) {
|
|
322
|
+
const key = `block:${memberId}`
|
|
323
|
+
if (state.busy[key]) return
|
|
324
|
+
state.busy[key] = true
|
|
325
|
+
const r = await client.blocks.set({ memberId, blocked: false }, call)
|
|
326
|
+
delete state.busy[key]
|
|
327
|
+
if (disposed) return
|
|
328
|
+
if (!r.ok) return announce(failureRef(r))
|
|
329
|
+
state.blocks = state.blocks.filter((b) => b.memberId !== memberId)
|
|
330
|
+
announce({ key: 'discussion.unblocked' })
|
|
331
|
+
},
|
|
332
|
+
editProfile() {
|
|
333
|
+
const p: OwnProfile | null | undefined = state.me?.profile
|
|
334
|
+
if (!p) return
|
|
335
|
+
state.profile = { displayName: p.displayName, handle: p.handle, avatarPreset: p.avatarPreset, sending: false, failure: null, fields: [] }
|
|
336
|
+
},
|
|
337
|
+
cancelProfile() {
|
|
338
|
+
state.profile = null
|
|
339
|
+
},
|
|
340
|
+
async saveProfile() {
|
|
341
|
+
const d = state.profile
|
|
342
|
+
const current = state.me?.profile
|
|
343
|
+
if (!d || !current || d.sending || !isDisplayNameOk(d.displayName) || !isHandleOk(d.handle) || normalizeHandle(d.handle) === '') return
|
|
344
|
+
const input: { displayName?: string; handle?: string; avatarPreset?: string | null } = {}
|
|
345
|
+
const name = normalizeName(d.displayName)
|
|
346
|
+
const handle = normalizeHandle(d.handle)
|
|
347
|
+
if (name !== current.displayName) input.displayName = name
|
|
348
|
+
if (handle !== current.handle) input.handle = handle
|
|
349
|
+
if (d.avatarPreset !== current.avatarPreset) input.avatarPreset = d.avatarPreset
|
|
350
|
+
if (Object.keys(input).length === 0) {
|
|
351
|
+
state.profile = null
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
d.sending = true
|
|
355
|
+
d.failure = null
|
|
356
|
+
d.fields = []
|
|
357
|
+
const r = await client.me.updateProfile(input, call)
|
|
358
|
+
d.sending = false
|
|
359
|
+
if (disposed) return
|
|
360
|
+
if (!r.ok) {
|
|
361
|
+
d.failure = r
|
|
362
|
+
d.fields = r.code === 'handle' ? ['handle'] : r.code === 'name' ? ['displayName'] : (r.fields ?? [])
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
// The form closes on the new values (the catalogue has no sentence for a saved profile).
|
|
366
|
+
if (state.me) state.me = { ...state.me, profile: r.value.profile }
|
|
367
|
+
state.profile = null
|
|
368
|
+
},
|
|
369
|
+
async exportData(save) {
|
|
370
|
+
if (state.exporting) return
|
|
371
|
+
state.exporting = true
|
|
372
|
+
const r = await client.me.export(call)
|
|
373
|
+
state.exporting = false
|
|
374
|
+
if (disposed) return
|
|
375
|
+
if (!r.ok) return announce(failureRef(r))
|
|
376
|
+
save(r.value)
|
|
377
|
+
},
|
|
378
|
+
async erase() {
|
|
379
|
+
if (state.busy.erase) return false
|
|
380
|
+
state.busy.erase = true
|
|
381
|
+
const r = await client.me.erase(call)
|
|
382
|
+
delete state.busy.erase
|
|
383
|
+
if (disposed) return false
|
|
384
|
+
if (!r.ok) {
|
|
385
|
+
announce(failureRef(r))
|
|
386
|
+
return false
|
|
387
|
+
}
|
|
388
|
+
state.purgeAfter = r.value.purgeAfter
|
|
389
|
+
if (state.me?.member) state.me = { ...state.me, member: { ...state.me.member, status: r.value.status } }
|
|
390
|
+
return true
|
|
391
|
+
},
|
|
392
|
+
async cancelErase() {
|
|
393
|
+
if (state.busy.erase) return
|
|
394
|
+
state.busy.erase = true
|
|
395
|
+
const r = await client.me.cancelErase(call)
|
|
396
|
+
delete state.busy.erase
|
|
397
|
+
if (disposed) return
|
|
398
|
+
if (!r.ok) return announce(failureRef(r))
|
|
399
|
+
state.purgeAfter = null
|
|
400
|
+
if (state.me?.member) state.me = { ...state.me, member: { ...state.me.member, status: r.value.status } }
|
|
401
|
+
announce({ key: 'panel.eraseCancelled' })
|
|
402
|
+
},
|
|
403
|
+
erasing: () => state.me?.member?.status === 'deletion_requested',
|
|
404
|
+
appealLength,
|
|
405
|
+
canAppealWith,
|
|
406
|
+
dispose() {
|
|
407
|
+
disposed = true
|
|
408
|
+
abort.abort()
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A notifier's receipt as `<blogmcp-my-community>` takes it (m1-contracts.md §7.9, §10.2; community.md invariant 8):
|
|
3
|
+
* from its OWN page's address only — `#t=` in `location.hash` when it mounts —, and the address bar cleared at once
|
|
4
|
+
* (the SDK's readReceiptFromHash, which also clears the router's copy in `history.state`).
|
|
5
|
+
*
|
|
6
|
+
* That reader remembers the receipt for the whole window too (so a framework that mounts a page twice still gets
|
|
7
|
+
* it), and hands it back with no fragment in the address. An element never takes that for its page's: a later page
|
|
8
|
+
* of the same tab — a client-side navigation, the next person at a shared device — would ask for the notifier's
|
|
9
|
+
* case again and offer its appeal. What an element took it keeps for itself (the same element put back by its host
|
|
10
|
+
* shows it again); no other element sees it.
|
|
11
|
+
*/
|
|
12
|
+
import { readReceiptFromHash, receiptFromHash } from '@blogmcp/sdk/community'
|
|
13
|
+
|
|
14
|
+
const taken = new WeakMap<object, string | null>()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The receipt this element's page carries: a string; `null` for a `#t=` that holds none (a cut link — "incomplete",
|
|
18
|
+
* never an older receipt of the tab); `undefined` when the page carries none for it.
|
|
19
|
+
*/
|
|
20
|
+
export function receiptForElement(owner: object | null): string | null | undefined {
|
|
21
|
+
if (typeof location !== 'undefined' && location.hash.startsWith('#t=')) {
|
|
22
|
+
const receipt = receiptFromHash(location.hash)
|
|
23
|
+
readReceiptFromHash()
|
|
24
|
+
if (owner) taken.set(owner, receipt)
|
|
25
|
+
return receipt
|
|
26
|
+
}
|
|
27
|
+
return owner && taken.has(owner) ? (taken.get(owner) ?? null) : undefined
|
|
28
|
+
}
|
package/src/core/text.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text, and only text (m1-contracts.md §10.4, community.md §7.7): what a member wrote is rendered as text
|
|
3
|
+
* nodes — never HTML, never markdown. A web address in it becomes a link only through @blogmcp/sanitize's
|
|
4
|
+
* `sanitizeUrl`, and the view gives every such link `rel="nofollow ugc noopener noreferrer"` and
|
|
5
|
+
* `target="_blank"`. test/text-only.spec.ts bans `v-html`, `innerHTML` and any HTML parser in src/.
|
|
6
|
+
*/
|
|
7
|
+
import { sanitizeUrl } from '@blogmcp/sanitize/url'
|
|
8
|
+
|
|
9
|
+
export type TextSegment =
|
|
10
|
+
| { readonly kind: 'text'; readonly text: string }
|
|
11
|
+
| { readonly kind: 'link'; readonly text: string; readonly href: string }
|
|
12
|
+
|
|
13
|
+
/** The rel every link to what a member wrote carries (user-generated, no referrer, no opener). */
|
|
14
|
+
export const UGC_LINK_REL = 'nofollow ugc noopener noreferrer'
|
|
15
|
+
|
|
16
|
+
/** A web address as people write it: `http(s)://` or `www.`, up to a blank or a character no address carries. */
|
|
17
|
+
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`{}|\\^]+/gi
|
|
18
|
+
/** Characters that end a sentence more often than an address. */
|
|
19
|
+
const TRAILING = '.,;:!?…\'"»”’]'
|
|
20
|
+
|
|
21
|
+
/** The address without the punctuation after it; a `)` stays when it closes a `(` inside the address. */
|
|
22
|
+
function trimUrl(raw: string): string {
|
|
23
|
+
let url = raw
|
|
24
|
+
while (url.length > 0) {
|
|
25
|
+
const last = url[url.length - 1]!
|
|
26
|
+
if (last === ')') {
|
|
27
|
+
const opens = url.split('(').length - 1
|
|
28
|
+
const closes = url.split(')').length - 1
|
|
29
|
+
if (closes <= opens) break
|
|
30
|
+
} else if (!TRAILING.includes(last)) {
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
url = url.slice(0, -1)
|
|
34
|
+
}
|
|
35
|
+
return url
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The link target of an address, or null: only http and https survive sanitizeUrl as a link. */
|
|
39
|
+
function safeHref(url: string): string | null {
|
|
40
|
+
const candidate = /^www\./i.test(url) ? `https://${url}` : url
|
|
41
|
+
// A bare scheme is no address.
|
|
42
|
+
if (/^https?:\/\/$/i.test(candidate)) return null
|
|
43
|
+
const safe = sanitizeUrl(candidate)
|
|
44
|
+
return safe !== 'about:blank' && /^https?:\/\//i.test(safe) ? safe : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A member's text as text runs and links. */
|
|
48
|
+
export function linkify(text: string): TextSegment[] {
|
|
49
|
+
const out: TextSegment[] = []
|
|
50
|
+
if (typeof text !== 'string' || text === '') return out
|
|
51
|
+
let at = 0
|
|
52
|
+
for (const m of text.matchAll(URL_RE)) {
|
|
53
|
+
const start = m.index ?? 0
|
|
54
|
+
if (start < at) continue
|
|
55
|
+
const url = trimUrl(m[0])
|
|
56
|
+
const href = url ? safeHref(url) : null
|
|
57
|
+
if (!href) continue
|
|
58
|
+
if (start > at) out.push({ kind: 'text', text: text.slice(at, start) })
|
|
59
|
+
out.push({ kind: 'link', text: url, href })
|
|
60
|
+
at = start + url.length
|
|
61
|
+
}
|
|
62
|
+
if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Characters in code points, as kb-api counts a body (after NFC and trimming). */
|
|
67
|
+
export function codePointLength(text: string): number {
|
|
68
|
+
let n = 0
|
|
69
|
+
for (const _ of text) n++
|
|
70
|
+
return n
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A body as kb-api will measure it: NFC, trimmed. */
|
|
74
|
+
export function normalizeBody(text: string): string {
|
|
75
|
+
return typeof text === 'string' ? text.normalize('NFC').trim() : ''
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ENTITIES: Readonly<Record<string, string>> = Object.freeze({
|
|
79
|
+
amp: '&',
|
|
80
|
+
lt: '<',
|
|
81
|
+
gt: '>',
|
|
82
|
+
quot: '"',
|
|
83
|
+
apos: "'",
|
|
84
|
+
nbsp: ' ',
|
|
85
|
+
ndash: '–',
|
|
86
|
+
mdash: '—',
|
|
87
|
+
hellip: '…',
|
|
88
|
+
laquo: '«',
|
|
89
|
+
raquo: '»',
|
|
90
|
+
bdquo: '„',
|
|
91
|
+
ldquo: '“',
|
|
92
|
+
rdquo: '”',
|
|
93
|
+
lsquo: '‘',
|
|
94
|
+
rsquo: '’',
|
|
95
|
+
sbquo: '‚',
|
|
96
|
+
copy: '©',
|
|
97
|
+
reg: '®',
|
|
98
|
+
euro: '€',
|
|
99
|
+
shy: '',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
function decodeEntities(text: string): string {
|
|
103
|
+
return text.replace(/&(#\d{1,7}|#x[0-9a-f]{1,6}|[a-z]{2,8});/gi, (match, entity: string) => {
|
|
104
|
+
if (entity[0] === '#') {
|
|
105
|
+
const hex = entity[1] === 'x' || entity[1] === 'X'
|
|
106
|
+
const cp = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10)
|
|
107
|
+
return cp > 0 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) ? String.fromCodePoint(cp) : match
|
|
108
|
+
}
|
|
109
|
+
return ENTITIES[entity.toLowerCase()] ?? match
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Elements whose end closes a paragraph of the text. */
|
|
114
|
+
const BLOCK_END = /<\/(?:p|div|li|h[1-6]|section|article|blockquote|tr|ul|ol|dl|dt|dd|header|footer|table|pre|figure)\s*>/gi
|
|
115
|
+
/** Elements whose start opens one. */
|
|
116
|
+
const BLOCK_START = /<(?:li|tr|dt|dd|h[1-6]|blockquote|pre|figure)\b[^>]*>/gi
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The paragraphs of the tenant's own legal text (`legal/current`'s `body` is HTML with anchors) as plain
|
|
120
|
+
* text, for the art. 9 consent step: tags are dropped and entities decoded AFTER that, so nothing in the
|
|
121
|
+
* text is ever parsed as markup again. String work only — no HTML parser, no live DOM.
|
|
122
|
+
*/
|
|
123
|
+
export function htmlToParagraphs(html: string): string[] {
|
|
124
|
+
if (typeof html !== 'string' || html === '') return []
|
|
125
|
+
const text = html
|
|
126
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
127
|
+
.replace(/<(script|style|template|noscript|svg|math)\b[\s\S]*?<\/\1\s*>/gi, '')
|
|
128
|
+
.replace(/[\t\n\r\f ]+/g, ' ')
|
|
129
|
+
.replace(/<br\b[^>]*>/gi, '\n')
|
|
130
|
+
.replace(BLOCK_END, '\n\n')
|
|
131
|
+
.replace(BLOCK_START, '\n\n')
|
|
132
|
+
.replace(/<[^>]*>/g, '')
|
|
133
|
+
return decodeEntities(text)
|
|
134
|
+
.split(/\n{2,}/)
|
|
135
|
+
.map((p) =>
|
|
136
|
+
p
|
|
137
|
+
.replace(/ *\n */g, '\n')
|
|
138
|
+
.replace(/[ ]{2,}/g, ' ')
|
|
139
|
+
.trim(),
|
|
140
|
+
)
|
|
141
|
+
.filter((p) => p !== '')
|
|
142
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Turnstile for the notice form, by the storefront's TurnstileWidget rules: rendered explicitly,
|
|
3
|
+
* the script loaded only when a notice form mounts (no other page sends Cloudflare a byte), the token
|
|
4
|
+
* one-shot — kb-api spends it on siteverify whatever the answer, so the form asks for a new one after every
|
|
5
|
+
* failure — and a form whose script did not load cannot be sent.
|
|
6
|
+
*
|
|
7
|
+
* The widget is rendered into an element in the custom element's LIGHT DOM (projected through a slot):
|
|
8
|
+
* Turnstile's iframe then lives in the page's own document, as on any other site.
|
|
9
|
+
*/
|
|
10
|
+
import { COMMUNITY_NOTICE_TURNSTILE_ACTION } from '@blogmcp/sdk/community'
|
|
11
|
+
|
|
12
|
+
export const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
|
13
|
+
|
|
14
|
+
interface TurnstileRenderOptions {
|
|
15
|
+
sitekey: string
|
|
16
|
+
action?: string
|
|
17
|
+
language?: string
|
|
18
|
+
appearance?: 'always' | 'execute' | 'interaction-only'
|
|
19
|
+
callback?: (token: string) => void
|
|
20
|
+
'expired-callback'?: () => void
|
|
21
|
+
'error-callback'?: () => void
|
|
22
|
+
'timeout-callback'?: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TurnstileApi {
|
|
26
|
+
render(el: HTMLElement, options: TurnstileRenderOptions): string
|
|
27
|
+
reset(widgetId: string): void
|
|
28
|
+
remove(widgetId: string): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type TurnstileWindow = Window & { turnstile?: TurnstileApi }
|
|
32
|
+
|
|
33
|
+
let scriptPromise: Promise<TurnstileApi> | null = null
|
|
34
|
+
|
|
35
|
+
/** One `<script>` per page however many forms mount; a failed load may be tried again later. */
|
|
36
|
+
export function loadTurnstile(nonce?: string): Promise<TurnstileApi> {
|
|
37
|
+
const w = window as TurnstileWindow
|
|
38
|
+
if (w.turnstile) return Promise.resolve(w.turnstile)
|
|
39
|
+
if (scriptPromise) return scriptPromise
|
|
40
|
+
scriptPromise = new Promise<TurnstileApi>((resolve, reject) => {
|
|
41
|
+
const script = document.createElement('script')
|
|
42
|
+
script.src = TURNSTILE_SCRIPT_SRC
|
|
43
|
+
script.async = true
|
|
44
|
+
script.defer = true
|
|
45
|
+
if (nonce) script.nonce = nonce
|
|
46
|
+
script.onload = () => (w.turnstile ? resolve(w.turnstile) : reject(new Error('[blogmcp-community] the Turnstile script loaded without its API')))
|
|
47
|
+
script.onerror = () => reject(new Error('[blogmcp-community] the Turnstile script failed to load'))
|
|
48
|
+
document.head.appendChild(script)
|
|
49
|
+
}).catch((err: unknown) => {
|
|
50
|
+
scriptPromise = null
|
|
51
|
+
throw err
|
|
52
|
+
})
|
|
53
|
+
return scriptPromise
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TurnstileWidget {
|
|
57
|
+
/** A fresh token (the last one is spent). */
|
|
58
|
+
reset(): void
|
|
59
|
+
remove(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RenderTurnstileOptions {
|
|
63
|
+
siteKey: string
|
|
64
|
+
language: string
|
|
65
|
+
/** A new token, or null when it expired or failed (the form cannot be sent until a new one comes). */
|
|
66
|
+
onToken(token: string | null): void
|
|
67
|
+
nonce?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The widget in `container`; rejects when the script cannot load (an ad blocker, the network). */
|
|
71
|
+
export async function renderTurnstile(container: HTMLElement, o: RenderTurnstileOptions): Promise<TurnstileWidget> {
|
|
72
|
+
const api = await loadTurnstile(o.nonce)
|
|
73
|
+
const id = api.render(container, {
|
|
74
|
+
sitekey: o.siteKey,
|
|
75
|
+
// kb-api refuses a token issued for any other action.
|
|
76
|
+
action: COMMUNITY_NOTICE_TURNSTILE_ACTION,
|
|
77
|
+
language: o.language,
|
|
78
|
+
// Visible only when Cloudflare really wants an interaction.
|
|
79
|
+
appearance: 'interaction-only',
|
|
80
|
+
callback: (token) => o.onToken(token),
|
|
81
|
+
'expired-callback': () => o.onToken(null),
|
|
82
|
+
'error-callback': () => o.onToken(null),
|
|
83
|
+
'timeout-callback': () => o.onToken(null),
|
|
84
|
+
})
|
|
85
|
+
let removed = false
|
|
86
|
+
return {
|
|
87
|
+
reset() {
|
|
88
|
+
if (removed) return
|
|
89
|
+
o.onToken(null)
|
|
90
|
+
api.reset(id)
|
|
91
|
+
},
|
|
92
|
+
remove() {
|
|
93
|
+
if (removed) return
|
|
94
|
+
removed = true
|
|
95
|
+
api.remove(id)
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|