@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.
Files changed (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. package/dist/chunks/mount-DzjwnHyx.js +0 -4597
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What every forum element reads out of `me/get` beside its own operation (m3-contracts.md §3.5, §4.8, §4.9, §5.3):
3
+ * the team's away mode (one sentence, `state.away`), the pause of the forum's writes — the module's brake (both
4
+ * surfaces: `state.braked`) or the forum's own (`state.forumBraked`; the article discussions go on) —, the module's
5
+ * `read_only` (the forum is one of its features), and what a `membersOnly` refusal asks of the viewer.
6
+ */
7
+ import {
8
+ communityAwayOf,
9
+ isKnownModuleState,
10
+ isKnownNeed,
11
+ surfaceInfoOf,
12
+ type CommunityFailure,
13
+ type CommunityMessageKey,
14
+ type KnownNeed,
15
+ type MyState,
16
+ } from '@blogmcp/sdk/community'
17
+
18
+ export interface ForumNotices {
19
+ /** The team is away: new topics wait for review (`state.away`). */
20
+ away: boolean
21
+ /** The forum's writes are paused — the module's brake, or the forum's own — or not. */
22
+ paused: 'module' | 'forum' | null
23
+ /** The module is `read_only`: the forum takes no writes meanwhile (`error.readOnly`). */
24
+ readOnly: boolean
25
+ }
26
+
27
+ /** The notices of `me/get`'s module block, at `now`. */
28
+ export function forumNoticesOf(me: MyState | null | undefined, now: number = Date.now()): ForumNotices {
29
+ const module = me?.module
30
+ if (!module) return { away: false, paused: null, readOnly: false }
31
+ const state = isKnownModuleState(module.state) ? module.state : 'dark'
32
+ const forumBrake = surfaceInfoOf(module, 'forum')?.brake ?? null
33
+ const forumBraked = forumBrake !== null && Date.parse(forumBrake.until) > now
34
+ return {
35
+ away: communityAwayOf(module, now) !== null,
36
+ paused: module.brake !== undefined || state === 'braked' ? 'module' : forumBraked ? 'forum' : null,
37
+ readOnly: state === 'read_only',
38
+ }
39
+ }
40
+
41
+ /** The sentence of a pause of the forum's writes. */
42
+ export const pausedKey = (paused: ForumNotices['paused']): CommunityMessageKey | null =>
43
+ paused === 'module' ? 'state.braked' : paused === 'forum' ? 'state.forumBraked' : null
44
+
45
+ /**
46
+ * What a `membersOnly` refusal asks of the viewer (§3.1 step 3): to join — a member session that has not finished its
47
+ * onboarding (`needs` `onboarding` or `reconsent_rules`) — or to sign in (a visitor, or a session the tenant does not
48
+ * admit as a member: `sign_in`, or no needs at all).
49
+ */
50
+ export function membersOnlyAsk(failure: Pick<CommunityFailure, 'needs'> | null | undefined): 'join' | 'signIn' {
51
+ const needs: KnownNeed[] = (failure?.needs ?? []).filter(isKnownNeed)
52
+ return !needs.includes('sign_in') && (needs.includes('onboarding') || needs.includes('reconsent_rules')) ? 'join' : 'signIn'
53
+ }
54
+
55
+ /**
56
+ * Whether the viewer is a member who may take part (an onboarded member's session): me/get names a member and no
57
+ * need to sign in. The team at the keyboard reads the forum but writes through the KB admin.
58
+ */
59
+ export const isForumMember = (me: MyState | null | undefined): boolean => !!me?.member && !me.needs.includes('sign_in')
@@ -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
+ }