@blogmcp/community 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-WadUnaTa.js +6436 -0
- package/dist/core/attrs.d.ts +99 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +175 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +24 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +92 -0
- package/dist/core/outcomes.d.ts +29 -0
- package/dist/core/panel.d.ts +96 -0
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +35 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +47 -0
- package/dist/global.d.ts +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +45 -0
- package/dist/mount.d.ts +105 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +55 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +44 -0
- package/dist/ui/LinkedText.vue.d.ts +6 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +26 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +200 -0
- package/dist/vue.js +201 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +186 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/client.ts +54 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +876 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +112 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +492 -0
- package/src/core/outcomes.ts +86 -0
- package/src/core/panel.ts +446 -0
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +174 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +165 -0
- package/src/global.ts +26 -0
- package/src/index.ts +70 -0
- package/src/mount.ts +234 -0
- package/src/react.ts +97 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +334 -0
- package/src/ui/LinkedText.vue +24 -0
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +141 -0
- package/src/ui/MessageItem.vue +223 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +378 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +174 -0
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +259 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +242 -0
|
@@ -0,0 +1,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
|
+
}
|