@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.
Files changed (82) hide show
  1. package/README.md +64 -12
  2. package/dist/blogmcp-community.global.js +7 -7
  3. package/dist/chunks/{mount-WadUnaTa.js → mount--2_V2q3M.js} +4724 -1097
  4. package/dist/core/attrs.d.ts +17 -2
  5. package/dist/core/discussion.d.ts +92 -18
  6. package/dist/core/follow.d.ts +26 -0
  7. package/dist/core/forum-category.d.ts +85 -0
  8. package/dist/core/forum-search.d.ts +35 -0
  9. package/dist/core/forum-state.d.ts +30 -0
  10. package/dist/core/forum-words.d.ts +8 -0
  11. package/dist/core/forum.d.ts +21 -0
  12. package/dist/core/moderation.d.ts +80 -0
  13. package/dist/core/onboarding.d.ts +7 -0
  14. package/dist/core/profile.d.ts +6 -1
  15. package/dist/core/realtime.d.ts +13 -2
  16. package/dist/core/topic.d.ts +47 -0
  17. package/dist/element.d.ts +26 -1
  18. package/dist/global.d.ts +7 -6
  19. package/dist/index.d.ts +13 -4
  20. package/dist/index.js +54 -27
  21. package/dist/mount.d.ts +50 -1
  22. package/dist/react.d.ts +17 -4
  23. package/dist/react.js +32 -8
  24. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  25. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  26. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  27. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  28. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  29. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  30. package/dist/ui/ForumView.vue.d.ts +12 -0
  31. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  32. package/dist/ui/MessageComposer.vue.d.ts +6 -2
  33. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  34. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  35. package/dist/ui/TopicRow.vue.d.ts +10 -0
  36. package/dist/ui/TopicView.vue.d.ts +32 -0
  37. package/dist/ui/context.d.ts +7 -1
  38. package/dist/ui/discussion-context.d.ts +11 -3
  39. package/dist/vue.d.ts +164 -5
  40. package/dist/vue.js +134 -8
  41. package/package.json +3 -3
  42. package/src/core/attrs.ts +22 -2
  43. package/src/core/discussion.ts +309 -43
  44. package/src/core/follow.ts +139 -0
  45. package/src/core/forum-category.ts +323 -0
  46. package/src/core/forum-search.ts +153 -0
  47. package/src/core/forum-state.ts +59 -0
  48. package/src/core/forum-words.ts +38 -0
  49. package/src/core/forum.ts +83 -0
  50. package/src/core/moderation.ts +414 -0
  51. package/src/core/onboarding.ts +16 -4
  52. package/src/core/outcomes.ts +10 -0
  53. package/src/core/profile.ts +12 -1
  54. package/src/core/realtime.ts +15 -2
  55. package/src/core/topic.ts +234 -0
  56. package/src/element.ts +63 -1
  57. package/src/global.ts +27 -5
  58. package/src/index.ts +49 -2
  59. package/src/mount.ts +144 -2
  60. package/src/react.ts +45 -2
  61. package/src/ui/FollowButtonView.vue +85 -0
  62. package/src/ui/FollowToggle.vue +39 -0
  63. package/src/ui/ForumCategoryView.vue +243 -0
  64. package/src/ui/ForumModerationView.vue +309 -0
  65. package/src/ui/ForumNotices.vue +23 -0
  66. package/src/ui/ForumSearchView.vue +171 -0
  67. package/src/ui/ForumView.vue +128 -0
  68. package/src/ui/MembersOnly.vue +59 -0
  69. package/src/ui/MessageComposer.vue +51 -9
  70. package/src/ui/MessageItem.vue +12 -1
  71. package/src/ui/MessageMenu.vue +3 -2
  72. package/src/ui/ModeratorDialog.vue +102 -0
  73. package/src/ui/MyCommunityView.vue +3 -0
  74. package/src/ui/NoticeStatusView.vue +15 -2
  75. package/src/ui/NotificationCentreView.vue +37 -3
  76. package/src/ui/ProfileView.vue +15 -4
  77. package/src/ui/TopicRow.vue +70 -0
  78. package/src/ui/TopicView.vue +395 -0
  79. package/src/ui/context.ts +8 -3
  80. package/src/ui/discussion-context.ts +11 -3
  81. package/src/ui/styles.css +14 -0
  82. 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
+ }
@@ -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 (M2 T9, m2-contracts.md §3.8:
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
- if (needs.includes('art9_consent') || (offerArt9 && art9 !== null && art9 !== 'accepted')) steps.push('art9')
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
  }
@@ -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
 
@@ -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() {