@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,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
|
+
}
|
package/src/core/format.ts
CHANGED
|
@@ -31,6 +31,28 @@ export function formatDate(lang: Lang, iso: string | null | undefined, withTime
|
|
|
31
31
|
return format(lang, withTime)?.format(new Date(at)) ?? iso
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const months = new Map<Lang, Intl.DateTimeFormat | null>()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A month as `profile/get`'s `memberSince` gives it (`YYYY-MM`, UTC) in the reader's language, in the form a date
|
|
38
|
+
* takes inside a sentence („Členem od října 2026", "Member since October 2026"): Intl formats the first of the month
|
|
39
|
+
* and the day is left out — a month on its own would be the nominative („říjen"). The value as it came when Intl has
|
|
40
|
+
* no words, '' for anything that is no month.
|
|
41
|
+
*/
|
|
42
|
+
export function formatMonth(lang: Lang, ym: string | null | undefined): string {
|
|
43
|
+
if (typeof ym !== 'string' || !/^\d{4}-(?:0[1-9]|1[0-2])$/.test(ym)) return ''
|
|
44
|
+
const f = cached(months, lang, (locale) => new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }))
|
|
45
|
+
try {
|
|
46
|
+
const parts = f?.formatToParts(new Date(`${ym}-01T00:00:00.000Z`))
|
|
47
|
+
if (!parts) return ym
|
|
48
|
+
const day = parts.findIndex((p) => p.type === 'day')
|
|
49
|
+
const kept = parts.filter((_p, i) => i !== day && !(i === day + 1 && parts[i]!.type === 'literal'))
|
|
50
|
+
return kept.map((p) => p.value).join('').trim() || ym
|
|
51
|
+
} catch {
|
|
52
|
+
return ym
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/** Whether an ISO timestamp lies in the past (an appeal window, a sanction's end). */
|
|
35
57
|
export function isPast(iso: string | null | undefined, now: number = Date.now()): boolean {
|
|
36
58
|
if (typeof iso !== 'string') return false
|
|
@@ -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
|
+
}
|