@blogmcp/community 0.2.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 +64 -12
- package/dist/blogmcp-community.global.js +7 -7
- package/dist/chunks/{mount-WadUnaTa.js → mount--2_V2q3M.js} +4724 -1097
- package/dist/core/attrs.d.ts +17 -2
- package/dist/core/discussion.d.ts +92 -18
- package/dist/core/follow.d.ts +26 -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 +7 -0
- package/dist/core/profile.d.ts +6 -1
- package/dist/core/realtime.d.ts +13 -2
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +26 -1
- package/dist/global.d.ts +7 -6
- package/dist/index.d.ts +13 -4
- package/dist/index.js +54 -27
- package/dist/mount.d.ts +50 -1
- package/dist/react.d.ts +17 -4
- package/dist/react.js +32 -8
- 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/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +6 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +164 -5
- package/dist/vue.js +134 -8
- package/package.json +3 -3
- package/src/core/attrs.ts +22 -2
- package/src/core/discussion.ts +309 -43
- package/src/core/follow.ts +139 -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 +16 -4
- package/src/core/outcomes.ts +10 -0
- package/src/core/profile.ts +12 -1
- package/src/core/realtime.ts +15 -2
- package/src/core/topic.ts +234 -0
- package/src/element.ts +63 -1
- package/src/global.ts +27 -5
- package/src/index.ts +49 -2
- package/src/mount.ts +144 -2
- package/src/react.ts +45 -2
- 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/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +51 -9
- package/src/ui/MessageItem.vue +12 -1
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +3 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +37 -3
- package/src/ui/ProfileView.vue +15 -4
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +14 -0
- package/src/vue.ts +145 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forum's words for the discussion's components (m3-contracts.md §5.3): a topic's thread reuses the discussion's
|
|
3
|
+
* view — the same messages, composer, dialogs and refusals — and reads it with these keys in place of the
|
|
4
|
+
* discussion's, so a forum reads „příspěvek" (a post) where an article's discussion reads „komentář" (a comment). Every
|
|
5
|
+
* key is the SDK catalogue's in both languages; a plural maps by its name (test/forum-elements.spec.ts checks both).
|
|
6
|
+
*/
|
|
7
|
+
import type { CommunityMessageKey } from '@blogmcp/sdk/community'
|
|
8
|
+
|
|
9
|
+
export const FORUM_WORDS: Readonly<Partial<Record<CommunityMessageKey, CommunityMessageKey>>> = Object.freeze({
|
|
10
|
+
'composer.label': 'forumText.composer.label',
|
|
11
|
+
'composer.editLabel': 'forumText.composer.editLabel',
|
|
12
|
+
'composer.placeholder': 'forumText.composer.placeholder',
|
|
13
|
+
'composer.tooLong': 'forumText.composer.tooLong',
|
|
14
|
+
'composer.linksNote': 'forumText.composer.linksNote',
|
|
15
|
+
'composer.posted': 'forumText.composer.posted',
|
|
16
|
+
'composer.held': 'forumText.composer.held',
|
|
17
|
+
'composer.cooldown': 'forumText.composer.cooldown',
|
|
18
|
+
'discussion.empty': 'forumText.discussion.empty',
|
|
19
|
+
'discussion.deleteTitle': 'forumText.discussion.deleteTitle',
|
|
20
|
+
'discussion.deleteExplain': 'forumText.discussion.deleteExplain',
|
|
21
|
+
'discussion.collapsed': 'forumText.discussion.collapsed',
|
|
22
|
+
'discussion.heldNote': 'forumText.discussion.heldNote',
|
|
23
|
+
'discussion.hiddenUntilConsentNote': 'forumText.discussion.hiddenUntilConsentNote',
|
|
24
|
+
'discussion.removedNote': 'forumText.discussion.removedNote',
|
|
25
|
+
'discussion.placeholderHeld': 'forumText.discussion.placeholderHeld',
|
|
26
|
+
'discussion.placeholderRemoved': 'forumText.discussion.placeholderRemoved',
|
|
27
|
+
'discussion.placeholderDeleted': 'forumText.discussion.placeholderDeleted',
|
|
28
|
+
'report.title': 'forumText.report.title',
|
|
29
|
+
'report.category': 'forumText.report.category',
|
|
30
|
+
'error.links': 'forumText.error.links',
|
|
31
|
+
'error.blocked': 'forumText.error.blocked',
|
|
32
|
+
'error.confirm': 'forumText.error.confirm',
|
|
33
|
+
'confirm.other': 'forumText.confirm.other',
|
|
34
|
+
'warning.identifier': 'forumText.warning.identifier',
|
|
35
|
+
'warning.other': 'forumText.warning.other',
|
|
36
|
+
'need.onboarding': 'forumText.need.onboarding',
|
|
37
|
+
'need.art9_consent': 'forumText.need.art9_consent',
|
|
38
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-forum>`'s brain (m3-contracts.md §3.2, §5.3): the listed categories in the team's order
|
|
3
|
+
* (`forum/categories/list`), each with its latest topics (`forum/topics/list`, the activity order, a few rows of the
|
|
4
|
+
* first categories), and `me/get` for the notices (away mode, the forum's pause). The forum is for its members:
|
|
5
|
+
* anyone else gets `membersOnly` (with `needs` for a member session short of its onboarding); a forum switched off
|
|
6
|
+
* renders nothing.
|
|
7
|
+
*/
|
|
8
|
+
import { reactive } from 'vue'
|
|
9
|
+
import {
|
|
10
|
+
knownTopicItemsOf,
|
|
11
|
+
type CommunityCallOptions,
|
|
12
|
+
type CommunityClient,
|
|
13
|
+
type CommunityFailure,
|
|
14
|
+
type ForumCategory,
|
|
15
|
+
type KnownTopicItem,
|
|
16
|
+
type MyState,
|
|
17
|
+
} from '@blogmcp/sdk/community'
|
|
18
|
+
|
|
19
|
+
export type ForumPhase = 'loading' | 'ready' | 'failed' | 'hidden' | 'membersOnly'
|
|
20
|
+
|
|
21
|
+
/** The categories whose latest topics the index shows, and how many of each (one request a category). */
|
|
22
|
+
export const FORUM_LATEST_CATEGORIES = 10
|
|
23
|
+
export const FORUM_LATEST_PER_CATEGORY = 3
|
|
24
|
+
|
|
25
|
+
export interface ForumUi {
|
|
26
|
+
phase: ForumPhase
|
|
27
|
+
failure: CommunityFailure | null
|
|
28
|
+
me: MyState | null
|
|
29
|
+
categories: ForumCategory[]
|
|
30
|
+
/** The latest rows of a category, by its id (a category whose list failed has none). */
|
|
31
|
+
latest: Record<string, KnownTopicItem[]>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ForumController {
|
|
35
|
+
readonly state: ForumUi
|
|
36
|
+
load(): Promise<void>
|
|
37
|
+
dispose(): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createForum(o: { client: CommunityClient }): ForumController {
|
|
41
|
+
const { client } = o
|
|
42
|
+
const abort = new AbortController()
|
|
43
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
44
|
+
let disposed = false
|
|
45
|
+
let ticket = 0
|
|
46
|
+
const state = reactive<ForumUi>({ phase: 'loading', failure: null, me: null, categories: [], latest: {} }) as ForumUi
|
|
47
|
+
|
|
48
|
+
async function load(): Promise<void> {
|
|
49
|
+
const mine = ++ticket
|
|
50
|
+
state.phase = 'loading'
|
|
51
|
+
state.failure = null
|
|
52
|
+
const [me, list] = await Promise.all([client.me.get(call), client.forum.categories.list(call)])
|
|
53
|
+
if (disposed || mine !== ticket) return
|
|
54
|
+
if (me.ok) state.me = me.value
|
|
55
|
+
if (!list.ok) {
|
|
56
|
+
state.failure = list
|
|
57
|
+
state.phase = list.code === 'surfaceOff' ? 'hidden' : list.code === 'membersOnly' ? 'membersOnly' : 'failed'
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
state.categories = [...list.value.categories].sort((a, b) => a.position - b.position)
|
|
61
|
+
state.latest = {}
|
|
62
|
+
state.phase = 'ready'
|
|
63
|
+
// The latest rows of the first categories, side by side; a category whose list fails just shows none.
|
|
64
|
+
const first = state.categories.slice(0, FORUM_LATEST_CATEGORIES)
|
|
65
|
+
const pages = await Promise.all(first.map((c) => client.forum.topics.list({ categoryId: c.id, sort: 'activity', limit: FORUM_LATEST_PER_CATEGORY }, call)))
|
|
66
|
+
if (disposed || mine !== ticket) return
|
|
67
|
+
const latest: Record<string, KnownTopicItem[]> = {}
|
|
68
|
+
first.forEach((c, i) => {
|
|
69
|
+
const page = pages[i]
|
|
70
|
+
if (page?.ok) latest[c.id] = knownTopicItemsOf(page.value.items).slice(0, FORUM_LATEST_PER_CATEGORY)
|
|
71
|
+
})
|
|
72
|
+
state.latest = latest
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
state,
|
|
77
|
+
load,
|
|
78
|
+
dispose() {
|
|
79
|
+
disposed = true
|
|
80
|
+
abort.abort()
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-forum-moderation>`'s brain (m3-contracts.md §3.4, §4.2, §4.5, §5.3; D-M3-6): a category moderator's queue —
|
|
3
|
+
* the cases of category `rules` in their categories (open ones, and decided ones a `system` decision still holds:
|
|
4
|
+
* `caseStatus`) and the messages waiting in pre-moderation — and what they do with them:
|
|
5
|
+
*
|
|
6
|
+
* - a case: its package as written (text only; no reporter, note or contact), then a decision through the engine —
|
|
7
|
+
* restore (`no_action`), hide (`visibility_restricted`) or remove — with the facts and the rule it rests on (the
|
|
8
|
+
* case's own by default: a restriction without one is `invalid` on `contractualGround`, and the rule is asked for),
|
|
9
|
+
* the statement previewed first (`dryRun`), decided after; or passed to the team (`moderation/escalate`, a category
|
|
10
|
+
* of the illegal line);
|
|
11
|
+
* - a pre-moderated message: published (no statement: nothing was restricted) or refused (a rule and the facts: a
|
|
12
|
+
* removal with its statement) — M3 FX (R3): the queue carries its text (rendered as text) and its `rev`, and the
|
|
13
|
+
* review sends the `rev` back: kb-api decides that text only. A refusal other than `notFound` (STALE_REVISION is
|
|
14
|
+
* `submit`) re-reads the item: its author edited it meanwhile → the new text is shown with `moderation.textChanged`
|
|
15
|
+
* and the moderator decides again; gone → it leaves the queue.
|
|
16
|
+
*
|
|
17
|
+
* A member who moderates nothing now (me/get's `moderator` null or empty) gets nothing at all: the element renders
|
|
18
|
+
* nothing. kb-api re-checks the appointment on every call — anything outside the moderator's scope is `notFound`, and
|
|
19
|
+
* the row leaves the queue.
|
|
20
|
+
*/
|
|
21
|
+
import { reactive } from 'vue'
|
|
22
|
+
import {
|
|
23
|
+
ESCALATION_CATEGORIES,
|
|
24
|
+
communityRulesOf,
|
|
25
|
+
type CaseCategory,
|
|
26
|
+
type CommunityCallOptions,
|
|
27
|
+
type CommunityClient,
|
|
28
|
+
type CommunityFailure,
|
|
29
|
+
type CommunityRule,
|
|
30
|
+
type ForumCategory,
|
|
31
|
+
type ModerationCase,
|
|
32
|
+
type ModerationItem,
|
|
33
|
+
type ModerationRestriction,
|
|
34
|
+
type MyState,
|
|
35
|
+
} from '@blogmcp/sdk/community'
|
|
36
|
+
import type { Lang, TextRef } from './i18n'
|
|
37
|
+
import { failureText } from './outcomes'
|
|
38
|
+
import { codePointLength, normalizeBody } from './text'
|
|
39
|
+
|
|
40
|
+
export type ModerationPhase = 'loading' | 'ready' | 'none' | 'failed' | 'hidden' | 'membersOnly'
|
|
41
|
+
|
|
42
|
+
/** The facts' bound here (kb-api checks its own). */
|
|
43
|
+
const FACTS_MAX = 4000
|
|
44
|
+
|
|
45
|
+
/** A case opened from the queue: its package and the decision being made. */
|
|
46
|
+
export interface OpenCase {
|
|
47
|
+
item: ModerationItem
|
|
48
|
+
kase: ModerationCase | null
|
|
49
|
+
loading: boolean
|
|
50
|
+
restriction: ModerationRestriction | ''
|
|
51
|
+
facts: string
|
|
52
|
+
/** The rule the restriction rests on ('' the case's own). */
|
|
53
|
+
ground: string
|
|
54
|
+
/** kb-api asked for the rule (`invalid` on `contractualGround`): the rule must be chosen. */
|
|
55
|
+
needsRule: boolean
|
|
56
|
+
/** The statement of the previewed decision, for the same choice. */
|
|
57
|
+
preview: string | null
|
|
58
|
+
previewing: boolean
|
|
59
|
+
deciding: boolean
|
|
60
|
+
failure: CommunityFailure | null
|
|
61
|
+
decided: boolean
|
|
62
|
+
escalation: { category: CaseCategory | ''; sending: boolean; failure: CommunityFailure | null; done: boolean } | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A pre-moderated message being reviewed. */
|
|
66
|
+
export interface PremodReview {
|
|
67
|
+
item: ModerationItem
|
|
68
|
+
outcome: 'approve' | 'reject' | ''
|
|
69
|
+
ruleId: string
|
|
70
|
+
facts: string
|
|
71
|
+
sending: boolean
|
|
72
|
+
failure: CommunityFailure | null
|
|
73
|
+
statement: string | null
|
|
74
|
+
done: 'approved' | 'rejected' | null
|
|
75
|
+
/** M3 FX (R3): the author edited the text while it was being reviewed — `item` holds the new one; decide again. */
|
|
76
|
+
changed: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How far a review's re-read pages the queue for its item (50 a page): the queue is oldest first and the item is old. */
|
|
80
|
+
const REREAD_PAGES = 20
|
|
81
|
+
|
|
82
|
+
export interface ModerationUi {
|
|
83
|
+
phase: ModerationPhase
|
|
84
|
+
failure: CommunityFailure | null
|
|
85
|
+
me: MyState | null
|
|
86
|
+
/** '' every category of the moderator's. */
|
|
87
|
+
categoryId: string
|
|
88
|
+
/** The moderator's categories (their names), in the forum's order. */
|
|
89
|
+
categories: ForumCategory[]
|
|
90
|
+
items: ModerationItem[]
|
|
91
|
+
nextCursor: string | null
|
|
92
|
+
loadingMore: boolean
|
|
93
|
+
rules: CommunityRule[] | null
|
|
94
|
+
open: OpenCase | null
|
|
95
|
+
review: PremodReview | null
|
|
96
|
+
announcement: TextRef | null
|
|
97
|
+
announceId: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ModerationController {
|
|
101
|
+
readonly state: ModerationUi
|
|
102
|
+
load(): Promise<void>
|
|
103
|
+
loadMore(): Promise<void>
|
|
104
|
+
setCategory(categoryId: string): Promise<void>
|
|
105
|
+
openCase(item: ModerationItem): Promise<void>
|
|
106
|
+
closeCase(): void
|
|
107
|
+
/** Whether the case's decision may be previewed now (a restriction, facts, a rule where one is needed). */
|
|
108
|
+
canPreview(): boolean
|
|
109
|
+
preview(): Promise<void>
|
|
110
|
+
decide(): Promise<void>
|
|
111
|
+
startEscalation(): void
|
|
112
|
+
escalate(): Promise<void>
|
|
113
|
+
openReview(item: ModerationItem, outcome: 'approve' | 'reject'): void
|
|
114
|
+
closeReview(): void
|
|
115
|
+
review(): Promise<void>
|
|
116
|
+
dispose(): void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createModeration(o: { client: CommunityClient; lang: Lang; categoryId?: string | null }): ModerationController {
|
|
120
|
+
const { client, lang } = o
|
|
121
|
+
const abort = new AbortController()
|
|
122
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
123
|
+
let disposed = false
|
|
124
|
+
let ticket = 0
|
|
125
|
+
const state = reactive<ModerationUi>({
|
|
126
|
+
phase: 'loading',
|
|
127
|
+
failure: null,
|
|
128
|
+
me: null,
|
|
129
|
+
categoryId: o.categoryId?.toLowerCase() ?? '',
|
|
130
|
+
categories: [],
|
|
131
|
+
items: [],
|
|
132
|
+
nextCursor: null,
|
|
133
|
+
loadingMore: false,
|
|
134
|
+
rules: null,
|
|
135
|
+
open: null,
|
|
136
|
+
review: null,
|
|
137
|
+
announcement: null,
|
|
138
|
+
announceId: 0,
|
|
139
|
+
}) as ModerationUi
|
|
140
|
+
|
|
141
|
+
const announce = (text: TextRef): void => {
|
|
142
|
+
state.announcement = text
|
|
143
|
+
state.announceId += 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The moderator's categories now (me/get), or none. */
|
|
147
|
+
const categoryIds = (): string[] => [...(state.me?.moderator?.categoryIds ?? [])].map((id) => id.toLowerCase())
|
|
148
|
+
|
|
149
|
+
/** A row the moderator may no longer act on (another moderator decided it, the appointment ended): out of the queue. */
|
|
150
|
+
function gone(item: ModerationItem): void {
|
|
151
|
+
state.items = state.items.filter((i) => i !== item)
|
|
152
|
+
announce({ key: 'moderation.gone' })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const queueInput = (cursor: string | null) => ({ ...(state.categoryId !== '' ? { categoryId: state.categoryId } : {}), ...(cursor ? { cursor } : {}) })
|
|
156
|
+
|
|
157
|
+
async function readQueue(mine: number): Promise<void> {
|
|
158
|
+
const r = await client.moderation.queue(queueInput(null), call)
|
|
159
|
+
if (disposed || mine !== ticket) return
|
|
160
|
+
if (!r.ok) {
|
|
161
|
+
state.failure = r
|
|
162
|
+
// Not (or no longer) a moderator of what was asked: nothing to show.
|
|
163
|
+
state.phase = r.code === 'notFound' ? 'none' : r.code === 'surfaceOff' ? 'hidden' : r.code === 'membersOnly' ? 'membersOnly' : 'failed'
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
state.items = [...r.value.items]
|
|
167
|
+
state.nextCursor = r.value.nextCursor
|
|
168
|
+
state.phase = 'ready'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function loadRules(): Promise<void> {
|
|
172
|
+
if (state.rules !== null) return
|
|
173
|
+
const r = await client.legal.current({ kind: 'rules', locale: lang }, call)
|
|
174
|
+
if (!disposed && r.ok) state.rules = communityRulesOf(r.value.bodyJson)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function load(): Promise<void> {
|
|
178
|
+
const mine = ++ticket
|
|
179
|
+
state.phase = 'loading'
|
|
180
|
+
state.failure = null
|
|
181
|
+
const me = await client.me.get(call)
|
|
182
|
+
if (disposed || mine !== ticket) return
|
|
183
|
+
if (!me.ok) {
|
|
184
|
+
state.failure = me
|
|
185
|
+
state.phase = 'failed'
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
state.me = me.value
|
|
189
|
+
// A member who moderates nothing now: nothing at all (not even a heading).
|
|
190
|
+
if (categoryIds().length === 0) {
|
|
191
|
+
state.phase = 'none'
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const list = await client.forum.categories.list(call)
|
|
195
|
+
if (disposed || mine !== ticket) return
|
|
196
|
+
if (list.ok) state.categories = list.value.categories.filter((c) => categoryIds().includes(c.id.toLowerCase()))
|
|
197
|
+
// The rules' titles name a queued case's rule (and the decision's choices).
|
|
198
|
+
void loadRules()
|
|
199
|
+
if (state.categoryId !== '' && !categoryIds().includes(state.categoryId)) state.categoryId = ''
|
|
200
|
+
await readQueue(mine)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const decisionInput = (dryRun: boolean) => {
|
|
204
|
+
const c = state.open!
|
|
205
|
+
return {
|
|
206
|
+
caseId: c.item.caseId!,
|
|
207
|
+
restriction: c.restriction as ModerationRestriction,
|
|
208
|
+
// A member's report is a notice (DSA art. 16); the filters' and the team's cases are the provider's initiative.
|
|
209
|
+
basis: c.item.source === 'member_report' ? ('notice' as const) : ('own_initiative' as const),
|
|
210
|
+
facts: normalizeBody(c.facts),
|
|
211
|
+
...(c.ground !== '' ? { contractualGround: c.ground } : {}),
|
|
212
|
+
locale: lang,
|
|
213
|
+
dryRun,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function canPreview(): boolean {
|
|
218
|
+
const c = state.open
|
|
219
|
+
if (!c || !c.kase || c.restriction === '' || c.previewing || c.deciding || c.decided) return false
|
|
220
|
+
const facts = normalizeBody(c.facts)
|
|
221
|
+
if (facts === '' || codePointLength(facts) > FACTS_MAX) return false
|
|
222
|
+
// A restriction rests on a rule: the case's own, else one chosen (kb-api asks for it when the case has none).
|
|
223
|
+
return c.restriction === 'no_action' || c.ground !== '' || (c.kase.ruleId !== null && !c.needsRule)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* R3: after a review's refusal, the item as the queue holds it now. Its text changed (a newer rev) — the review shows
|
|
228
|
+
* the new text (`changed`) and asks again; it is no longer queued — it leaves; unchanged — the refusal stands.
|
|
229
|
+
*/
|
|
230
|
+
async function reread(v: PremodReview): Promise<void> {
|
|
231
|
+
const same = (i: ModerationItem): boolean => i.kind === 'premod' && i.conversationId === v.item.conversationId && i.seq === v.item.seq
|
|
232
|
+
let cursor: string | null = null
|
|
233
|
+
for (let page = 0; page < REREAD_PAGES; page++) {
|
|
234
|
+
const r = await client.moderation.queue(queueInput(cursor), call)
|
|
235
|
+
if (disposed || state.review !== v || !r.ok) return
|
|
236
|
+
const now = r.value.items.find(same)
|
|
237
|
+
if (now) {
|
|
238
|
+
if (now.rev === v.item.rev) return
|
|
239
|
+
state.items = state.items.map((i) => (same(i) ? now : i))
|
|
240
|
+
v.item = now
|
|
241
|
+
v.failure = null
|
|
242
|
+
v.changed = true
|
|
243
|
+
announce({ key: 'moderation.textChanged' })
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
cursor = r.value.nextCursor
|
|
247
|
+
if (cursor === null) break
|
|
248
|
+
}
|
|
249
|
+
state.review = null
|
|
250
|
+
gone(v.item)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function caseRefused(c: OpenCase, f: CommunityFailure): void {
|
|
254
|
+
c.failure = f
|
|
255
|
+
if (f.code === 'invalid' && f.fields?.includes('contractualGround')) {
|
|
256
|
+
c.needsRule = true
|
|
257
|
+
void loadRules()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (f.code === 'notFound') {
|
|
261
|
+
state.open = null
|
|
262
|
+
gone(c.item)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
state,
|
|
268
|
+
load,
|
|
269
|
+
async loadMore() {
|
|
270
|
+
if (state.loadingMore || state.nextCursor === null || state.phase !== 'ready') return
|
|
271
|
+
const mine = ticket
|
|
272
|
+
state.loadingMore = true
|
|
273
|
+
const r = await client.moderation.queue(queueInput(state.nextCursor), call)
|
|
274
|
+
state.loadingMore = false
|
|
275
|
+
if (disposed || mine !== ticket) return
|
|
276
|
+
if (!r.ok) return announce(failureText(r, lang))
|
|
277
|
+
state.items = [...state.items, ...r.value.items]
|
|
278
|
+
state.nextCursor = r.value.nextCursor
|
|
279
|
+
},
|
|
280
|
+
async setCategory(categoryId) {
|
|
281
|
+
const id = categoryId.toLowerCase()
|
|
282
|
+
if (id === state.categoryId || (id !== '' && !categoryIds().includes(id))) return
|
|
283
|
+
state.categoryId = id
|
|
284
|
+
const mine = ++ticket
|
|
285
|
+
await readQueue(mine)
|
|
286
|
+
},
|
|
287
|
+
async openCase(item) {
|
|
288
|
+
if (item.kind !== 'case' || !item.caseId) return
|
|
289
|
+
state.review = null
|
|
290
|
+
state.open = {
|
|
291
|
+
item,
|
|
292
|
+
kase: null,
|
|
293
|
+
loading: true,
|
|
294
|
+
restriction: '',
|
|
295
|
+
facts: '',
|
|
296
|
+
ground: '',
|
|
297
|
+
needsRule: false,
|
|
298
|
+
preview: null,
|
|
299
|
+
previewing: false,
|
|
300
|
+
deciding: false,
|
|
301
|
+
failure: null,
|
|
302
|
+
decided: false,
|
|
303
|
+
escalation: null,
|
|
304
|
+
}
|
|
305
|
+
// The reactive one: what is compared and changed below is what the view reads.
|
|
306
|
+
const c = state.open
|
|
307
|
+
void loadRules()
|
|
308
|
+
const r = await client.moderation.getCase({ caseId: item.caseId }, call)
|
|
309
|
+
if (disposed || state.open !== c) return
|
|
310
|
+
c.loading = false
|
|
311
|
+
if (r.ok) c.kase = r.value.case
|
|
312
|
+
else caseRefused(c, r)
|
|
313
|
+
},
|
|
314
|
+
closeCase() {
|
|
315
|
+
state.open = null
|
|
316
|
+
},
|
|
317
|
+
canPreview,
|
|
318
|
+
async preview() {
|
|
319
|
+
const c = state.open
|
|
320
|
+
if (!c || !canPreview()) return
|
|
321
|
+
c.previewing = true
|
|
322
|
+
c.failure = null
|
|
323
|
+
const r = await client.moderation.decide(decisionInput(true), call)
|
|
324
|
+
c.previewing = false
|
|
325
|
+
if (disposed || state.open !== c) return
|
|
326
|
+
if (r.ok) c.preview = r.value.statement
|
|
327
|
+
else caseRefused(c, r)
|
|
328
|
+
},
|
|
329
|
+
async decide() {
|
|
330
|
+
const c = state.open
|
|
331
|
+
// Only what was previewed is decided: the moderator saw the statement the author gets.
|
|
332
|
+
if (!c || c.preview === null || c.deciding || c.decided) return
|
|
333
|
+
c.deciding = true
|
|
334
|
+
c.failure = null
|
|
335
|
+
const r = await client.moderation.decide(decisionInput(false), call)
|
|
336
|
+
c.deciding = false
|
|
337
|
+
if (disposed || state.open !== c) return
|
|
338
|
+
if (!r.ok) return caseRefused(c, r)
|
|
339
|
+
c.decided = true
|
|
340
|
+
c.preview = r.value.statement
|
|
341
|
+
state.items = state.items.filter((i) => i !== c.item)
|
|
342
|
+
announce({ key: 'moderation.decided' })
|
|
343
|
+
},
|
|
344
|
+
startEscalation() {
|
|
345
|
+
const c = state.open
|
|
346
|
+
if (c && !c.escalation) c.escalation = { category: '', sending: false, failure: null, done: false }
|
|
347
|
+
},
|
|
348
|
+
async escalate() {
|
|
349
|
+
const c = state.open
|
|
350
|
+
const e = c?.escalation
|
|
351
|
+
if (!c || !e || e.sending || e.done || e.category === '' || !ESCALATION_CATEGORIES.includes(e.category)) return
|
|
352
|
+
e.sending = true
|
|
353
|
+
e.failure = null
|
|
354
|
+
const r = await client.moderation.escalate({ conversationId: c.item.conversationId, seq: c.item.seq, category: e.category }, call)
|
|
355
|
+
e.sending = false
|
|
356
|
+
if (disposed || state.open !== c) return
|
|
357
|
+
if (r.ok) {
|
|
358
|
+
e.done = true
|
|
359
|
+
announce({ key: 'moderation.escalated' })
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
e.failure = r
|
|
363
|
+
if (r.code === 'notFound') {
|
|
364
|
+
state.open = null
|
|
365
|
+
gone(c.item)
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
openReview(item, outcome) {
|
|
369
|
+
if (item.kind !== 'premod') return
|
|
370
|
+
state.open = null
|
|
371
|
+
state.review = { item, outcome, ruleId: '', facts: '', sending: false, failure: null, statement: null, done: null, changed: false }
|
|
372
|
+
if (outcome === 'reject') void loadRules()
|
|
373
|
+
},
|
|
374
|
+
closeReview() {
|
|
375
|
+
state.review = null
|
|
376
|
+
},
|
|
377
|
+
async review() {
|
|
378
|
+
const v = state.review
|
|
379
|
+
if (!v || v.sending || v.done || v.outcome === '') return
|
|
380
|
+
const facts = normalizeBody(v.facts)
|
|
381
|
+
if (v.outcome === 'reject' && (v.ruleId === '' || facts === '' || codePointLength(facts) > FACTS_MAX)) return
|
|
382
|
+
v.sending = true
|
|
383
|
+
v.failure = null
|
|
384
|
+
v.changed = false
|
|
385
|
+
// R3: the rev of the text shown — kb-api decides that text only (a newer one is STALE_REVISION, nothing written).
|
|
386
|
+
const ref = { conversationId: v.item.conversationId, seq: v.item.seq, rev: v.item.rev ?? 1 }
|
|
387
|
+
const r =
|
|
388
|
+
v.outcome === 'approve'
|
|
389
|
+
? await client.moderation.reviewPremod({ ...ref, outcome: 'approve' }, call)
|
|
390
|
+
: // The statement of a rejection in the reader's language, as decide's and remove's (kb-api's schema takes it).
|
|
391
|
+
await client.moderation.reviewPremod({ ...ref, outcome: 'reject', ruleId: v.ruleId, facts, locale: lang }, call)
|
|
392
|
+
v.sending = false
|
|
393
|
+
if (disposed || state.review !== v) return
|
|
394
|
+
if (!r.ok) {
|
|
395
|
+
v.failure = r
|
|
396
|
+
if (r.code === 'notFound') {
|
|
397
|
+
state.review = null
|
|
398
|
+
gone(v.item)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
await reread(v)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
v.done = v.outcome === 'approve' ? 'approved' : 'rejected'
|
|
405
|
+
if ('statement' in r.value) v.statement = r.value.statement
|
|
406
|
+
state.items = state.items.filter((i) => i !== v.item)
|
|
407
|
+
announce({ key: v.done === 'approved' ? 'moderation.approved' : 'moderation.rejected' })
|
|
408
|
+
},
|
|
409
|
+
dispose() {
|
|
410
|
+
disposed = true
|
|
411
|
+
abort.abort()
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
}
|
package/src/core/onboarding.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
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:
|
|
11
12
|
* the layout from me/get's `settings.art9ConsentLayout` — `joint` one consent, `split` one per controller of
|
|
12
13
|
* the text's `bodyJson.controllers` when it names two, all of them required, the text itself shown once —
|
|
13
14
|
* and, where `settings.art9Refusal` is `public_write`, a refusal that joins without the consent: kb-api then
|
|
@@ -135,6 +136,13 @@ export interface OnboardingOptions {
|
|
|
135
136
|
* member who refused it, or has not given it, where the tenant asks for it — and no refusal is offered.
|
|
136
137
|
*/
|
|
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
|
|
138
146
|
signal?: AbortSignal
|
|
139
147
|
}
|
|
140
148
|
|
|
@@ -157,12 +165,15 @@ export function onboardingFailureText(failure: Pick<CommunityFailure, 'code' | '
|
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
/** What is still missing for this member, in order (MyState and its known needs). */
|
|
160
|
-
function stepsOf(me: MyState, needs: readonly string[], offerArt9: boolean): OnboardingStep[] {
|
|
168
|
+
function stepsOf(me: MyState, needs: readonly string[], offerArt9: boolean, forceArt9: boolean): OnboardingStep[] {
|
|
161
169
|
const steps: OnboardingStep[] = []
|
|
162
170
|
if (!me.member?.ageBand) steps.push('age')
|
|
163
171
|
if (me.acceptances.rules !== 'accepted') steps.push('rules')
|
|
164
172
|
const art9 = me.acceptances.art9Consent
|
|
165
|
-
|
|
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')
|
|
166
177
|
if (!me.profile) steps.push('profile')
|
|
167
178
|
return steps
|
|
168
179
|
}
|
|
@@ -191,6 +202,7 @@ interface Read {
|
|
|
191
202
|
export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
192
203
|
const call: CommunityCallOptions = o.signal ? { signal: o.signal } : {}
|
|
193
204
|
const offerArt9 = o.offerArt9 === true
|
|
205
|
+
const forceArt9 = o.forceArt9 === true && !offerArt9
|
|
194
206
|
const state = reactive<OnboardingState>({
|
|
195
207
|
phase: 'loading',
|
|
196
208
|
steps: [],
|
|
@@ -255,7 +267,7 @@ export function createOnboarding(o: OnboardingOptions): OnboardingModel {
|
|
|
255
267
|
o.onSignIn()
|
|
256
268
|
return null
|
|
257
269
|
}
|
|
258
|
-
const steps = stepsOf(me.value, needs, offerArt9)
|
|
270
|
+
const steps = stepsOf(me.value, needs, offerArt9, forceArt9)
|
|
259
271
|
const texts = await textsFor(steps)
|
|
260
272
|
return texts ? { me: me.value, steps, texts } : null
|
|
261
273
|
}
|
package/src/core/outcomes.ts
CHANGED
|
@@ -78,6 +78,16 @@ const EFFECTS: Readonly<Record<CommunityUiCode, FailureEffect>> = Object.freeze(
|
|
|
78
78
|
pushRefused: 'show',
|
|
79
79
|
// Live updates are off here: nothing to show the reader — the discussion keeps polling (the realtime session's fallback).
|
|
80
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',
|
|
81
91
|
submit: 'show',
|
|
82
92
|
} satisfies Record<CommunityUiCode, FailureEffect>)
|
|
83
93
|
|
package/src/core/profile.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type CommunityFailure,
|
|
19
19
|
type CommunityMessageKey,
|
|
20
20
|
type CommunityRule,
|
|
21
|
+
type ForumCategoryRef,
|
|
21
22
|
type KnownAuthor,
|
|
22
23
|
type PublicProfile,
|
|
23
24
|
} from '@blogmcp/sdk/community'
|
|
@@ -32,6 +33,11 @@ export interface ProfileUi {
|
|
|
32
33
|
bio: string | null
|
|
33
34
|
/** The trust level's catalogue key (`trust.<n>`), or null for a label this SDK does not know. */
|
|
34
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[]
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export interface ProfileController {
|
|
@@ -53,7 +59,7 @@ export function createProfile(o: { client: CommunityClient }): ProfileController
|
|
|
53
59
|
const call: CommunityCallOptions = { signal: abort.signal }
|
|
54
60
|
let disposed = false
|
|
55
61
|
let ticket = 0
|
|
56
|
-
const state = reactive<ProfileUi>({ phase: 'idle', failure: null, author: null, memberSince: null, bio: null, trustKey: null }) as ProfileUi
|
|
62
|
+
const state = reactive<ProfileUi>({ phase: 'idle', failure: null, author: null, memberSince: null, bio: null, trustKey: null, moderates: [] }) as ProfileUi
|
|
57
63
|
|
|
58
64
|
return {
|
|
59
65
|
state,
|
|
@@ -64,6 +70,7 @@ export function createProfile(o: { client: CommunityClient }): ProfileController
|
|
|
64
70
|
state.memberSince = null
|
|
65
71
|
state.bio = null
|
|
66
72
|
state.trustKey = null
|
|
73
|
+
state.moderates = []
|
|
67
74
|
const h = handleOf(handle)
|
|
68
75
|
if (h === null) {
|
|
69
76
|
state.phase = 'notFound'
|
|
@@ -87,6 +94,10 @@ export function createProfile(o: { client: CommunityClient }): ProfileController
|
|
|
87
94
|
state.memberSince = typeof p.memberSince === 'string' ? p.memberSince : null
|
|
88
95
|
state.bio = typeof p.bio === 'string' && p.bio.trim() !== '' ? p.bio : null
|
|
89
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
|
+
: []
|
|
90
101
|
state.phase = 'ready'
|
|
91
102
|
},
|
|
92
103
|
dispose() {
|