@blogmcp/community 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -13
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/mount--2_V2q3M.js +10063 -0
- package/dist/core/attrs.d.ts +42 -1
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +120 -15
- package/dist/core/follow.d.ts +26 -0
- package/dist/core/format.d.ts +7 -0
- package/dist/core/forum-category.d.ts +85 -0
- package/dist/core/forum-search.d.ts +35 -0
- package/dist/core/forum-state.d.ts +30 -0
- package/dist/core/forum-words.d.ts +8 -0
- package/dist/core/forum.d.ts +21 -0
- package/dist/core/moderation.d.ts +80 -0
- package/dist/core/onboarding.d.ts +49 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- package/dist/core/profile.d.ts +45 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +46 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +47 -1
- package/dist/global.d.ts +8 -4
- package/dist/index.d.ts +20 -3
- package/dist/index.js +62 -12
- package/dist/mount.d.ts +89 -0
- package/dist/react.d.ts +27 -3
- package/dist/react.js +48 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- package/dist/ui/FollowButtonView.vue.d.ts +17 -0
- package/dist/ui/FollowToggle.vue.d.ts +6 -0
- package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
- package/dist/ui/ForumModerationView.vue.d.ts +12 -0
- package/dist/ui/ForumNotices.vue.d.ts +7 -0
- package/dist/ui/ForumSearchView.vue.d.ts +11 -0
- package/dist/ui/ForumView.vue.d.ts +12 -0
- package/dist/ui/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +8 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +273 -3
- package/dist/vue.js +235 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +61 -2
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +497 -46
- package/src/core/follow.ts +139 -0
- package/src/core/format.ts +22 -0
- package/src/core/forum-category.ts +323 -0
- package/src/core/forum-search.ts +153 -0
- package/src/core/forum-state.ts +59 -0
- package/src/core/forum-words.ts +38 -0
- package/src/core/forum.ts +83 -0
- package/src/core/moderation.ts +414 -0
- package/src/core/onboarding.ts +177 -18
- package/src/core/outcomes.ts +23 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +155 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +52 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/core/topic.ts +234 -0
- package/src/element.ts +124 -6
- package/src/global.ts +40 -4
- package/src/index.ts +84 -2
- package/src/mount.ts +250 -1
- package/src/react.ts +90 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- package/src/ui/FollowButtonView.vue +85 -0
- package/src/ui/FollowToggle.vue +39 -0
- package/src/ui/ForumCategoryView.vue +243 -0
- package/src/ui/ForumModerationView.vue +309 -0
- package/src/ui/ForumNotices.vue +23 -0
- package/src/ui/ForumSearchView.vue +171 -0
- package/src/ui/ForumView.vue +128 -0
- package/src/ui/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +64 -11
- package/src/ui/MessageItem.vue +36 -7
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +12 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +193 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +102 -0
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +30 -0
- package/src/vue.ts +271 -3
- package/dist/chunks/mount-DzjwnHyx.js +0 -4597
|
@@ -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
|
+
}
|