@blogmcp/community 0.1.0 → 0.3.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/README.md +162 -13
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/mount--2_V2q3M.js +10063 -0
- package/dist/core/attrs.d.ts +42 -1
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +120 -15
- package/dist/core/follow.d.ts +26 -0
- package/dist/core/format.d.ts +7 -0
- package/dist/core/forum-category.d.ts +85 -0
- package/dist/core/forum-search.d.ts +35 -0
- package/dist/core/forum-state.d.ts +30 -0
- package/dist/core/forum-words.d.ts +8 -0
- package/dist/core/forum.d.ts +21 -0
- package/dist/core/moderation.d.ts +80 -0
- package/dist/core/onboarding.d.ts +49 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- package/dist/core/profile.d.ts +45 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +46 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +47 -1
- package/dist/global.d.ts +8 -4
- package/dist/index.d.ts +20 -3
- package/dist/index.js +62 -12
- package/dist/mount.d.ts +89 -0
- package/dist/react.d.ts +27 -3
- package/dist/react.js +48 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- package/dist/ui/FollowButtonView.vue.d.ts +17 -0
- package/dist/ui/FollowToggle.vue.d.ts +6 -0
- package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
- package/dist/ui/ForumModerationView.vue.d.ts +12 -0
- package/dist/ui/ForumNotices.vue.d.ts +7 -0
- package/dist/ui/ForumSearchView.vue.d.ts +11 -0
- package/dist/ui/ForumView.vue.d.ts +12 -0
- package/dist/ui/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +8 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +273 -3
- package/dist/vue.js +235 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +61 -2
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +497 -46
- package/src/core/follow.ts +139 -0
- package/src/core/format.ts +22 -0
- package/src/core/forum-category.ts +323 -0
- package/src/core/forum-search.ts +153 -0
- package/src/core/forum-state.ts +59 -0
- package/src/core/forum-words.ts +38 -0
- package/src/core/forum.ts +83 -0
- package/src/core/moderation.ts +414 -0
- package/src/core/onboarding.ts +177 -18
- package/src/core/outcomes.ts +23 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +155 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +52 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/core/topic.ts +234 -0
- package/src/element.ts +124 -6
- package/src/global.ts +40 -4
- package/src/index.ts +84 -2
- package/src/mount.ts +250 -1
- package/src/react.ts +90 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- package/src/ui/FollowButtonView.vue +85 -0
- package/src/ui/FollowToggle.vue +39 -0
- package/src/ui/ForumCategoryView.vue +243 -0
- package/src/ui/ForumModerationView.vue +309 -0
- package/src/ui/ForumNotices.vue +23 -0
- package/src/ui/ForumSearchView.vue +171 -0
- package/src/ui/ForumView.vue +128 -0
- package/src/ui/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +64 -11
- package/src/ui/MessageItem.vue +36 -7
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +12 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +193 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +102 -0
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +30 -0
- package/src/vue.ts +271 -3
- package/dist/chunks/mount-DzjwnHyx.js +0 -4597
package/src/core/onboarding.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
* minimum kb-api stores nothing and the reader is told from what age the discussion is open;
|
|
8
8
|
* rules the rules' summary (`legal/current` → `bodyJson`, text only) with a link to the full text, and
|
|
9
9
|
* the acceptance;
|
|
10
|
-
* art9 the art. 9 consent's text and the acceptance, when the tenant asks for it
|
|
10
|
+
* art9 the art. 9 consent's text and the acceptance, when the tenant asks for it — or, M3, before a forum write,
|
|
11
|
+
* whatever the tenant's setting says (`forceArt9`: m3-contracts.md §3.1 step 5, U14) — (M2 T9, m2-contracts.md §3.8:
|
|
12
|
+
* the layout from me/get's `settings.art9ConsentLayout` — `joint` one consent, `split` one per controller of
|
|
13
|
+
* the text's `bodyJson.controllers` when it names two, all of them required, the text itself shown once —
|
|
14
|
+
* and, where `settings.art9Refusal` is `public_write`, a refusal that joins without the consent: kb-api then
|
|
15
|
+
* opens the public discussion to the member, so `art9Refused` goes instead of the text id; under `read_only`
|
|
16
|
+
* no refusal is offered and a reader who closes the step keeps reading);
|
|
11
17
|
* profile the display name, the handle (kb-api makes one when none is given; the element shows what it
|
|
12
18
|
* would be), an avatar preset.
|
|
13
19
|
*
|
|
@@ -21,9 +27,14 @@
|
|
|
21
27
|
*/
|
|
22
28
|
import { reactive } from 'vue'
|
|
23
29
|
import {
|
|
30
|
+
art9ConsentLayoutOf,
|
|
31
|
+
art9ControllersOf,
|
|
32
|
+
art9RefusalOf,
|
|
24
33
|
communityRulesOf,
|
|
25
34
|
isKnownNeed,
|
|
26
35
|
type AgeBand,
|
|
36
|
+
type Art9ConsentLayout,
|
|
37
|
+
type Art9Refusal,
|
|
27
38
|
type CommunityCallOptions,
|
|
28
39
|
type CommunityClient,
|
|
29
40
|
type CommunityFailure,
|
|
@@ -32,7 +43,8 @@ import {
|
|
|
32
43
|
type MyState,
|
|
33
44
|
type OnboardInput,
|
|
34
45
|
} from '@blogmcp/sdk/community'
|
|
35
|
-
import type { Lang } from './i18n'
|
|
46
|
+
import type { Lang, TextRef } from './i18n'
|
|
47
|
+
import { failureText } from './outcomes'
|
|
36
48
|
import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName, suggestedHandle } from './names'
|
|
37
49
|
import { htmlToParagraphs } from './text'
|
|
38
50
|
|
|
@@ -40,6 +52,15 @@ export type OnboardingStep = 'age' | 'rules' | 'art9' | 'profile'
|
|
|
40
52
|
|
|
41
53
|
export type OnboardingPhase = 'loading' | 'ready' | 'sending' | 'done' | 'failed' | 'refused'
|
|
42
54
|
|
|
55
|
+
/** One consent of the art. 9 step: a controller the text names (or the tenant, when it names none) and its tick. */
|
|
56
|
+
export interface Art9Consent {
|
|
57
|
+
/** The controller's registry ref from the text's `bodyJson`; empty for the tenant of a text that names none. */
|
|
58
|
+
readonly entity: string
|
|
59
|
+
/** The name the reader sees; empty for the tenant of a text that names none (the step's own sentence then). */
|
|
60
|
+
readonly label: string
|
|
61
|
+
accepted: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
export interface OnboardingState {
|
|
44
65
|
/** `refused`: the age band is below the minimum — nothing was stored, the reader may go on reading. */
|
|
45
66
|
phase: OnboardingPhase
|
|
@@ -50,10 +71,24 @@ export interface OnboardingState {
|
|
|
50
71
|
rules: CommunityRule[]
|
|
51
72
|
art9Text: CurrentLegalText | null
|
|
52
73
|
art9Paragraphs: string[]
|
|
74
|
+
/** me/get's `settings.art9ConsentLayout` (absent: `joint`). */
|
|
75
|
+
art9Layout: Art9ConsentLayout
|
|
76
|
+
/** me/get's `settings.art9Refusal` (absent: `read_only`). */
|
|
77
|
+
art9Refusal: Art9Refusal
|
|
78
|
+
/**
|
|
79
|
+
* The consents the step asks for, each ticked on its own: one under `joint` (or a text naming fewer than two
|
|
80
|
+
* controllers), the text's two controllers under `split`. Empty while there is no art. 9 text.
|
|
81
|
+
*/
|
|
82
|
+
art9Controllers: Art9Consent[]
|
|
83
|
+
/** The reader chose „Nesouhlasím" (only where the refusal is offered). */
|
|
84
|
+
art9Refused: boolean
|
|
85
|
+
/** The me/onboard kb-api took carried the refusal (the done note says what it leaves). */
|
|
86
|
+
art9RefusedSent: boolean
|
|
53
87
|
/** The member accepted an older rules text; this one asks again. */
|
|
54
88
|
reconsent: boolean
|
|
55
89
|
ageBand: AgeBand | null
|
|
56
90
|
rulesAccepted: boolean
|
|
91
|
+
/** Every consent of the step ticked (setting it ticks or unticks them all). */
|
|
57
92
|
art9Accepted: boolean
|
|
58
93
|
displayName: string
|
|
59
94
|
handle: string
|
|
@@ -76,6 +111,15 @@ export interface OnboardingModel {
|
|
|
76
111
|
back(): void
|
|
77
112
|
suggestedHandle(): string | null
|
|
78
113
|
hasFieldError(field: string): boolean
|
|
114
|
+
/**
|
|
115
|
+
* The tenant takes a refusal of the art. 9 consent here (`art9Refusal = public_write`, not the give-consent flow): the
|
|
116
|
+
* step's lede then does not make writing depend on the consent (community.md §9.8 q8, art. 9(2)(a) — freely given).
|
|
117
|
+
*/
|
|
118
|
+
refusalOffered(): boolean
|
|
119
|
+
/** The art. 9 step offers „Nesouhlasím" (`art9Refusal = public_write`, never in the give-consent flow). */
|
|
120
|
+
canRefuse(): boolean
|
|
121
|
+
/** „Nesouhlasím": the step is answered without the consent; the next step, or — on the last one — `me/onboard`. */
|
|
122
|
+
refuse(): Promise<void>
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
export interface OnboardingOptions {
|
|
@@ -87,22 +131,61 @@ export interface OnboardingOptions {
|
|
|
87
131
|
onSignIn(): void
|
|
88
132
|
/** The age band was below the tenant's minimum (AGE_BELOW_MINIMUM): kb-api kept nothing, the element keeps this. */
|
|
89
133
|
onRefused?(minAge: number): void
|
|
134
|
+
/**
|
|
135
|
+
* The give-consent flow („Moje komunita", M2 T9): the art. 9 step is asked also when `needs` does not name it — a
|
|
136
|
+
* member who refused it, or has not given it, where the tenant asks for it — and no refusal is offered.
|
|
137
|
+
*/
|
|
138
|
+
offerArt9?: boolean
|
|
139
|
+
/**
|
|
140
|
+
* M3 (m3-contracts.md §3.1 step 5, U14): a forum write needs the art. 9 consent whatever the tenant's
|
|
141
|
+
* `require_art9_consent` says, so the step is asked whenever the consent is not in force — me/get names no
|
|
142
|
+
* `art9_consent` need (and may report the consent as null) where the tenant does not ask for it elsewhere. A refusal
|
|
143
|
+
* is offered as for the discussion (`art9Refusal = public_write`), and a member who refused there is not asked again.
|
|
144
|
+
*/
|
|
145
|
+
forceArt9?: boolean
|
|
90
146
|
signal?: AbortSignal
|
|
91
147
|
}
|
|
92
148
|
|
|
93
149
|
const PROFILE_FIELDS = new Set(['displayName', 'handle', 'avatarPreset'])
|
|
94
150
|
const TEXT_FIELDS = new Set(['rulesTextId', 'art9TextId'])
|
|
151
|
+
/** kb-api's issue code for a refusal the tenant does not take (`art9Refusal = read_only`, §3.8). */
|
|
152
|
+
export const ART9_REFUSAL_READ_ONLY = 'art9_refusal_read_only'
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A refusal of the art. 9 refusal itself: the tenant's setting is `read_only` (changed meanwhile, or a stale page) —
|
|
156
|
+
* kb-api's issue code, never the path alone. A path-only `art9Refused` is kb-api's „a consent is already in force"
|
|
157
|
+
* (given in another tab): a plain reread with the generic sentence.
|
|
158
|
+
*/
|
|
159
|
+
const refusalRefused = (f: Pick<CommunityFailure, 'code' | 'issueCodes'>): boolean =>
|
|
160
|
+
f.code === 'invalid' && f.issueCodes?.includes(ART9_REFUSAL_READ_ONLY) === true
|
|
161
|
+
|
|
162
|
+
/** The sentence a refusal of the onboarding shows: a refused refusal says the consent is needed to write. */
|
|
163
|
+
export function onboardingFailureText(failure: Pick<CommunityFailure, 'code' | 'until' | 'fields' | 'issueCodes'>, lang: Lang): TextRef {
|
|
164
|
+
return refusalRefused(failure) ? { key: 'onboarding.art9RequiredToWrite' } : failureText(failure, lang)
|
|
165
|
+
}
|
|
95
166
|
|
|
96
167
|
/** What is still missing for this member, in order (MyState and its known needs). */
|
|
97
|
-
function stepsOf(me: MyState, needs: readonly string[]): OnboardingStep[] {
|
|
168
|
+
function stepsOf(me: MyState, needs: readonly string[], offerArt9: boolean, forceArt9: boolean): OnboardingStep[] {
|
|
98
169
|
const steps: OnboardingStep[] = []
|
|
99
170
|
if (!me.member?.ageBand) steps.push('age')
|
|
100
171
|
if (me.acceptances.rules !== 'accepted') steps.push('rules')
|
|
101
|
-
|
|
172
|
+
const art9 = me.acceptances.art9Consent
|
|
173
|
+
// A forum write: the consent, unless it is in force or refused where a refusal leaves public writes (kb-api's needs).
|
|
174
|
+
const refusedOpen = me.acceptances.art9Refused === true && art9RefusalOf(me.settings?.art9Refusal) === 'public_write'
|
|
175
|
+
const forced = forceArt9 && art9 !== 'accepted' && !refusedOpen
|
|
176
|
+
if (needs.includes('art9_consent') || (offerArt9 && art9 !== null && art9 !== 'accepted') || forced) steps.push('art9')
|
|
102
177
|
if (!me.profile) steps.push('profile')
|
|
103
178
|
return steps
|
|
104
179
|
}
|
|
105
180
|
|
|
181
|
+
/** The consents the step asks for: the text's two controllers under `split`, else one (its first, or the tenant). */
|
|
182
|
+
function consentsOf(text: CurrentLegalText | null, layout: Art9ConsentLayout): Array<{ entity: string; label: string }> {
|
|
183
|
+
if (!text) return []
|
|
184
|
+
const listed = art9ControllersOf(text.bodyJson)
|
|
185
|
+
if (layout === 'split' && listed.length > 1) return listed.map((c) => ({ entity: c.entity, label: c.label }))
|
|
186
|
+
return [{ entity: listed[0]?.entity ?? '', label: listed.length > 1 ? '' : (listed[0]?.label ?? '') }]
|
|
187
|
+
}
|
|
188
|
+
|
|
106
189
|
/** The texts of the steps shown. */
|
|
107
190
|
interface Texts {
|
|
108
191
|
rules: CurrentLegalText | null
|
|
@@ -118,6 +201,8 @@ interface Read {
|
|
|
118
201
|
|
|
119
202
|
export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
120
203
|
const call: CommunityCallOptions = o.signal ? { signal: o.signal } : {}
|
|
204
|
+
const offerArt9 = o.offerArt9 === true
|
|
205
|
+
const forceArt9 = o.forceArt9 === true && !offerArt9
|
|
121
206
|
const state = reactive<OnboardingState>({
|
|
122
207
|
phase: 'loading',
|
|
123
208
|
steps: [],
|
|
@@ -127,10 +212,21 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
127
212
|
rules: [],
|
|
128
213
|
art9Text: null,
|
|
129
214
|
art9Paragraphs: [],
|
|
215
|
+
art9Layout: 'joint',
|
|
216
|
+
art9Refusal: 'read_only',
|
|
217
|
+
art9Controllers: [],
|
|
218
|
+
art9Refused: false,
|
|
219
|
+
art9RefusedSent: false,
|
|
130
220
|
reconsent: false,
|
|
131
221
|
ageBand: null,
|
|
132
222
|
rulesAccepted: false,
|
|
133
|
-
|
|
223
|
+
// Read through the reactive proxy (`this`), so a view's v-model ticks every consent and the guard sees them all.
|
|
224
|
+
get art9Accepted(): boolean {
|
|
225
|
+
return this.art9Controllers.length > 0 && this.art9Controllers.every((c) => c.accepted)
|
|
226
|
+
},
|
|
227
|
+
set art9Accepted(value: boolean) {
|
|
228
|
+
for (const c of this.art9Controllers) c.accepted = value
|
|
229
|
+
},
|
|
134
230
|
displayName: '',
|
|
135
231
|
handle: '',
|
|
136
232
|
avatarPreset: null,
|
|
@@ -171,15 +267,27 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
171
267
|
o.onSignIn()
|
|
172
268
|
return null
|
|
173
269
|
}
|
|
174
|
-
const steps = stepsOf(me.value, needs)
|
|
270
|
+
const steps = stepsOf(me.value, needs, offerArt9, forceArt9)
|
|
175
271
|
const texts = await textsFor(steps)
|
|
176
272
|
return texts ? { me: me.value, steps, texts } : null
|
|
177
273
|
}
|
|
178
274
|
|
|
275
|
+
/** The refusal is offered: the tenant takes it (`public_write`), and this is not the give-consent flow. */
|
|
276
|
+
const refusalOffered = (): boolean => !offerArt9 && state.art9Refusal === 'public_write'
|
|
277
|
+
|
|
179
278
|
/** What was read, shown at once; an acceptance holds only for the very text it was given to. */
|
|
180
279
|
function apply(r: Read): void {
|
|
181
280
|
if (r.texts.rules?.textId !== state.rulesText?.textId) state.rulesAccepted = false
|
|
182
|
-
|
|
281
|
+
const layout = art9ConsentLayoutOf(r.me.settings?.art9ConsentLayout)
|
|
282
|
+
const sameText = r.texts.art9 !== null && r.texts.art9.textId === state.art9Text?.textId
|
|
283
|
+
// A tick holds for the same controller of the same text; anything else is asked again.
|
|
284
|
+
state.art9Controllers = consentsOf(r.texts.art9, layout).map((c, i) => {
|
|
285
|
+
const before = state.art9Controllers[i]
|
|
286
|
+
return { ...c, accepted: sameText && before?.entity === c.entity && before.accepted === true }
|
|
287
|
+
})
|
|
288
|
+
state.art9Layout = layout
|
|
289
|
+
state.art9Refusal = art9RefusalOf(r.me.settings?.art9Refusal)
|
|
290
|
+
if (!sameText || !refusalOffered()) state.art9Refused = false
|
|
183
291
|
state.me = r.me
|
|
184
292
|
state.steps = r.steps
|
|
185
293
|
state.reconsent = r.me.acceptances.rules === 'reconsent_required'
|
|
@@ -196,8 +304,17 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
196
304
|
if (!r) return
|
|
197
305
|
apply(r)
|
|
198
306
|
state.index = 0
|
|
199
|
-
|
|
200
|
-
|
|
307
|
+
if (r.steps.length === 0) {
|
|
308
|
+
// The give-consent flow with no consent left to ask (given in another tab, or the tenant stopped asking for it):
|
|
309
|
+
// nothing is sent — an empty me/onboard would be no consent — and the caller reads the state it is handed.
|
|
310
|
+
if (offerArt9) {
|
|
311
|
+
state.phase = 'done'
|
|
312
|
+
o.onDone(r.me)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
// Nothing left to ask (another tab finished it): kb-api's answer settles the needs.
|
|
316
|
+
return submit()
|
|
317
|
+
}
|
|
201
318
|
state.phase = 'ready'
|
|
202
319
|
}
|
|
203
320
|
|
|
@@ -211,7 +328,7 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
211
328
|
case 'rules':
|
|
212
329
|
return state.rulesAccepted && state.rulesText !== null
|
|
213
330
|
case 'art9':
|
|
214
|
-
return state.
|
|
331
|
+
return state.art9Text !== null && (state.art9Accepted || (state.art9Refused && refusalOffered()))
|
|
215
332
|
case 'profile':
|
|
216
333
|
return isDisplayNameOk(state.displayName) && isHandleOk(state.handle)
|
|
217
334
|
}
|
|
@@ -219,7 +336,11 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
219
336
|
|
|
220
337
|
function canContinue(): boolean {
|
|
221
338
|
const s = step()
|
|
222
|
-
|
|
339
|
+
if (state.phase !== 'ready' || s === null) return false
|
|
340
|
+
// „Pokračovat" on the art. 9 step means the consent: a refusal goes only through „Nesouhlasím" (refuse()), never
|
|
341
|
+
// silently through the primary button.
|
|
342
|
+
if (s === 'art9') return state.art9Text !== null && state.art9Accepted
|
|
343
|
+
return answered(s)
|
|
223
344
|
}
|
|
224
345
|
|
|
225
346
|
function goTo(s: OnboardingStep): void {
|
|
@@ -246,7 +367,15 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
246
367
|
ageBand: state.ageBand ?? state.me?.member?.ageBand ?? '18_plus',
|
|
247
368
|
rulesTextId: state.steps.includes('rules') && state.rulesText ? state.rulesText.textId : '',
|
|
248
369
|
}
|
|
249
|
-
|
|
370
|
+
// A consent ticked wins over a refusal chosen before (the reader came back and ticked it).
|
|
371
|
+
let refusing = false
|
|
372
|
+
if (state.steps.includes('art9') && state.art9Text) {
|
|
373
|
+
if (state.art9Accepted) input.art9TextId = state.art9Text.textId
|
|
374
|
+
else if (state.art9Refused && refusalOffered()) {
|
|
375
|
+
input.art9Refused = true
|
|
376
|
+
refusing = true
|
|
377
|
+
}
|
|
378
|
+
}
|
|
250
379
|
if (state.steps.includes('profile')) {
|
|
251
380
|
input.displayName = normalizeName(state.displayName)
|
|
252
381
|
const handle = normalizeHandle(state.handle)
|
|
@@ -255,6 +384,7 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
255
384
|
}
|
|
256
385
|
const r = await o.client.me.onboard(input, call)
|
|
257
386
|
if (r.ok) {
|
|
387
|
+
state.art9RefusedSent = refusing
|
|
258
388
|
state.phase = 'done'
|
|
259
389
|
o.onDone(r.value)
|
|
260
390
|
return
|
|
@@ -276,6 +406,16 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
276
406
|
return goTo('profile')
|
|
277
407
|
case 'invalid': {
|
|
278
408
|
state.fields = r.fields ?? []
|
|
409
|
+
// The tenant takes no refusal (now): the settings are read again, the consent asked, the reader told why.
|
|
410
|
+
if (refusalRefused(r)) {
|
|
411
|
+
state.art9Refused = false
|
|
412
|
+
return reread(r)
|
|
413
|
+
}
|
|
414
|
+
// A refusal kb-api did not take because a consent is in force already (another tab): read again.
|
|
415
|
+
if (state.fields.includes('art9Refused')) {
|
|
416
|
+
state.art9Refused = false
|
|
417
|
+
return reread(r)
|
|
418
|
+
}
|
|
279
419
|
if (state.fields.some((f) => PROFILE_FIELDS.has(f))) return goTo('profile')
|
|
280
420
|
if (state.fields.some((f) => TEXT_FIELDS.has(f))) return reread(r)
|
|
281
421
|
if (state.fields.includes('ageBand')) return goTo('age')
|
|
@@ -319,6 +459,18 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
319
459
|
fail(refusal)
|
|
320
460
|
}
|
|
321
461
|
|
|
462
|
+
/** On to the next step, or — on the last one — `me/onboard`. */
|
|
463
|
+
async function advance(): Promise<void> {
|
|
464
|
+
state.failure = null
|
|
465
|
+
if (state.index < state.steps.length - 1) {
|
|
466
|
+
state.index += 1
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
await submit()
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const canRefuse = (): boolean => state.phase === 'ready' && step() === 'art9' && state.art9Text !== null && refusalOffered()
|
|
473
|
+
|
|
322
474
|
return {
|
|
323
475
|
state,
|
|
324
476
|
load,
|
|
@@ -327,17 +479,24 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
327
479
|
canContinue,
|
|
328
480
|
async next() {
|
|
329
481
|
if (!canContinue()) return
|
|
330
|
-
|
|
331
|
-
if (state.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
482
|
+
// The consent given: a refusal chosen before on this step no longer stands.
|
|
483
|
+
if (step() === 'art9' && state.art9Accepted) state.art9Refused = false
|
|
484
|
+
await advance()
|
|
485
|
+
},
|
|
486
|
+
canRefuse,
|
|
487
|
+
async refuse() {
|
|
488
|
+
if (!canRefuse()) return
|
|
489
|
+
state.art9Accepted = false
|
|
490
|
+
state.art9Refused = true
|
|
491
|
+
await advance()
|
|
336
492
|
},
|
|
493
|
+
refusalOffered,
|
|
337
494
|
back() {
|
|
338
495
|
if (state.phase !== 'ready' || state.index === 0) return
|
|
339
496
|
state.failure = null
|
|
340
497
|
state.index -= 1
|
|
498
|
+
// Back on the art. 9 step: the reader chooses again — a refusal chosen before does not stand unseen.
|
|
499
|
+
if (step() === 'art9') state.art9Refused = false
|
|
341
500
|
},
|
|
342
501
|
suggestedHandle: () => (normalizeHandle(state.handle) === '' ? suggestedHandle(state.displayName) : null),
|
|
343
502
|
hasFieldError: (field) => state.fields.includes(field),
|
package/src/core/outcomes.ts
CHANGED
|
@@ -36,7 +36,8 @@ export const COUNTDOWN_MAX_S = 120
|
|
|
36
36
|
* - `onboard` the inline onboarding opens (ONBOARDING_REQUIRED);
|
|
37
37
|
* - `reload` the discussion is read again (the viewer's state changed: a sanction, a session);
|
|
38
38
|
* - `notMoved` the discussion does not take writes yet: one sentence instead of the composer;
|
|
39
|
-
* - `hide` discussions are switched off: the element renders nothing
|
|
39
|
+
* - `hide` discussions are switched off: the element renders nothing (`realtimeOff`: only live updates are —
|
|
40
|
+
* the element keeps polling and never hides the discussion for it);
|
|
40
41
|
* - `retry` nothing was decided (no answer, the ledger): the same write may go again as it is;
|
|
41
42
|
* - `show` anything else: the sentence, the text stays for the author to change.
|
|
42
43
|
*/
|
|
@@ -66,6 +67,27 @@ const EFFECTS: Readonly<Record<CommunityUiCode, FailureEffect>> = Object.freeze(
|
|
|
66
67
|
surfaceOff: 'hide',
|
|
67
68
|
notMoved: 'notMoved',
|
|
68
69
|
ledger: 'retry',
|
|
70
|
+
// M2 (m2-contracts.md §3.1): a pause refuses every member write until it ends (`until`) — the write waits and the
|
|
71
|
+
// composer gives way to `state.braked`, and me/get is read again for the brake's end (reads go on).
|
|
72
|
+
braked: 'wait',
|
|
73
|
+
// A Pro feature of a module that went read_only: the member's state changed under the page — read it again.
|
|
74
|
+
readOnly: 'reload',
|
|
75
|
+
// Too many @mentions for the member's level: the sentence, and the text stays for the author to trim.
|
|
76
|
+
mentionLimit: 'show',
|
|
77
|
+
// A browser whose push service kb-api will not send to: the sentence; the switch stays off (the settings element).
|
|
78
|
+
pushRefused: 'show',
|
|
79
|
+
// Live updates are off here: nothing to show the reader — the discussion keeps polling (the realtime session's fallback).
|
|
80
|
+
realtimeOff: 'hide',
|
|
81
|
+
// M3 (m3-contracts.md §1.2): the forum is not open to this viewer — read again, and the read's answer is the
|
|
82
|
+
// members-only state with its sign-in or onboarding (`needs`); no write of a topic answers it (a topic id's refusals
|
|
83
|
+
// are its `notFound`, A11).
|
|
84
|
+
membersOnly: 'reload',
|
|
85
|
+
// The topic was closed meanwhile: read again — the topic's state closes the composer (`error.topicLocked` until then).
|
|
86
|
+
topicLocked: 'reload',
|
|
87
|
+
// A category that takes no topic from the member: the sentence, and the category's composer goes.
|
|
88
|
+
topicCreationClosed: 'show',
|
|
89
|
+
// Following as much as one member may: the sentence (the member unfollows something first).
|
|
90
|
+
followLimit: 'show',
|
|
69
91
|
submit: 'show',
|
|
70
92
|
} satisfies Record<CommunityUiCode, FailureEffect>)
|
|
71
93
|
|
package/src/core/panel.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `<blogmcp-my-community>`'s brain (m1-contracts.md §7.4, §8.3, §10.4; community.md §5): the reader's
|
|
3
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.
|
|
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
6
|
*
|
|
7
7
|
* With a decision id it shows that one statement (the host's statement page). With a notifier's receipt it
|
|
8
8
|
* shows that notice's status and appeal — `notices/status`, `notices/appeal`: no session needed for that.
|
|
9
9
|
*/
|
|
10
|
-
import { reactive } from 'vue'
|
|
10
|
+
import { reactive, shallowRef, type ShallowRef } from 'vue'
|
|
11
11
|
import {
|
|
12
12
|
COMMUNITY_APPEAL_MAX,
|
|
13
13
|
isKnownNotificationCode,
|
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
type ReportItem,
|
|
26
26
|
type Statement,
|
|
27
27
|
} from '@blogmcp/sdk/community'
|
|
28
|
-
import type
|
|
28
|
+
import { pickLang, type TextRef } from './i18n'
|
|
29
|
+
import { createOnboarding, type OnboardingModel } from './onboarding'
|
|
29
30
|
import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName } from './names'
|
|
30
31
|
import { codePointLength, normalizeBody } from './text'
|
|
31
32
|
|
|
@@ -71,6 +72,8 @@ export interface PanelUi {
|
|
|
71
72
|
|
|
72
73
|
export interface PanelController {
|
|
73
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>
|
|
74
77
|
/** The member's sections (me/get and the lists). */
|
|
75
78
|
loadMember(): Promise<void>
|
|
76
79
|
loadStatement(decisionId: string): Promise<void>
|
|
@@ -82,6 +85,9 @@ export interface PanelController {
|
|
|
82
85
|
fileNoticeAppeal(): Promise<void>
|
|
83
86
|
withdrawNoticeAppeal(): Promise<void>
|
|
84
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
|
|
85
91
|
unblock(memberId: string): Promise<void>
|
|
86
92
|
editProfile(): void
|
|
87
93
|
cancelProfile(): void
|
|
@@ -127,6 +133,7 @@ export function createPanel(o: PanelOptions): PanelController {
|
|
|
127
133
|
announceId: 0,
|
|
128
134
|
}) as PanelUi
|
|
129
135
|
let disposed = false
|
|
136
|
+
const consent = shallowRef<OnboardingModel | null>(null)
|
|
130
137
|
|
|
131
138
|
const announce = (text: TextRef): void => {
|
|
132
139
|
state.announcement = text
|
|
@@ -225,8 +232,35 @@ export function createPanel(o: PanelOptions): PanelController {
|
|
|
225
232
|
const appealLength = (d: AppealDraft): number => codePointLength(normalizeBody(d.body))
|
|
226
233
|
const canAppealWith = (d: AppealDraft): boolean => !d.sending && appealLength(d) > 0 && appealLength(d) <= COMMUNITY_APPEAL_MAX
|
|
227
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
|
+
|
|
228
257
|
return {
|
|
229
258
|
state,
|
|
259
|
+
consent,
|
|
260
|
+
giveConsent,
|
|
261
|
+
closeConsent() {
|
|
262
|
+
consent.value = null
|
|
263
|
+
},
|
|
230
264
|
loadMember,
|
|
231
265
|
loadStatement,
|
|
232
266
|
async loadNotice(receipt) {
|
|
@@ -406,6 +440,7 @@ export function createPanel(o: PanelOptions): PanelController {
|
|
|
406
440
|
dispose() {
|
|
407
441
|
disposed = true
|
|
408
442
|
abort.abort()
|
|
443
|
+
consent.value = null
|
|
409
444
|
},
|
|
410
445
|
}
|
|
411
446
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
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 ForumCategoryRef,
|
|
22
|
+
type KnownAuthor,
|
|
23
|
+
type PublicProfile,
|
|
24
|
+
} from '@blogmcp/sdk/community'
|
|
25
|
+
|
|
26
|
+
export type LoadPhase = 'idle' | 'loading' | 'ready' | 'failed' | 'signIn' | 'notFound'
|
|
27
|
+
|
|
28
|
+
export interface ProfileUi {
|
|
29
|
+
phase: LoadPhase
|
|
30
|
+
failure: CommunityFailure | null
|
|
31
|
+
author: KnownAuthor | null
|
|
32
|
+
memberSince: string | null
|
|
33
|
+
bio: string | null
|
|
34
|
+
/** The trust level's catalogue key (`trust.<n>`), or null for a label this SDK does not know. */
|
|
35
|
+
trustKey: CommunityMessageKey | null
|
|
36
|
+
/**
|
|
37
|
+
* M3 (m3-contracts.md §3.5, §4.5; D-M3-7): the categories the member moderates now — „Moderátor" names this
|
|
38
|
+
* appointment on the profile, never a trust level; kb-api sends it to a forum member only (else none).
|
|
39
|
+
*/
|
|
40
|
+
moderates: ForumCategoryRef[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ProfileController {
|
|
44
|
+
readonly state: ProfileUi
|
|
45
|
+
load(handle: string): Promise<void>
|
|
46
|
+
dispose(): void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A handle as an attribute may give it (`@jana_n`, `Jana_N`): lower case, without the `@`; null for none. */
|
|
50
|
+
export function handleOf(value: unknown): string | null {
|
|
51
|
+
if (typeof value !== 'string') return null
|
|
52
|
+
const h = value.trim().replace(/^@/, '').toLowerCase()
|
|
53
|
+
return COMMUNITY_HANDLE_RE.test(h) ? h : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createProfile(o: { client: CommunityClient }): ProfileController {
|
|
57
|
+
const { client } = o
|
|
58
|
+
const abort = new AbortController()
|
|
59
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
60
|
+
let disposed = false
|
|
61
|
+
let ticket = 0
|
|
62
|
+
const state = reactive<ProfileUi>({ phase: 'idle', failure: null, author: null, memberSince: null, bio: null, trustKey: null, moderates: [] }) as ProfileUi
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
state,
|
|
66
|
+
async load(handle) {
|
|
67
|
+
const mine = ++ticket
|
|
68
|
+
state.failure = null
|
|
69
|
+
state.author = null
|
|
70
|
+
state.memberSince = null
|
|
71
|
+
state.bio = null
|
|
72
|
+
state.trustKey = null
|
|
73
|
+
state.moderates = []
|
|
74
|
+
const h = handleOf(handle)
|
|
75
|
+
if (h === null) {
|
|
76
|
+
state.phase = 'notFound'
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
state.phase = 'loading'
|
|
80
|
+
const r = await client.profile.get({ handle: h }, call)
|
|
81
|
+
if (disposed || mine !== ticket) return
|
|
82
|
+
if (!r.ok) {
|
|
83
|
+
state.phase = r.code === 'signIn' ? 'signIn' : r.code === 'notFound' ? 'notFound' : 'failed'
|
|
84
|
+
state.failure = r
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
const p: PublicProfile = r.value
|
|
88
|
+
// A profile is a member's; an author of another kind (or one a newer kb-api added) is no profile to show.
|
|
89
|
+
if (!isKnownAuthor(p.author) || p.author.kind !== 'member' || p.author.memberId === null) {
|
|
90
|
+
state.phase = 'notFound'
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
state.author = p.author
|
|
94
|
+
state.memberSince = typeof p.memberSince === 'string' ? p.memberSince : null
|
|
95
|
+
state.bio = typeof p.bio === 'string' && p.bio.trim() !== '' ? p.bio : null
|
|
96
|
+
state.trustKey = trustLabelMessageKey(p.trustLabel)
|
|
97
|
+
// M3: the appointments acting now (a category's slug and name each; anything else is skipped).
|
|
98
|
+
state.moderates = Array.isArray(p.moderates)
|
|
99
|
+
? p.moderates.filter((c): c is ForumCategoryRef => typeof c?.slug === 'string' && typeof c?.name === 'string').map((c) => ({ slug: c.slug, name: c.name }))
|
|
100
|
+
: []
|
|
101
|
+
state.phase = 'ready'
|
|
102
|
+
},
|
|
103
|
+
dispose() {
|
|
104
|
+
disposed = true
|
|
105
|
+
abort.abort()
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface WelcomeUi {
|
|
111
|
+
phase: 'idle' | 'loading' | 'ready'
|
|
112
|
+
rules: CommunityRule[]
|
|
113
|
+
/** The rules could not be read: the page still links them. */
|
|
114
|
+
rulesFailed: boolean
|
|
115
|
+
/** The signed-in member's handle (the profile link), or null for a visitor and a reader who never joined. */
|
|
116
|
+
handle: string | null
|
|
117
|
+
/** Nobody is signed in: the sign-in link takes the profile link's place. */
|
|
118
|
+
visitor: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface WelcomeController {
|
|
122
|
+
readonly state: WelcomeUi
|
|
123
|
+
load(): Promise<void>
|
|
124
|
+
dispose(): void
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createWelcome(o: { client: CommunityClient; lang: string }): WelcomeController {
|
|
128
|
+
const { client } = o
|
|
129
|
+
const abort = new AbortController()
|
|
130
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
131
|
+
let disposed = false
|
|
132
|
+
const state = reactive<WelcomeUi>({ phase: 'idle', rules: [], rulesFailed: false, handle: null, visitor: false }) as WelcomeUi
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
state,
|
|
136
|
+
async load() {
|
|
137
|
+
state.phase = 'loading'
|
|
138
|
+
const [rules, me] = await Promise.all([client.legal.current({ kind: 'rules', locale: o.lang }, call), client.me.get(call)])
|
|
139
|
+
if (disposed) return
|
|
140
|
+
if (rules.ok) state.rules = communityRulesOf(rules.value.bodyJson)
|
|
141
|
+
else state.rulesFailed = true
|
|
142
|
+
if (me.ok) {
|
|
143
|
+
state.visitor = me.value.needs.includes('sign_in')
|
|
144
|
+
state.handle = me.value.member ? handleOf(me.value.profile?.handle) : null
|
|
145
|
+
} else {
|
|
146
|
+
state.visitor = me.code === 'signIn'
|
|
147
|
+
}
|
|
148
|
+
state.phase = 'ready'
|
|
149
|
+
},
|
|
150
|
+
dispose() {
|
|
151
|
+
disposed = true
|
|
152
|
+
abort.abort()
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|