@blogmcp/community 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -12
- package/dist/blogmcp-community.global.js +7 -7
- package/dist/chunks/{mount-WadUnaTa.js → mount--2_V2q3M.js} +4724 -1097
- package/dist/core/attrs.d.ts +17 -2
- package/dist/core/discussion.d.ts +92 -18
- package/dist/core/follow.d.ts +26 -0
- package/dist/core/forum-category.d.ts +85 -0
- package/dist/core/forum-search.d.ts +35 -0
- package/dist/core/forum-state.d.ts +30 -0
- package/dist/core/forum-words.d.ts +8 -0
- package/dist/core/forum.d.ts +21 -0
- package/dist/core/moderation.d.ts +80 -0
- package/dist/core/onboarding.d.ts +7 -0
- package/dist/core/profile.d.ts +6 -1
- package/dist/core/realtime.d.ts +13 -2
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +26 -1
- package/dist/global.d.ts +7 -6
- package/dist/index.d.ts +13 -4
- package/dist/index.js +54 -27
- package/dist/mount.d.ts +50 -1
- package/dist/react.d.ts +17 -4
- package/dist/react.js +32 -8
- package/dist/ui/FollowButtonView.vue.d.ts +17 -0
- package/dist/ui/FollowToggle.vue.d.ts +6 -0
- package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
- package/dist/ui/ForumModerationView.vue.d.ts +12 -0
- package/dist/ui/ForumNotices.vue.d.ts +7 -0
- package/dist/ui/ForumSearchView.vue.d.ts +11 -0
- package/dist/ui/ForumView.vue.d.ts +12 -0
- package/dist/ui/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +6 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +164 -5
- package/dist/vue.js +134 -8
- package/package.json +3 -3
- package/src/core/attrs.ts +22 -2
- package/src/core/discussion.ts +309 -43
- package/src/core/follow.ts +139 -0
- package/src/core/forum-category.ts +323 -0
- package/src/core/forum-search.ts +153 -0
- package/src/core/forum-state.ts +59 -0
- package/src/core/forum-words.ts +38 -0
- package/src/core/forum.ts +83 -0
- package/src/core/moderation.ts +414 -0
- package/src/core/onboarding.ts +16 -4
- package/src/core/outcomes.ts +10 -0
- package/src/core/profile.ts +12 -1
- package/src/core/realtime.ts +15 -2
- package/src/core/topic.ts +234 -0
- package/src/element.ts +63 -1
- package/src/global.ts +27 -5
- package/src/index.ts +49 -2
- package/src/mount.ts +144 -2
- package/src/react.ts +45 -2
- package/src/ui/FollowButtonView.vue +85 -0
- package/src/ui/FollowToggle.vue +39 -0
- package/src/ui/ForumCategoryView.vue +243 -0
- package/src/ui/ForumModerationView.vue +309 -0
- package/src/ui/ForumNotices.vue +23 -0
- package/src/ui/ForumSearchView.vue +171 -0
- package/src/ui/ForumView.vue +128 -0
- package/src/ui/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +51 -9
- package/src/ui/MessageItem.vue +12 -1
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +3 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +37 -3
- package/src/ui/ProfileView.vue +15 -4
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +14 -0
- package/src/vue.ts +145 -3
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The follow button's brain (m3-contracts.md §3.3, §4.3, §5.3; D-M3-4, B16): one target — a topic or an article
|
|
3
|
+
* discussion by its conversation id, or a category — and whether the member follows it.
|
|
4
|
+
*
|
|
5
|
+
* - `me/follows/set { following: true }` needs a target the member may read now: anything else is the one `notFound`,
|
|
6
|
+
* and the button says „not available" (`follow.unavailable`) and offers nothing more; `followLimit` above
|
|
7
|
+
* COMMUNITY_FOLLOWS_MAX; a visitor's session is `signIn`.
|
|
8
|
+
* - `me/follows/set { following: false }` never fails on its target (an unknown one answers `{ following: false }`): an
|
|
9
|
+
* unfollow has no error path of its own — only the transport's (the request did not go), which leaves the state
|
|
10
|
+
* as it was and says so like any write.
|
|
11
|
+
* - The state the button starts from: what the page already knows (a topic's or a category's `following`, the host's
|
|
12
|
+
* `following` attribute), else the member's follows (`me/follows/list`, read until the target is found or the list
|
|
13
|
+
* ends — at most COMMUNITY_FOLLOWS_MAX rows, a few pages).
|
|
14
|
+
*/
|
|
15
|
+
import { reactive } from 'vue'
|
|
16
|
+
import {
|
|
17
|
+
COMMUNITY_FOLLOWS_MAX,
|
|
18
|
+
followKeyOf,
|
|
19
|
+
type CommunityCallOptions,
|
|
20
|
+
type CommunityClient,
|
|
21
|
+
type CommunityFailure,
|
|
22
|
+
type FollowTarget,
|
|
23
|
+
} from '@blogmcp/sdk/community'
|
|
24
|
+
import type { Lang, TextRef } from './i18n'
|
|
25
|
+
import { failureText } from './outcomes'
|
|
26
|
+
|
|
27
|
+
export type FollowPhase = 'idle' | 'loading' | 'ready' | 'signIn' | 'unavailable' | 'failed'
|
|
28
|
+
|
|
29
|
+
export interface FollowUi {
|
|
30
|
+
phase: FollowPhase
|
|
31
|
+
following: boolean
|
|
32
|
+
busy: boolean
|
|
33
|
+
/** The last refusal of a click (the state stays as it was), or null. */
|
|
34
|
+
failure: CommunityFailure | null
|
|
35
|
+
announcement: TextRef | null
|
|
36
|
+
announceId: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FollowController {
|
|
40
|
+
readonly state: FollowUi
|
|
41
|
+
/** The state from what the page knows (`known`), else the member's follows. */
|
|
42
|
+
load(known?: boolean | null): Promise<void>
|
|
43
|
+
/** A newer `following` the page learnt (a topic re-read): taken unless a click is on its way. */
|
|
44
|
+
sync(following: boolean): void
|
|
45
|
+
toggle(): Promise<void>
|
|
46
|
+
dispose(): void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pages of `me/follows/list` read for the state at most (COMMUNITY_FOLLOWS_MAX rows, whatever kb-api's page size). */
|
|
50
|
+
const LIST_PAGES_MAX = 25
|
|
51
|
+
|
|
52
|
+
export function createFollow(o: { client: CommunityClient; target: FollowTarget; lang: Lang }): FollowController {
|
|
53
|
+
const { client, target, lang } = o
|
|
54
|
+
const key = followKeyOf(target)
|
|
55
|
+
const abort = new AbortController()
|
|
56
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
57
|
+
let disposed = false
|
|
58
|
+
const state = reactive<FollowUi>({ phase: 'idle', following: false, busy: false, failure: null, announcement: null, announceId: 0 }) as FollowUi
|
|
59
|
+
|
|
60
|
+
const announce = (text: TextRef): void => {
|
|
61
|
+
state.announcement = text
|
|
62
|
+
state.announceId += 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether the member follows the target, from their follows; null when that could not be read. */
|
|
66
|
+
async function readFollowing(): Promise<boolean | CommunityFailure> {
|
|
67
|
+
let cursor: string | null = null
|
|
68
|
+
let rows = 0
|
|
69
|
+
for (let page = 0; page < LIST_PAGES_MAX; page++) {
|
|
70
|
+
const r = await client.me.follows.list(cursor === null ? {} : { cursor }, call)
|
|
71
|
+
if (!r.ok) return r
|
|
72
|
+
for (const f of r.value.follows) if (followKeyOf(f) === key) return true
|
|
73
|
+
rows += r.value.follows.length
|
|
74
|
+
cursor = r.value.nextCursor
|
|
75
|
+
if (cursor === null || rows >= COMMUNITY_FOLLOWS_MAX) return false
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
state,
|
|
82
|
+
async load(known) {
|
|
83
|
+
state.failure = null
|
|
84
|
+
if (key === null) {
|
|
85
|
+
state.phase = 'unavailable'
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (typeof known === 'boolean') {
|
|
89
|
+
state.following = known
|
|
90
|
+
state.phase = 'ready'
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
state.phase = 'loading'
|
|
94
|
+
const r = await readFollowing()
|
|
95
|
+
if (disposed) return
|
|
96
|
+
if (typeof r === 'boolean') {
|
|
97
|
+
state.following = r
|
|
98
|
+
state.phase = 'ready'
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
state.phase = r.code === 'signIn' ? 'signIn' : 'failed'
|
|
102
|
+
state.failure = r
|
|
103
|
+
},
|
|
104
|
+
sync(following) {
|
|
105
|
+
if (!state.busy && state.phase === 'ready') state.following = following
|
|
106
|
+
},
|
|
107
|
+
async toggle() {
|
|
108
|
+
if (state.busy || state.phase !== 'ready' || key === null) return
|
|
109
|
+
const next = !state.following
|
|
110
|
+
state.busy = true
|
|
111
|
+
state.failure = null
|
|
112
|
+
const r = await client.me.follows.set({ target, following: next }, call)
|
|
113
|
+
state.busy = false
|
|
114
|
+
if (disposed) return
|
|
115
|
+
if (r.ok) {
|
|
116
|
+
state.following = r.value.following
|
|
117
|
+
announce({ key: r.value.following ? 'follow.followed' : 'follow.unfollowed' })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (next && r.code === 'notFound') {
|
|
121
|
+
// Following needs a target the member may read now (B16): not available — the button offers nothing more.
|
|
122
|
+
state.phase = 'unavailable'
|
|
123
|
+
announce({ key: 'follow.unavailable' })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (r.code === 'signIn') {
|
|
127
|
+
state.phase = 'signIn'
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
// An unfollow is never refused on its target; what is left is the transport or a quota: the state stays.
|
|
131
|
+
state.failure = r
|
|
132
|
+
announce(failureText(r, lang))
|
|
133
|
+
},
|
|
134
|
+
dispose() {
|
|
135
|
+
disposed = true
|
|
136
|
+
abort.abort()
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-forum-category slug>`'s brain (m3-contracts.md §3.2, §3.3, §5.3): one category — its row of
|
|
3
|
+
* `forum/categories/list` (its description, who opens topics there, its mode, whether the member follows it) and its
|
|
4
|
+
* topics (`forum/topics/list` by the slug, the activity or the newest order, the forum's keyset cursor) — and the
|
|
5
|
+
* new-topic composer where the category takes topics from members (`forum/topics/create`: the title and the opening
|
|
6
|
+
* post as one text; the Russmedia confirmation, the cooldowns, the onboarding with the art. 9 consent a forum write
|
|
7
|
+
* needs, a pre-moderated topic waiting for review). In „Články" (kind `article_discussions`) the rows are the article
|
|
8
|
+
* discussions, and nothing is created there.
|
|
9
|
+
*/
|
|
10
|
+
import { reactive, shallowRef, type ShallowRef } from 'vue'
|
|
11
|
+
import {
|
|
12
|
+
COMMUNITY_MESSAGE_MAX,
|
|
13
|
+
COMMUNITY_TOPICS_PAGE_SIZE,
|
|
14
|
+
forumTopicTitleOf,
|
|
15
|
+
knownTopicItemsOf,
|
|
16
|
+
newClientMsgId,
|
|
17
|
+
type CommunityCallOptions,
|
|
18
|
+
type CommunityClient,
|
|
19
|
+
type CommunityFailure,
|
|
20
|
+
type ForumCategory,
|
|
21
|
+
type ForumSort,
|
|
22
|
+
type KnownTopicItem,
|
|
23
|
+
type MyState,
|
|
24
|
+
} from '@blogmcp/sdk/community'
|
|
25
|
+
import type { Draft } from './discussion'
|
|
26
|
+
import { forumNoticesOf, isForumMember } from './forum-state'
|
|
27
|
+
import type { Lang, TextRef } from './i18n'
|
|
28
|
+
import { createOnboarding, type OnboardingModel } from './onboarding'
|
|
29
|
+
import { failureEffect, failureText, waitUntilOf } from './outcomes'
|
|
30
|
+
import { codePointLength, normalizeBody } from './text'
|
|
31
|
+
|
|
32
|
+
export type CategoryPhase = 'loading' | 'ready' | 'failed' | 'hidden' | 'membersOnly' | 'notFound'
|
|
33
|
+
|
|
34
|
+
/** The new topic's draft: the composer's, with its title. */
|
|
35
|
+
export interface TopicDraft extends Draft {
|
|
36
|
+
title: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CategoryUi {
|
|
40
|
+
phase: CategoryPhase
|
|
41
|
+
failure: CommunityFailure | null
|
|
42
|
+
me: MyState | null
|
|
43
|
+
/** The category's row of the list (null when the list did not name it: the page header stays). */
|
|
44
|
+
category: ForumCategory | null
|
|
45
|
+
/** The page's own header of the category (its id, slug, name, kind). */
|
|
46
|
+
header: { id: string; slug: string; name: string; kind: string } | null
|
|
47
|
+
items: KnownTopicItem[]
|
|
48
|
+
nextCursor: string | null
|
|
49
|
+
sort: ForumSort
|
|
50
|
+
loadingMore: boolean
|
|
51
|
+
/** The list is read again (another order). */
|
|
52
|
+
sorting: boolean
|
|
53
|
+
composer: TopicDraft
|
|
54
|
+
/** The topic the member just opened (its link), and whether it waits for review. */
|
|
55
|
+
created: { conversationId: string; held: boolean } | null
|
|
56
|
+
/** kb-api said the category takes no topic from the member (TOPIC_CREATION_CLOSED). */
|
|
57
|
+
creationClosed: boolean
|
|
58
|
+
/**
|
|
59
|
+
* A write was refused BRAKED — by the forum's own brake (`surface: 'forum'`) or the module's (none named): the composer
|
|
60
|
+
* gives way to the pause until the category is read again.
|
|
61
|
+
*/
|
|
62
|
+
brakedBy: 'forum' | 'module' | null
|
|
63
|
+
clock: number
|
|
64
|
+
announcement: TextRef | null
|
|
65
|
+
announceId: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CategoryController {
|
|
69
|
+
readonly state: CategoryUi
|
|
70
|
+
readonly onboarding: ShallowRef<OnboardingModel | null>
|
|
71
|
+
load(): Promise<void>
|
|
72
|
+
loadMore(): Promise<void>
|
|
73
|
+
setSort(sort: ForumSort): Promise<void>
|
|
74
|
+
/** The member may open a topic here now (the composer shows). */
|
|
75
|
+
canCreate(): boolean
|
|
76
|
+
/** Why the composer does not show where it would: the team's category, a pause, read_only — or null. */
|
|
77
|
+
notCreating(): 'team' | 'paused' | 'readOnly' | null
|
|
78
|
+
create(): Promise<void>
|
|
79
|
+
setConfirmed(code: string, checked: boolean): void
|
|
80
|
+
sendConfirmed(): Promise<void>
|
|
81
|
+
beginOnboarding(options?: { forceArt9?: boolean }): void
|
|
82
|
+
cancelOnboarding(): void
|
|
83
|
+
waitLeft(): number
|
|
84
|
+
dispose(): void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const emptyTopicDraft = (): TopicDraft => ({ title: '', body: '', sending: false, failure: null, confirm: null, waitUntil: null, clientMsgId: null })
|
|
88
|
+
|
|
89
|
+
export function createCategory(o: {
|
|
90
|
+
client: CommunityClient
|
|
91
|
+
slug: string
|
|
92
|
+
lang: Lang
|
|
93
|
+
now?: () => number
|
|
94
|
+
onCreated?(topic: { conversationId: string; held: boolean }): void
|
|
95
|
+
}): CategoryController {
|
|
96
|
+
const { client, lang } = o
|
|
97
|
+
const slug = o.slug.trim().toLowerCase()
|
|
98
|
+
const now = o.now ?? Date.now
|
|
99
|
+
const abort = new AbortController()
|
|
100
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
101
|
+
let disposed = false
|
|
102
|
+
let ticket = 0
|
|
103
|
+
let ticker: ReturnType<typeof setInterval> | null = null
|
|
104
|
+
const onboarding = shallowRef<OnboardingModel | null>(null)
|
|
105
|
+
|
|
106
|
+
const state = reactive<CategoryUi>({
|
|
107
|
+
phase: 'loading',
|
|
108
|
+
failure: null,
|
|
109
|
+
me: null,
|
|
110
|
+
category: null,
|
|
111
|
+
header: null,
|
|
112
|
+
items: [],
|
|
113
|
+
nextCursor: null,
|
|
114
|
+
sort: 'activity',
|
|
115
|
+
loadingMore: false,
|
|
116
|
+
sorting: false,
|
|
117
|
+
composer: emptyTopicDraft(),
|
|
118
|
+
created: null,
|
|
119
|
+
creationClosed: false,
|
|
120
|
+
brakedBy: null,
|
|
121
|
+
clock: now(),
|
|
122
|
+
announcement: null,
|
|
123
|
+
announceId: 0,
|
|
124
|
+
}) as CategoryUi
|
|
125
|
+
|
|
126
|
+
const announce = (text: TextRef): void => {
|
|
127
|
+
state.announcement = text
|
|
128
|
+
state.announceId += 1
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function listFailed(f: CommunityFailure): void {
|
|
132
|
+
state.failure = f
|
|
133
|
+
if (f.code === 'surfaceOff') state.phase = 'hidden'
|
|
134
|
+
else if (f.code === 'membersOnly') state.phase = 'membersOnly'
|
|
135
|
+
else if (f.code === 'notFound') state.phase = 'notFound'
|
|
136
|
+
else if (state.phase !== 'ready') state.phase = 'failed'
|
|
137
|
+
else announce(failureText(f, lang))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The first page of the category's topics in the current order. */
|
|
141
|
+
async function firstPage(mine: number): Promise<boolean> {
|
|
142
|
+
const r = await client.forum.topics.list({ categorySlug: slug, sort: state.sort, limit: COMMUNITY_TOPICS_PAGE_SIZE }, call)
|
|
143
|
+
if (disposed || mine !== ticket) return false
|
|
144
|
+
if (!r.ok) {
|
|
145
|
+
listFailed(r)
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
state.header = { ...r.value.category }
|
|
149
|
+
state.items = knownTopicItemsOf(r.value.items)
|
|
150
|
+
state.nextCursor = r.value.nextCursor
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function load(): Promise<void> {
|
|
155
|
+
const mine = ++ticket
|
|
156
|
+
state.phase = 'loading'
|
|
157
|
+
state.failure = null
|
|
158
|
+
const [me, list, page] = await Promise.all([client.me.get(call), client.forum.categories.list(call), firstPage(mine)])
|
|
159
|
+
if (disposed || mine !== ticket) return
|
|
160
|
+
if (me.ok) state.me = me.value
|
|
161
|
+
if (list.ok) state.category = list.value.categories.find((c) => c.slug === slug) ?? null
|
|
162
|
+
state.brakedBy = null
|
|
163
|
+
if (page) state.phase = 'ready'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function startClock(): void {
|
|
167
|
+
state.clock = now()
|
|
168
|
+
if (ticker) return
|
|
169
|
+
ticker = setInterval(() => {
|
|
170
|
+
state.clock = now()
|
|
171
|
+
const d = state.composer
|
|
172
|
+
if (d.waitUntil !== null && d.waitUntil <= state.clock) {
|
|
173
|
+
d.waitUntil = null
|
|
174
|
+
if (d.failure && (d.failure.code === 'fast' || d.failure.code === 'quota')) d.failure = null
|
|
175
|
+
}
|
|
176
|
+
if (d.waitUntil === null && ticker) {
|
|
177
|
+
clearInterval(ticker)
|
|
178
|
+
ticker = null
|
|
179
|
+
}
|
|
180
|
+
}, 1000)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const waitLeft = (): number => {
|
|
184
|
+
const d = state.composer
|
|
185
|
+
return d.waitUntil === null ? 0 : Math.max(0, Math.ceil((d.waitUntil - Math.max(state.clock, now())) / 1000))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isTopics = (): boolean => (state.category?.kind ?? state.header?.kind) === 'topics'
|
|
189
|
+
|
|
190
|
+
function notCreating(): 'team' | 'paused' | 'readOnly' | null {
|
|
191
|
+
if (!isTopics() || !isForumMember(state.me)) return null
|
|
192
|
+
if (state.category?.topicCreation !== 'members' || state.creationClosed) return 'team'
|
|
193
|
+
const notices = forumNoticesOf(state.me, now())
|
|
194
|
+
if (state.brakedBy !== null || notices.paused !== null) return 'paused'
|
|
195
|
+
if (notices.readOnly) return 'readOnly'
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const canCreate = (): boolean => state.phase === 'ready' && isTopics() && isForumMember(state.me) && state.category?.topicCreation === 'members' && notCreating() === null
|
|
200
|
+
|
|
201
|
+
function beginOnboarding(options: { forceArt9?: boolean } = {}): void {
|
|
202
|
+
if (onboarding.value || disposed) return
|
|
203
|
+
const model = createOnboarding({
|
|
204
|
+
client,
|
|
205
|
+
lang,
|
|
206
|
+
signal: abort.signal,
|
|
207
|
+
// A forum write needs the art. 9 consent whatever the tenant asks elsewhere (U14).
|
|
208
|
+
forceArt9: options.forceArt9 !== false,
|
|
209
|
+
onDone: (me) => {
|
|
210
|
+
state.me = me
|
|
211
|
+
onboarding.value = null
|
|
212
|
+
announce({ key: model.state.art9RefusedSent ? 'onboarding.art9RefusedDone' : 'onboarding.done' })
|
|
213
|
+
},
|
|
214
|
+
onSignIn: () => {
|
|
215
|
+
onboarding.value = null
|
|
216
|
+
void load()
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
onboarding.value = model
|
|
220
|
+
void model.load()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function send(confirmations?: string[]): Promise<void> {
|
|
224
|
+
const d = state.composer
|
|
225
|
+
if (d.sending || waitLeft() > 0 || disposed || !state.category) return
|
|
226
|
+
const title = forumTopicTitleOf(d.title)
|
|
227
|
+
const body = normalizeBody(d.body)
|
|
228
|
+
if (title === null || body === '' || codePointLength(body) > COMMUNITY_MESSAGE_MAX) return
|
|
229
|
+
d.sending = true
|
|
230
|
+
d.failure = null
|
|
231
|
+
const clientMsgId = (d.clientMsgId ??= newClientMsgId())
|
|
232
|
+
const r = await client.forum.topics.create(
|
|
233
|
+
{ categoryId: state.category.id, title, body, clientMsgId, languageCode: lang, ...(confirmations ? { confirmations } : {}) },
|
|
234
|
+
call,
|
|
235
|
+
)
|
|
236
|
+
if (disposed) return
|
|
237
|
+
d.sending = false
|
|
238
|
+
if (r.ok) {
|
|
239
|
+
const held = r.value.message.state === 'held'
|
|
240
|
+
state.composer = emptyTopicDraft()
|
|
241
|
+
state.created = { conversationId: r.value.topic.conversationId, held }
|
|
242
|
+
announce({ key: held ? 'topic.createdHeld' : 'topic.created' })
|
|
243
|
+
o.onCreated?.(state.created)
|
|
244
|
+
// The member's new topic heads the list (their own held one included: its author sees it, „čeká na kontrolu").
|
|
245
|
+
const mine = ++ticket
|
|
246
|
+
await firstPage(mine)
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
d.failure = r
|
|
250
|
+
switch (failureEffect(r.code)) {
|
|
251
|
+
case 'confirm':
|
|
252
|
+
if (r.codes && r.codes.length > 0) d.confirm = { codes: [...r.codes], checked: [] }
|
|
253
|
+
return
|
|
254
|
+
case 'wait':
|
|
255
|
+
d.waitUntil = waitUntilOf(r, now())
|
|
256
|
+
if (d.waitUntil !== null) startClock()
|
|
257
|
+
if (r.code === 'braked') state.brakedBy = r.surface === 'forum' ? 'forum' : 'module'
|
|
258
|
+
return
|
|
259
|
+
case 'onboard':
|
|
260
|
+
beginOnboarding({ forceArt9: true })
|
|
261
|
+
return
|
|
262
|
+
default:
|
|
263
|
+
if (r.code === 'topicCreationClosed') state.creationClosed = true
|
|
264
|
+
else if (r.code === 'membersOnly' || r.code === 'surfaceOff' || r.code === 'notFound') listFailed(r)
|
|
265
|
+
else if (r.code === 'readOnly' || r.code === 'signIn' || r.code === 'restricted' || r.code === 'suspended' || r.code === 'terminated') {
|
|
266
|
+
const me = await client.me.get(call)
|
|
267
|
+
if (!disposed && me.ok) state.me = me.value
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
state,
|
|
274
|
+
onboarding,
|
|
275
|
+
load,
|
|
276
|
+
async loadMore() {
|
|
277
|
+
if (state.loadingMore || state.nextCursor === null || state.phase !== 'ready') return
|
|
278
|
+
const mine = ticket
|
|
279
|
+
state.loadingMore = true
|
|
280
|
+
const r = await client.forum.topics.list({ categorySlug: slug, sort: state.sort, cursor: state.nextCursor, limit: COMMUNITY_TOPICS_PAGE_SIZE }, call)
|
|
281
|
+
state.loadingMore = false
|
|
282
|
+
if (disposed || mine !== ticket) return
|
|
283
|
+
if (!r.ok) return listFailed(r)
|
|
284
|
+
const seen = new Set(state.items.map((i) => i.conversationId))
|
|
285
|
+
state.items = [...state.items, ...knownTopicItemsOf(r.value.items).filter((i) => !seen.has(i.conversationId))]
|
|
286
|
+
state.nextCursor = r.value.nextCursor
|
|
287
|
+
},
|
|
288
|
+
async setSort(sort) {
|
|
289
|
+
if (state.sort === sort || state.phase !== 'ready') return
|
|
290
|
+
state.sort = sort
|
|
291
|
+
const mine = ++ticket
|
|
292
|
+
state.sorting = true
|
|
293
|
+
await firstPage(mine)
|
|
294
|
+
if (mine === ticket) state.sorting = false
|
|
295
|
+
},
|
|
296
|
+
canCreate,
|
|
297
|
+
notCreating,
|
|
298
|
+
create: () => send(),
|
|
299
|
+
setConfirmed(code, checked) {
|
|
300
|
+
const c = state.composer.confirm
|
|
301
|
+
if (!c || !c.codes.includes(code)) return
|
|
302
|
+
const rest = c.checked.filter((x) => x !== code)
|
|
303
|
+
c.checked = checked ? [...rest, code] : rest
|
|
304
|
+
},
|
|
305
|
+
async sendConfirmed() {
|
|
306
|
+
const c = state.composer.confirm
|
|
307
|
+
if (!c || !c.codes.every((code) => c.checked.includes(code))) return
|
|
308
|
+
await send([...c.codes])
|
|
309
|
+
},
|
|
310
|
+
beginOnboarding,
|
|
311
|
+
cancelOnboarding() {
|
|
312
|
+
onboarding.value = null
|
|
313
|
+
},
|
|
314
|
+
waitLeft,
|
|
315
|
+
dispose() {
|
|
316
|
+
disposed = true
|
|
317
|
+
abort.abort()
|
|
318
|
+
if (ticker) clearInterval(ticker)
|
|
319
|
+
ticker = null
|
|
320
|
+
onboarding.value = null
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-forum-search>`'s brain (m3-contracts.md §3.2, §4.4, §5.3): the forum's search — topic titles and visible
|
|
3
|
+
* messages of topics and article discussions, for a member (anyone else: `membersOnly`), 20 hits a page and five pages
|
|
4
|
+
* at most (no deeper paging, no bulk read), a quota per call. The query is checked before it goes (forumSearchQueryOf:
|
|
5
|
+
* kb-api would refuse it and spend the quota); a hit is plain text — its title (null when withheld from the viewer)
|
|
6
|
+
* and a snippet of at most 200 characters. The forum's search box brings the query in the page's fragment (`#q=…`),
|
|
7
|
+
* which never reaches a server.
|
|
8
|
+
*/
|
|
9
|
+
import { reactive } from 'vue'
|
|
10
|
+
import {
|
|
11
|
+
COMMUNITY_SEARCH_QUERY_MAX,
|
|
12
|
+
forumSearchQueryOf,
|
|
13
|
+
nextSearchPage,
|
|
14
|
+
type CommunityCallOptions,
|
|
15
|
+
type CommunityClient,
|
|
16
|
+
type CommunityFailure,
|
|
17
|
+
type ForumCategory,
|
|
18
|
+
type ForumSearchResult,
|
|
19
|
+
type KnownForumSpace,
|
|
20
|
+
} from '@blogmcp/sdk/community'
|
|
21
|
+
|
|
22
|
+
export type SearchPhase = 'idle' | 'searching' | 'ready' | 'failed' | 'hidden' | 'membersOnly'
|
|
23
|
+
|
|
24
|
+
export interface SearchUi {
|
|
25
|
+
phase: SearchPhase
|
|
26
|
+
failure: CommunityFailure | null
|
|
27
|
+
query: string
|
|
28
|
+
/** '' every space. */
|
|
29
|
+
space: KnownForumSpace | ''
|
|
30
|
+
/** '' every category. */
|
|
31
|
+
categoryId: string
|
|
32
|
+
/** The categories to narrow by (read once). */
|
|
33
|
+
categories: ForumCategory[]
|
|
34
|
+
/** The query is too short or too long to ask (said, nothing sent). */
|
|
35
|
+
invalid: 'short' | 'long' | null
|
|
36
|
+
results: ForumSearchResult[]
|
|
37
|
+
page: number
|
|
38
|
+
hasMore: boolean
|
|
39
|
+
loadingMore: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SearchController {
|
|
43
|
+
readonly state: SearchUi
|
|
44
|
+
/** The categories for the filter (and the forum's own refusals: a visitor learns the forum is for members). */
|
|
45
|
+
load(): Promise<void>
|
|
46
|
+
search(): Promise<void>
|
|
47
|
+
more(): Promise<void>
|
|
48
|
+
dispose(): void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The query a page's fragment carries (`#q=<encoded>`), or null. */
|
|
52
|
+
export function queryFromHash(hash: string | null | undefined): string | null {
|
|
53
|
+
if (typeof hash !== 'string' || !hash.startsWith('#')) return null
|
|
54
|
+
for (const part of hash.slice(1).split('&')) {
|
|
55
|
+
if (!part.startsWith('q=')) continue
|
|
56
|
+
try {
|
|
57
|
+
const q = decodeURIComponent(part.slice(2).replace(/\+/g, ' '))
|
|
58
|
+
return q.length <= COMMUNITY_SEARCH_QUERY_MAX * 2 ? q : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The search page's link with the query in its fragment (the forum's search box), or null. */
|
|
67
|
+
export function searchLink(searchPath: string, raw: string): string | null {
|
|
68
|
+
const q = forumSearchQueryOf(raw)
|
|
69
|
+
if (q === null || searchPath.includes('#')) return null
|
|
70
|
+
return `${searchPath}#q=${encodeURIComponent(q)}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createSearch(o: { client: CommunityClient; query?: string | null }): SearchController {
|
|
74
|
+
const { client } = o
|
|
75
|
+
const abort = new AbortController()
|
|
76
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
77
|
+
let disposed = false
|
|
78
|
+
let ticket = 0
|
|
79
|
+
const state = reactive<SearchUi>({
|
|
80
|
+
phase: 'idle',
|
|
81
|
+
failure: null,
|
|
82
|
+
query: o.query ?? '',
|
|
83
|
+
space: '',
|
|
84
|
+
categoryId: '',
|
|
85
|
+
categories: [],
|
|
86
|
+
invalid: null,
|
|
87
|
+
results: [],
|
|
88
|
+
page: 0,
|
|
89
|
+
hasMore: false,
|
|
90
|
+
loadingMore: false,
|
|
91
|
+
}) as SearchUi
|
|
92
|
+
|
|
93
|
+
const refused = (f: CommunityFailure): void => {
|
|
94
|
+
state.failure = f
|
|
95
|
+
state.phase = f.code === 'surfaceOff' ? 'hidden' : f.code === 'membersOnly' ? 'membersOnly' : 'failed'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What the results on the page were asked with: „more" pages them, whatever was typed or chosen since. */
|
|
99
|
+
let asked: { query: string; categoryId?: string; space?: KnownForumSpace } | null = null
|
|
100
|
+
|
|
101
|
+
async function search(): Promise<void> {
|
|
102
|
+
const q = forumSearchQueryOf(state.query)
|
|
103
|
+
if (q === null) {
|
|
104
|
+
state.invalid = state.query.trim().length > COMMUNITY_SEARCH_QUERY_MAX ? 'long' : 'short'
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
state.invalid = null
|
|
108
|
+
const mine = ++ticket
|
|
109
|
+
state.phase = 'searching'
|
|
110
|
+
state.failure = null
|
|
111
|
+
const input = { query: q, ...(state.categoryId !== '' ? { categoryId: state.categoryId } : {}), ...(state.space !== '' ? { space: state.space } : {}) }
|
|
112
|
+
asked = input
|
|
113
|
+
const r = await client.forum.search({ ...input, page: 1 }, call)
|
|
114
|
+
if (disposed || mine !== ticket) return
|
|
115
|
+
if (!r.ok) return refused(r)
|
|
116
|
+
state.results = [...r.value.results]
|
|
117
|
+
state.page = r.value.page
|
|
118
|
+
state.hasMore = r.value.hasMore
|
|
119
|
+
state.phase = 'ready'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
state,
|
|
124
|
+
async load() {
|
|
125
|
+
const r = await client.forum.categories.list(call)
|
|
126
|
+
if (disposed) return
|
|
127
|
+
if (r.ok) state.categories = r.value.categories.filter((c) => c.kind === 'topics')
|
|
128
|
+
else if (r.code === 'membersOnly' || r.code === 'surfaceOff') refused(r)
|
|
129
|
+
if (forumSearchQueryOf(state.query) !== null && state.phase === 'idle') await search()
|
|
130
|
+
},
|
|
131
|
+
search,
|
|
132
|
+
async more() {
|
|
133
|
+
const next = nextSearchPage({ page: state.page, hasMore: state.hasMore })
|
|
134
|
+
if (next === null || asked === null || state.loadingMore || state.phase !== 'ready') return
|
|
135
|
+
const mine = ticket
|
|
136
|
+
state.loadingMore = true
|
|
137
|
+
const r = await client.forum.search({ ...asked, page: next }, call)
|
|
138
|
+
state.loadingMore = false
|
|
139
|
+
if (disposed || mine !== ticket) return
|
|
140
|
+
if (!r.ok) {
|
|
141
|
+
state.failure = r
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
state.results = [...state.results, ...r.value.results]
|
|
145
|
+
state.page = r.value.page
|
|
146
|
+
state.hasMore = r.value.hasMore
|
|
147
|
+
},
|
|
148
|
+
dispose() {
|
|
149
|
+
disposed = true
|
|
150
|
+
abort.abort()
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -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')
|