@blogmcp/community 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. package/dist/chunks/mount-DzjwnHyx.js +0 -4597
@@ -0,0 +1,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
+ }
@@ -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
+ }