@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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. package/src/vue.ts +242 -0
@@ -0,0 +1,446 @@
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 (and,
5
+ * M2 T9, a refused one given after all — the onboarding's art. 9 step, opened here), 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, shallowRef, type ShallowRef } 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 { pickLang, type TextRef } from './i18n'
29
+ import { createOnboarding, type OnboardingModel } from './onboarding'
30
+ import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName } from './names'
31
+ import { codePointLength, normalizeBody } from './text'
32
+
33
+ export type LoadPhase = 'idle' | 'loading' | 'ready' | 'failed'
34
+
35
+ export interface AppealDraft {
36
+ body: string
37
+ sending: boolean
38
+ failure: CommunityFailure | null
39
+ }
40
+
41
+ export interface ProfileDraft {
42
+ displayName: string
43
+ handle: string
44
+ avatarPreset: string | null
45
+ sending: boolean
46
+ failure: CommunityFailure | null
47
+ fields: string[]
48
+ }
49
+
50
+ export interface PanelUi {
51
+ /** The member's part: `signIn` when the session cannot act as a member (or there is none). */
52
+ phase: LoadPhase | 'signIn'
53
+ failure: CommunityFailure | null
54
+ me: MyState | null
55
+ notifications: NotificationItem[]
56
+ decisions: DecisionSummary[]
57
+ appeals: AppealItem[]
58
+ reports: ReportItem[]
59
+ blocks: BlockEntry[]
60
+ /** The statement the page opens (`decision-id`). */
61
+ statement: { phase: LoadPhase | 'signIn'; value: Statement | null; failure: CommunityFailure | null; appeal: AppealDraft }
62
+ /** The notifier's part (`#t=` in the address). `missing`: a `#t=` that holds no receipt. */
63
+ notice: { phase: LoadPhase | 'missing'; value: NoticeStatus | null; failure: CommunityFailure | null; appeal: AppealDraft }
64
+ profile: ProfileDraft | null
65
+ /** The profile's deletion: its date once requested here (kb-api's MyState does not carry it). */
66
+ purgeAfter: string | null
67
+ exporting: boolean
68
+ busy: Record<string, boolean>
69
+ announcement: TextRef | null
70
+ announceId: number
71
+ }
72
+
73
+ export interface PanelController {
74
+ readonly state: PanelUi
75
+ /** The give-consent flow (the onboarding's art. 9 step), while it is open. */
76
+ readonly consent: ShallowRef<OnboardingModel | null>
77
+ /** The member's sections (me/get and the lists). */
78
+ loadMember(): Promise<void>
79
+ loadStatement(decisionId: string): Promise<void>
80
+ loadNotice(receipt: string | null): Promise<void>
81
+ unreadCount(): number
82
+ markAllRead(): Promise<void>
83
+ fileAppeal(): Promise<void>
84
+ withdrawAppeal(): Promise<void>
85
+ fileNoticeAppeal(): Promise<void>
86
+ withdrawNoticeAppeal(): Promise<void>
87
+ withdrawConsent(): Promise<boolean>
88
+ /** A refused art. 9 consent (`acceptances.art9Refused`): opens its step to give it after all. */
89
+ giveConsent(): void
90
+ closeConsent(): void
91
+ unblock(memberId: string): Promise<void>
92
+ editProfile(): void
93
+ cancelProfile(): void
94
+ saveProfile(): Promise<void>
95
+ /** `me/export` as a JSON file the browser saves (`save` does the download; tests pass their own). */
96
+ exportData(save: (data: MemberExport) => void): Promise<void>
97
+ erase(): Promise<boolean>
98
+ cancelErase(): Promise<void>
99
+ /** The member asked for the deletion (here or before). */
100
+ erasing(): boolean
101
+ appealLength(draft: AppealDraft): number
102
+ canAppealWith(draft: AppealDraft): boolean
103
+ dispose(): void
104
+ }
105
+
106
+ export interface PanelOptions {
107
+ client: CommunityClient
108
+ lang: string
109
+ }
110
+
111
+ const appealDraft = (): AppealDraft => ({ body: '', sending: false, failure: null })
112
+
113
+ export function createPanel(o: PanelOptions): PanelController {
114
+ const { client } = o
115
+ const abort = new AbortController()
116
+ const call: CommunityCallOptions = { signal: abort.signal }
117
+ const state = reactive<PanelUi>({
118
+ phase: 'idle',
119
+ failure: null,
120
+ me: null,
121
+ notifications: [],
122
+ decisions: [],
123
+ appeals: [],
124
+ reports: [],
125
+ blocks: [],
126
+ statement: { phase: 'idle', value: null, failure: null, appeal: appealDraft() },
127
+ notice: { phase: 'idle', value: null, failure: null, appeal: appealDraft() },
128
+ profile: null,
129
+ purgeAfter: null,
130
+ exporting: false,
131
+ busy: {},
132
+ announcement: null,
133
+ announceId: 0,
134
+ }) as PanelUi
135
+ let disposed = false
136
+ const consent = shallowRef<OnboardingModel | null>(null)
137
+
138
+ const announce = (text: TextRef): void => {
139
+ state.announcement = text
140
+ state.announceId += 1
141
+ }
142
+ const failureRef = (f: CommunityFailure): TextRef => ({ key: `error.${f.code}` as TextRef['key'] })
143
+
144
+ async function loadMember(): Promise<void> {
145
+ state.phase = 'loading'
146
+ state.failure = null
147
+ const me = await client.me.get(call)
148
+ if (disposed) return
149
+ if (!me.ok) {
150
+ if (me.code === 'signIn') {
151
+ state.phase = 'signIn'
152
+ return
153
+ }
154
+ state.phase = 'failed'
155
+ state.failure = me
156
+ return
157
+ }
158
+ state.me = me.value
159
+ if (me.value.needs.includes('sign_in')) {
160
+ state.phase = 'signIn'
161
+ return
162
+ }
163
+ if (!me.value.member) {
164
+ // Signed in, never joined: nothing of theirs to list yet.
165
+ state.phase = 'ready'
166
+ return
167
+ }
168
+ const [notifications, decisions, appeals, reports, blocks] = await Promise.all([
169
+ client.me.notifications.list({}, call),
170
+ client.me.decisions.list({}, call),
171
+ client.me.appeals.list(call),
172
+ client.me.reports.list(call),
173
+ client.blocks.list(call),
174
+ ])
175
+ if (disposed) return
176
+ if (notifications.ok) state.notifications = notifications.value.items.filter((n) => isKnownNotificationCode(n.code))
177
+ if (decisions.ok) state.decisions = [...decisions.value.items]
178
+ if (appeals.ok) state.appeals = [...appeals.value.items]
179
+ if (reports.ok) state.reports = [...reports.value.items]
180
+ if (blocks.ok) state.blocks = [...blocks.value.blocks]
181
+ const first = [notifications, decisions, appeals, reports, blocks].find((r) => !r.ok)
182
+ if (first && !first.ok) announce(failureRef(first))
183
+ state.phase = 'ready'
184
+ }
185
+
186
+ // The statement the page shows now, and its load: a host that reuses the element for another decision-id
187
+ // (client-side navigation) must never see the late answer for the one before (nor appeal that one there).
188
+ let statementId: string | null = null
189
+ let statementLoad = 0
190
+
191
+ async function loadStatement(decisionId: string): Promise<void> {
192
+ const ticket = ++statementLoad
193
+ if (decisionId !== statementId) {
194
+ // Another decision: nothing of the one before stays on its page — its text, the appeal typed under it.
195
+ statementId = decisionId
196
+ state.statement.value = null
197
+ state.statement.appeal = appealDraft()
198
+ }
199
+ state.statement.phase = 'loading'
200
+ state.statement.failure = null
201
+ const r = await client.me.decisions.get({ decisionId }, call)
202
+ if (disposed || ticket !== statementLoad) return
203
+ if (r.ok) {
204
+ state.statement.value = r.value
205
+ state.statement.phase = 'ready'
206
+ return
207
+ }
208
+ state.statement.failure = r
209
+ state.statement.phase = r.code === 'signIn' ? 'signIn' : 'failed'
210
+ }
211
+
212
+ async function loadNotice(receipt: string | null): Promise<void> {
213
+ if (receipt === null) {
214
+ state.notice.phase = 'missing'
215
+ return
216
+ }
217
+ state.notice.phase = 'loading'
218
+ state.notice.failure = null
219
+ const r = await client.notices.status({ receipt }, call)
220
+ if (disposed) return
221
+ if (r.ok) {
222
+ state.notice.value = r.value
223
+ state.notice.phase = 'ready'
224
+ return
225
+ }
226
+ state.notice.failure = r
227
+ state.notice.phase = 'failed'
228
+ }
229
+ // The receipt is the notifier's capability: kept in this closure only, never in the state a view renders.
230
+ let noticeReceipt: string | null = null
231
+
232
+ const appealLength = (d: AppealDraft): number => codePointLength(normalizeBody(d.body))
233
+ const canAppealWith = (d: AppealDraft): boolean => !d.sending && appealLength(d) > 0 && appealLength(d) <= COMMUNITY_APPEAL_MAX
234
+
235
+ function giveConsent(): void {
236
+ if (consent.value || disposed || !state.me?.member || state.me.acceptances.art9Refused !== true) return
237
+ const model = createOnboarding({
238
+ client,
239
+ lang: pickLang(o.lang),
240
+ signal: abort.signal,
241
+ offerArt9: true,
242
+ onDone: (me) => {
243
+ state.me = me
244
+ consent.value = null
245
+ // Only a consent in force is announced: the flow also closes when no step was left to ask (nothing sent).
246
+ if (me.acceptances.art9Consent === 'accepted') announce({ key: 'panel.art9Given' })
247
+ },
248
+ onSignIn: () => {
249
+ consent.value = null
250
+ state.phase = 'signIn'
251
+ },
252
+ })
253
+ consent.value = model
254
+ void model.load()
255
+ }
256
+
257
+ return {
258
+ state,
259
+ consent,
260
+ giveConsent,
261
+ closeConsent() {
262
+ consent.value = null
263
+ },
264
+ loadMember,
265
+ loadStatement,
266
+ async loadNotice(receipt) {
267
+ noticeReceipt = receipt
268
+ await loadNotice(receipt)
269
+ },
270
+ unreadCount: () => state.notifications.filter((n) => n.readAt === null).length,
271
+ async markAllRead() {
272
+ const ids = state.notifications.filter((n) => n.readAt === null).map((n) => n.id)
273
+ if (ids.length === 0 || state.busy.markRead) return
274
+ state.busy.markRead = true
275
+ const r = await client.me.notifications.markRead({ ids }, call)
276
+ delete state.busy.markRead
277
+ if (disposed) return
278
+ if (!r.ok) return announce(failureRef(r))
279
+ const at = new Date().toISOString()
280
+ state.notifications = state.notifications.map((n) => (ids.includes(n.id) ? { ...n, readAt: at } : n))
281
+ },
282
+ async fileAppeal() {
283
+ const s = state.statement
284
+ const d = s.appeal
285
+ if (!s.value || !s.value.canAppeal || !canAppealWith(d)) return
286
+ const decisionId = s.value.decisionId
287
+ d.sending = true
288
+ d.failure = null
289
+ const r = await client.me.appeals.file({ decisionId, body: normalizeBody(d.body) }, call)
290
+ d.sending = false
291
+ // The page moved on to another statement meanwhile: the answer is that one's no more.
292
+ if (disposed || decisionId !== statementId) return
293
+ if (!r.ok) {
294
+ d.failure = r
295
+ return
296
+ }
297
+ d.body = ''
298
+ announce({ key: 'statement.appealSent' })
299
+ await loadStatement(decisionId)
300
+ },
301
+ async withdrawAppeal() {
302
+ const s = state.statement
303
+ const open = s.value?.appeal
304
+ if (!s.value || !open || open.status !== 'open' || state.busy.withdraw) return
305
+ const decisionId = s.value.decisionId
306
+ state.busy.withdraw = true
307
+ const r = await client.me.appeals.withdraw({ appealId: open.appealId }, call)
308
+ delete state.busy.withdraw
309
+ if (disposed || decisionId !== statementId) return
310
+ if (!r.ok) return announce(failureRef(r))
311
+ announce({ key: 'statement.appealWithdrawn' })
312
+ await loadStatement(decisionId)
313
+ },
314
+ async fileNoticeAppeal() {
315
+ const n = state.notice
316
+ const d = n.appeal
317
+ if (!n.value || !n.value.appeal.canAppeal || noticeReceipt === null || !canAppealWith(d)) return
318
+ d.sending = true
319
+ d.failure = null
320
+ const r = await client.notices.appeal({ receipt: noticeReceipt, body: normalizeBody(d.body) }, call)
321
+ d.sending = false
322
+ if (disposed) return
323
+ if (!r.ok) {
324
+ d.failure = r
325
+ return
326
+ }
327
+ d.body = ''
328
+ announce({ key: 'notice.appealSent' })
329
+ await loadNotice(noticeReceipt)
330
+ },
331
+ async withdrawNoticeAppeal() {
332
+ if (!state.notice.value?.appeal.open || noticeReceipt === null || state.busy.noticeWithdraw) return
333
+ state.busy.noticeWithdraw = true
334
+ const r = await client.notices.withdrawAppeal({ receipt: noticeReceipt }, call)
335
+ delete state.busy.noticeWithdraw
336
+ if (disposed) return
337
+ if (!r.ok) return announce(failureRef(r))
338
+ announce({ key: 'statement.appealWithdrawn' })
339
+ await loadNotice(noticeReceipt)
340
+ },
341
+ async withdrawConsent() {
342
+ if (state.busy.consent) return false
343
+ state.busy.consent = true
344
+ const r = await client.me.consents.withdraw({ kind: 'art9_consent' }, call)
345
+ delete state.busy.consent
346
+ if (disposed) return false
347
+ if (!r.ok) {
348
+ announce(failureRef(r))
349
+ return false
350
+ }
351
+ if (state.me) state.me = { ...state.me, acceptances: { ...state.me.acceptances, art9Consent: r.value.state as MyState['acceptances']['rules'] } }
352
+ announce({ key: 'panel.art9Withdrawn' })
353
+ return true
354
+ },
355
+ async unblock(memberId) {
356
+ const key = `block:${memberId}`
357
+ if (state.busy[key]) return
358
+ state.busy[key] = true
359
+ const r = await client.blocks.set({ memberId, blocked: false }, call)
360
+ delete state.busy[key]
361
+ if (disposed) return
362
+ if (!r.ok) return announce(failureRef(r))
363
+ state.blocks = state.blocks.filter((b) => b.memberId !== memberId)
364
+ announce({ key: 'discussion.unblocked' })
365
+ },
366
+ editProfile() {
367
+ const p: OwnProfile | null | undefined = state.me?.profile
368
+ if (!p) return
369
+ state.profile = { displayName: p.displayName, handle: p.handle, avatarPreset: p.avatarPreset, sending: false, failure: null, fields: [] }
370
+ },
371
+ cancelProfile() {
372
+ state.profile = null
373
+ },
374
+ async saveProfile() {
375
+ const d = state.profile
376
+ const current = state.me?.profile
377
+ if (!d || !current || d.sending || !isDisplayNameOk(d.displayName) || !isHandleOk(d.handle) || normalizeHandle(d.handle) === '') return
378
+ const input: { displayName?: string; handle?: string; avatarPreset?: string | null } = {}
379
+ const name = normalizeName(d.displayName)
380
+ const handle = normalizeHandle(d.handle)
381
+ if (name !== current.displayName) input.displayName = name
382
+ if (handle !== current.handle) input.handle = handle
383
+ if (d.avatarPreset !== current.avatarPreset) input.avatarPreset = d.avatarPreset
384
+ if (Object.keys(input).length === 0) {
385
+ state.profile = null
386
+ return
387
+ }
388
+ d.sending = true
389
+ d.failure = null
390
+ d.fields = []
391
+ const r = await client.me.updateProfile(input, call)
392
+ d.sending = false
393
+ if (disposed) return
394
+ if (!r.ok) {
395
+ d.failure = r
396
+ d.fields = r.code === 'handle' ? ['handle'] : r.code === 'name' ? ['displayName'] : (r.fields ?? [])
397
+ return
398
+ }
399
+ // The form closes on the new values (the catalogue has no sentence for a saved profile).
400
+ if (state.me) state.me = { ...state.me, profile: r.value.profile }
401
+ state.profile = null
402
+ },
403
+ async exportData(save) {
404
+ if (state.exporting) return
405
+ state.exporting = true
406
+ const r = await client.me.export(call)
407
+ state.exporting = false
408
+ if (disposed) return
409
+ if (!r.ok) return announce(failureRef(r))
410
+ save(r.value)
411
+ },
412
+ async erase() {
413
+ if (state.busy.erase) return false
414
+ state.busy.erase = true
415
+ const r = await client.me.erase(call)
416
+ delete state.busy.erase
417
+ if (disposed) return false
418
+ if (!r.ok) {
419
+ announce(failureRef(r))
420
+ return false
421
+ }
422
+ state.purgeAfter = r.value.purgeAfter
423
+ if (state.me?.member) state.me = { ...state.me, member: { ...state.me.member, status: r.value.status } }
424
+ return true
425
+ },
426
+ async cancelErase() {
427
+ if (state.busy.erase) return
428
+ state.busy.erase = true
429
+ const r = await client.me.cancelErase(call)
430
+ delete state.busy.erase
431
+ if (disposed) return
432
+ if (!r.ok) return announce(failureRef(r))
433
+ state.purgeAfter = null
434
+ if (state.me?.member) state.me = { ...state.me, member: { ...state.me.member, status: r.value.status } }
435
+ announce({ key: 'panel.eraseCancelled' })
436
+ },
437
+ erasing: () => state.me?.member?.status === 'deletion_requested',
438
+ appealLength,
439
+ canAppealWith,
440
+ dispose() {
441
+ disposed = true
442
+ abort.abort()
443
+ consent.value = null
444
+ },
445
+ }
446
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `<blogmcp-profile handle>`'s and `<blogmcp-welcome>`'s brains (m2-contracts.md §3.2, §3.10).
3
+ *
4
+ * - The profile: `profile/get { handle }` — the public projection (the author, `memberSince` as `YYYY-MM`, the bio
5
+ * as text, the trust level as its LABEL, never the number). kb-api answers a signed-in host session only; an
6
+ * unknown handle, an erased member and one hidden from the viewer are the same `notFound`.
7
+ * - The welcome page: the tenant's current rules (`legal/current`, their summary from `bodyJson` as the onboarding
8
+ * shows it) and who is viewing (`me/get`: the profile link needs the member's handle).
9
+ */
10
+ import { reactive } from 'vue'
11
+ import {
12
+ COMMUNITY_HANDLE_RE,
13
+ communityRulesOf,
14
+ isKnownAuthor,
15
+ trustLabelMessageKey,
16
+ type CommunityCallOptions,
17
+ type CommunityClient,
18
+ type CommunityFailure,
19
+ type CommunityMessageKey,
20
+ type CommunityRule,
21
+ type KnownAuthor,
22
+ type PublicProfile,
23
+ } from '@blogmcp/sdk/community'
24
+
25
+ export type LoadPhase = 'idle' | 'loading' | 'ready' | 'failed' | 'signIn' | 'notFound'
26
+
27
+ export interface ProfileUi {
28
+ phase: LoadPhase
29
+ failure: CommunityFailure | null
30
+ author: KnownAuthor | null
31
+ memberSince: string | null
32
+ bio: string | null
33
+ /** The trust level's catalogue key (`trust.<n>`), or null for a label this SDK does not know. */
34
+ trustKey: CommunityMessageKey | null
35
+ }
36
+
37
+ export interface ProfileController {
38
+ readonly state: ProfileUi
39
+ load(handle: string): Promise<void>
40
+ dispose(): void
41
+ }
42
+
43
+ /** A handle as an attribute may give it (`@jana_n`, `Jana_N`): lower case, without the `@`; null for none. */
44
+ export function handleOf(value: unknown): string | null {
45
+ if (typeof value !== 'string') return null
46
+ const h = value.trim().replace(/^@/, '').toLowerCase()
47
+ return COMMUNITY_HANDLE_RE.test(h) ? h : null
48
+ }
49
+
50
+ export function createProfile(o: { client: CommunityClient }): ProfileController {
51
+ const { client } = o
52
+ const abort = new AbortController()
53
+ const call: CommunityCallOptions = { signal: abort.signal }
54
+ let disposed = false
55
+ let ticket = 0
56
+ const state = reactive<ProfileUi>({ phase: 'idle', failure: null, author: null, memberSince: null, bio: null, trustKey: null }) as ProfileUi
57
+
58
+ return {
59
+ state,
60
+ async load(handle) {
61
+ const mine = ++ticket
62
+ state.failure = null
63
+ state.author = null
64
+ state.memberSince = null
65
+ state.bio = null
66
+ state.trustKey = null
67
+ const h = handleOf(handle)
68
+ if (h === null) {
69
+ state.phase = 'notFound'
70
+ return
71
+ }
72
+ state.phase = 'loading'
73
+ const r = await client.profile.get({ handle: h }, call)
74
+ if (disposed || mine !== ticket) return
75
+ if (!r.ok) {
76
+ state.phase = r.code === 'signIn' ? 'signIn' : r.code === 'notFound' ? 'notFound' : 'failed'
77
+ state.failure = r
78
+ return
79
+ }
80
+ const p: PublicProfile = r.value
81
+ // A profile is a member's; an author of another kind (or one a newer kb-api added) is no profile to show.
82
+ if (!isKnownAuthor(p.author) || p.author.kind !== 'member' || p.author.memberId === null) {
83
+ state.phase = 'notFound'
84
+ return
85
+ }
86
+ state.author = p.author
87
+ state.memberSince = typeof p.memberSince === 'string' ? p.memberSince : null
88
+ state.bio = typeof p.bio === 'string' && p.bio.trim() !== '' ? p.bio : null
89
+ state.trustKey = trustLabelMessageKey(p.trustLabel)
90
+ state.phase = 'ready'
91
+ },
92
+ dispose() {
93
+ disposed = true
94
+ abort.abort()
95
+ },
96
+ }
97
+ }
98
+
99
+ export interface WelcomeUi {
100
+ phase: 'idle' | 'loading' | 'ready'
101
+ rules: CommunityRule[]
102
+ /** The rules could not be read: the page still links them. */
103
+ rulesFailed: boolean
104
+ /** The signed-in member's handle (the profile link), or null for a visitor and a reader who never joined. */
105
+ handle: string | null
106
+ /** Nobody is signed in: the sign-in link takes the profile link's place. */
107
+ visitor: boolean
108
+ }
109
+
110
+ export interface WelcomeController {
111
+ readonly state: WelcomeUi
112
+ load(): Promise<void>
113
+ dispose(): void
114
+ }
115
+
116
+ export function createWelcome(o: { client: CommunityClient; lang: string }): WelcomeController {
117
+ const { client } = o
118
+ const abort = new AbortController()
119
+ const call: CommunityCallOptions = { signal: abort.signal }
120
+ let disposed = false
121
+ const state = reactive<WelcomeUi>({ phase: 'idle', rules: [], rulesFailed: false, handle: null, visitor: false }) as WelcomeUi
122
+
123
+ return {
124
+ state,
125
+ async load() {
126
+ state.phase = 'loading'
127
+ const [rules, me] = await Promise.all([client.legal.current({ kind: 'rules', locale: o.lang }, call), client.me.get(call)])
128
+ if (disposed) return
129
+ if (rules.ok) state.rules = communityRulesOf(rules.value.bodyJson)
130
+ else state.rulesFailed = true
131
+ if (me.ok) {
132
+ state.visitor = me.value.needs.includes('sign_in')
133
+ state.handle = me.value.member ? handleOf(me.value.profile?.handle) : null
134
+ } else {
135
+ state.visitor = me.code === 'signIn'
136
+ }
137
+ state.phase = 'ready'
138
+ },
139
+ dispose() {
140
+ disposed = true
141
+ abort.abort()
142
+ },
143
+ }
144
+ }