@blogmcp/community 0.0.0-stage → 0.1.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-DzjwnHyx.js +4597 -0
  5. package/dist/core/attrs.d.ts +73 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/client.d.ts +27 -0
  8. package/dist/core/discussion.d.ts +144 -0
  9. package/dist/core/download.d.ts +4 -0
  10. package/dist/core/format.d.ts +17 -0
  11. package/dist/core/i18n.d.ts +28 -0
  12. package/dist/core/names.d.ts +15 -0
  13. package/dist/core/notice.d.ts +50 -0
  14. package/dist/core/onboarding.d.ts +52 -0
  15. package/dist/core/outcomes.d.ts +28 -0
  16. package/dist/core/panel.d.ts +80 -0
  17. package/dist/core/receipt.d.ts +5 -0
  18. package/dist/core/text.d.ts +22 -0
  19. package/dist/core/turnstile.d.ts +33 -0
  20. package/dist/element.d.ts +26 -0
  21. package/dist/global.d.ts +8 -0
  22. package/dist/index.d.ts +25 -0
  23. package/dist/index.js +22 -0
  24. package/dist/mount.d.ts +65 -0
  25. package/dist/react.d.ts +17 -0
  26. package/dist/react.js +35 -0
  27. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  28. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  29. package/dist/ui/DiscussionView.vue.d.ts +33 -0
  30. package/dist/ui/LinkedText.vue.d.ts +5 -0
  31. package/dist/ui/MessageComposer.vue.d.ts +24 -0
  32. package/dist/ui/MessageItem.vue.d.ts +6 -0
  33. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  34. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  35. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  36. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  37. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  38. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  39. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  40. package/dist/ui/StatementView.vue.d.ts +15 -0
  41. package/dist/ui/context.d.ts +21 -0
  42. package/dist/ui/discussion-context.d.ts +19 -0
  43. package/dist/ui/types.d.ts +7 -0
  44. package/dist/version.d.ts +1 -0
  45. package/dist/vue.d.ts +89 -0
  46. package/dist/vue.js +97 -0
  47. package/package.json +80 -4
  48. package/src/core/attrs.ts +147 -0
  49. package/src/core/avatars.ts +44 -0
  50. package/src/core/client.ts +54 -0
  51. package/src/core/discussion.ts +691 -0
  52. package/src/core/download.ts +21 -0
  53. package/src/core/format.ts +90 -0
  54. package/src/core/i18n.ts +49 -0
  55. package/src/core/names.ts +51 -0
  56. package/src/core/notice.ts +178 -0
  57. package/src/core/onboarding.ts +345 -0
  58. package/src/core/outcomes.ts +74 -0
  59. package/src/core/panel.ts +411 -0
  60. package/src/core/receipt.ts +28 -0
  61. package/src/core/text.ts +142 -0
  62. package/src/core/turnstile.ts +98 -0
  63. package/src/element.ts +109 -0
  64. package/src/global.ts +12 -0
  65. package/src/index.ts +35 -0
  66. package/src/mount.ts +127 -0
  67. package/src/react.ts +53 -0
  68. package/src/shims.d.ts +11 -0
  69. package/src/ui/AuthorAvatar.vue +26 -0
  70. package/src/ui/ConfirmDialog.vue +38 -0
  71. package/src/ui/DiscussionView.vue +294 -0
  72. package/src/ui/LinkedText.vue +18 -0
  73. package/src/ui/MessageComposer.vue +130 -0
  74. package/src/ui/MessageItem.vue +205 -0
  75. package/src/ui/MessageMenu.vue +67 -0
  76. package/src/ui/MyCommunityView.vue +369 -0
  77. package/src/ui/NoticeFormView.vue +244 -0
  78. package/src/ui/NoticeStatusView.vue +82 -0
  79. package/src/ui/OnboardingSteps.vue +163 -0
  80. package/src/ui/ReactionBar.vue +41 -0
  81. package/src/ui/ReportDialog.vue +88 -0
  82. package/src/ui/StatementView.vue +141 -0
  83. package/src/ui/context.ts +75 -0
  84. package/src/ui/discussion-context.ts +30 -0
  85. package/src/ui/styles.css +243 -0
  86. package/src/ui/types.ts +7 -0
  87. package/src/version.ts +3 -0
  88. package/src/vue.ts +116 -0
@@ -0,0 +1,294 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A refused age band, per element: kb-api keeps nothing of it (§7.4.2), so the element does, for its whole life —
4
+ * a discussion started again (another language) and the element put back by its host included.
5
+ */
6
+ const refusedAges = new WeakMap<object, number>()
7
+ </script>
8
+
9
+ <script setup lang="ts">
10
+ /**
11
+ * `<blogmcp-discussion>`: the discussion of one article (m1-contracts.md §10.4). Lives in a shadow root; talks
12
+ * only to the host's relative `api-base` through the SDK client; renders nothing when discussions are off,
13
+ * and one sentence while the discussion takes no writes yet.
14
+ */
15
+ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useHost, watch } from 'vue'
16
+ import { signInLink, statementPath, type CommunityTransport, type DiscussionRestriction } from '@blogmcp/sdk/community'
17
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
18
+ import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
19
+ import { createDiscussionController, type DiscussionController } from '../core/discussion'
20
+ import { formatDate } from '../core/format'
21
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
22
+ import { failureText } from '../core/outcomes'
23
+ import ConfirmDialog from './ConfirmDialog.vue'
24
+ import MessageComposer from './MessageComposer.vue'
25
+ import MessageItem from './MessageItem.vue'
26
+ import OnboardingSteps from './OnboardingSteps.vue'
27
+ import ReactionBar from './ReactionBar.vue'
28
+ import ReportDialog from './ReportDialog.vue'
29
+ import { provideText, uid } from './context'
30
+ import { provideDiscussion } from './discussion-context'
31
+
32
+ defineOptions({ inheritAttrs: false })
33
+
34
+ const props = defineProps<{
35
+ articleId?: string
36
+ /** The host's relative base (default `${siteBase}/api/community`). */
37
+ apiBase?: string
38
+ /** The path the site is served under (`/blog` on a managed blog): its proxy and its pages, as the defaults. */
39
+ siteBase?: string
40
+ lang?: string
41
+ /** The site's sign-in page; the current path goes in its fragment. */
42
+ signInHref?: string
43
+ /** JSON (or an object): `statementPath`, `myCommunityPath`, `rulesPath`, `reportPath`, `noticesPath`. */
44
+ routes?: string | Record<string, unknown>
45
+ /** Head poll interval in ms (default 20 000; 0 = off). */
46
+ pollMs?: number
47
+ /** A replacement transport (a property, never an attribute): tests, a signing transport later. */
48
+ transport?: CommunityTransport
49
+ }>()
50
+
51
+ const host = useHost()
52
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
53
+ const { t, text } = provideText(lang)
54
+ watch(
55
+ () => props.lang,
56
+ (v) => {
57
+ lang.value = pickLang(v, documentLang())
58
+ },
59
+ )
60
+
61
+ const headingId = uid('bcm-discussion')
62
+ const heading = ref<HTMLElement | null>(null)
63
+ const generation = ref(0)
64
+ const current = shallowRef<DiscussionController | null>(null)
65
+ const siteBase = computed(() => siteBaseFor(props.siteBase))
66
+
67
+ function create(): DiscussionController | null {
68
+ const articleId = props.articleId?.trim()
69
+ if (!articleId) {
70
+ warnOnce('article-id', '[blogmcp-community] <blogmcp-discussion> needs an article-id')
71
+ return null
72
+ }
73
+ if (siteBase.value === null) return null
74
+ const client = communityClientFor(props.apiBase, props.transport, siteBase.value)
75
+ if (!client) return null
76
+ return createDiscussionController({
77
+ client,
78
+ articleId,
79
+ lang: lang.value,
80
+ pollMs: props.pollMs,
81
+ refusedAge: (host && refusedAges.get(host)) ?? null,
82
+ onAgeRefused: (minAge) => {
83
+ if (host) refusedAges.set(host, minAge)
84
+ },
85
+ onPosted: (state) => host?.dispatchEvent(new CustomEvent('bcm-posted', { detail: { state }, bubbles: true, composed: true })),
86
+ })
87
+ }
88
+
89
+ const opened = reactive(new Set<number>())
90
+ const originals = reactive(new Set<number>())
91
+ const allReplies = reactive(new Set<number>())
92
+ const deleting = ref<number | null>(null)
93
+
94
+ const parsedRoutes = computed(() => parseRoutes(props.routes))
95
+ const routes = computed(() => routesWithDefaults(parsedRoutes.value, current.value?.state.me?.links, siteBase.value ?? ''))
96
+
97
+ provideDiscussion({
98
+ get ctl() {
99
+ return current.value as DiscussionController
100
+ },
101
+ routes,
102
+ opened,
103
+ originals,
104
+ allReplies,
105
+ focusHeading: () => heading.value,
106
+ deleteMessage: (seq) => {
107
+ deleting.value = seq
108
+ },
109
+ })
110
+
111
+ let observer: IntersectionObserver | null = null
112
+
113
+ function start(): void {
114
+ current.value?.dispose()
115
+ opened.clear()
116
+ originals.clear()
117
+ allReplies.clear()
118
+ deleting.value = null
119
+ current.value = create()
120
+ generation.value += 1
121
+ void current.value?.start()
122
+ }
123
+
124
+ onMounted(() => {
125
+ start()
126
+ if (host && typeof IntersectionObserver !== 'undefined') {
127
+ observer = new IntersectionObserver((entries) => {
128
+ const e = entries[entries.length - 1]
129
+ if (e) current.value?.setInView(e.isIntersecting)
130
+ }, { rootMargin: '200px' })
131
+ observer.observe(host)
132
+ }
133
+ })
134
+ // Another article, base or language is another discussion (the language picks the translations and the posts' languageCode).
135
+ watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.pollMs, lang.value] as const, () => start())
136
+ onBeforeUnmount(() => {
137
+ observer?.disconnect()
138
+ current.value?.dispose()
139
+ current.value = null
140
+ })
141
+
142
+ const ctl = computed(() => current.value)
143
+ const state = computed(() => current.value?.state ?? null)
144
+ const mode = computed(() => current.value?.mode() ?? 'pending')
145
+ const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
146
+ // Joined: the reader goes on where the onboarding was, in the composer. A refusal closed: to the discussion's heading
147
+ // (the onboarding's button the focus was on is gone).
148
+ watch(mode, (now, before) => {
149
+ if (now === 'write' && before === 'onboarding') void nextTick(() => composer.value?.focus())
150
+ if (now === 'ageRefused' && before === 'onboarding') void nextTick(() => heading.value?.focus())
151
+ })
152
+ const needKey = computed(() => {
153
+ const n = current.value?.needs()
154
+ return n?.onboarding ? 'need.onboarding' : n?.rules ? 'need.reconsent_rules' : 'need.art9_consent'
155
+ })
156
+
157
+ function signInHref(): string | null {
158
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
159
+ }
160
+
161
+ function sanctionText(r: DiscussionRestriction): string {
162
+ const date = r.endsAt ? formatDate(lang.value, r.endsAt) : ''
163
+ if (r.kind === 'ban') return t('sanction.ban')
164
+ if (r.kind === 'suspension') return date ? t('sanction.suspensionUntil', { date }) : t('sanction.suspension')
165
+ return date ? t('sanction.restrictionUntil', { date }) : t('sanction.restriction')
166
+ }
167
+
168
+ const loadFailure = computed(() => {
169
+ const f = state.value?.failure
170
+ if (!f) return ''
171
+ const r = failureText(f, lang.value)
172
+ return t(r.key, r.params)
173
+ })
174
+
175
+ // One live region, present from the start; the same outcome twice is announced twice (cleared in between).
176
+ const liveText = ref('')
177
+ watch(
178
+ () => state.value?.announceId,
179
+ () => {
180
+ liveText.value = ''
181
+ const next = text(state.value?.announcement)
182
+ void nextTick(() => {
183
+ liveText.value = next
184
+ })
185
+ },
186
+ )
187
+
188
+ async function confirmDelete(): Promise<void> {
189
+ const seq = deleting.value
190
+ if (seq === null || !current.value) return
191
+ await current.value.remove(seq)
192
+ deleting.value = null
193
+ }
194
+ </script>
195
+
196
+ <template>
197
+ <section v-if="ctl && state && state.phase !== 'hidden'" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
198
+ <header class="bcm-head">
199
+ <h2 :id="headingId" ref="heading" tabindex="-1">{{ t('discussion.title') }}</h2>
200
+ <span v-if="state.phase === 'ready'" class="bcm-count">{{ t('discussion.count', { count: state.thread.count }) }}</span>
201
+ </header>
202
+
203
+ <p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('discussion.loading') }}</p>
204
+
205
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
206
+ <span>{{ loadFailure }}</span>
207
+ <button type="button" class="bcm-btn" @click="ctl.refresh()">{{ t('common.retry') }}</button>
208
+ </div>
209
+
210
+ <template v-else>
211
+ <ReactionBar
212
+ :reaction-set="state.thread.reactionSet"
213
+ :summary="state.thread.articleReactions"
214
+ :interactive="ctl.canReact()"
215
+ :busy="!!state.busy['react:article']"
216
+ @react="(emoji: string) => ctl && ctl.react({ articleId: state!.thread.articleId ?? articleId ?? '' }, emoji)"
217
+ />
218
+
219
+ <div v-if="state.newMessages" class="bcm-banner" role="status">
220
+ <span>{{ t('discussion.newMessages') }}</span>
221
+ <button type="button" class="bcm-btn" :disabled="state.refreshing" @click="ctl.refresh()">{{ t('discussion.refresh') }}</button>
222
+ </div>
223
+
224
+ <p v-if="state.thread.messages.length === 0" class="bcm-muted">{{ t('discussion.empty') }}</p>
225
+ <ol v-else class="bcm-thread">
226
+ <MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
227
+ </ol>
228
+
229
+ <div v-if="state.thread.nextCursor !== null" class="bcm-row">
230
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('discussion.loadMore') }}</button>
231
+ </div>
232
+
233
+ <div class="bcm-section">
234
+ <MessageComposer
235
+ v-if="mode === 'write'"
236
+ ref="composer"
237
+ kind="new"
238
+ :draft="state.composer"
239
+ :wait="ctl.waitLeft(state.composer)"
240
+ :rules-href="routes.rulesPath"
241
+ @submit="ctl.submit()"
242
+ @confirm="(code: string, checked: boolean) => ctl && ctl.setConfirmed(state!.composer, code, checked)"
243
+ @confirmed="ctl.sendConfirmed(state.composer)"
244
+ />
245
+ <div v-else-if="mode === 'signIn'" class="bcm-row">
246
+ <p>{{ t('discussion.signInPrompt') }}</p>
247
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
248
+ </div>
249
+ <template v-else-if="mode === 'onboarding'">
250
+ <OnboardingSteps v-if="ctl.onboarding.value" :model="ctl.onboarding.value" :rules-href="routes.rulesPath" @cancel="ctl.cancelOnboarding()" />
251
+ <div v-else class="bcm-row">
252
+ <p>{{ t(needKey) }}</p>
253
+ <button type="button" class="bcm-btn bcm-primary" @click="ctl.beginOnboarding()">{{ t('onboarding.continue') }}</button>
254
+ </div>
255
+ </template>
256
+ <p v-else-if="mode === 'ageRefused'" class="bcm-note">{{ t('onboarding.ageBelow', { min: state.refusedAge ?? 18 }) }}</p>
257
+ <div v-else-if="mode === 'sanction'" class="bcm-section">
258
+ <p v-for="r in state.thread.viewer.restrictions" :key="r.decisionId" class="bcm-note">
259
+ {{ sanctionText(r) }}
260
+ <a v-if="statementPath(routes.statementPath, r.decisionId)" :href="statementPath(routes.statementPath, r.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
261
+ </p>
262
+ </div>
263
+ <p v-else-if="mode === 'notMoved'" class="bcm-note">{{ t('error.notMoved') }}</p>
264
+ <p v-else-if="mode === 'restricted'" class="bcm-note">
265
+ {{ t('error.restricted') }}
266
+ <a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
267
+ </p>
268
+ </div>
269
+ </template>
270
+
271
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
272
+
273
+ <ReportDialog
274
+ v-if="state.report"
275
+ :report="state.report"
276
+ :my-community-href="routes.myCommunityPath"
277
+ :rules-href="routes.rulesPath"
278
+ :fallback-focus="() => heading"
279
+ @submit="ctl.submitReport()"
280
+ @close="ctl.closeReport()"
281
+ />
282
+ <ConfirmDialog
283
+ v-if="deleting !== null"
284
+ title="discussion.deleteTitle"
285
+ explain="discussion.deleteExplain"
286
+ confirm="discussion.deleteConfirm"
287
+ danger
288
+ :busy="!!state.busy[`delete:${deleting}`]"
289
+ :fallback-focus="() => heading"
290
+ @confirm="confirmDelete()"
291
+ @cancel="deleting = null"
292
+ />
293
+ </section>
294
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A member's text, as text: runs of text nodes, and the web addresses in it as links that went through
4
+ * sanitizeUrl (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab. Never HTML.
5
+ */
6
+ import { computed } from 'vue'
7
+ import { linkify, UGC_LINK_REL } from '../core/text'
8
+
9
+ const props = defineProps<{ text: string | null }>()
10
+ const segments = computed(() => linkify(props.text ?? ''))
11
+ </script>
12
+
13
+ <template>
14
+ <template v-for="(s, i) in segments" :key="i">
15
+ <a v-if="s.kind === 'link'" :href="s.href" :rel="UGC_LINK_REL" target="_blank">{{ s.text }}</a>
16
+ <template v-else>{{ s.text }}</template>
17
+ </template>
18
+ </template>
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The composer of a new message, a reply or an edit: 4 000 characters with a counter (announced near the
4
+ * end), the links note, a link to the rules (community.md §4.5: by every composer; a new tab, so the text
5
+ * stays), Cmd/Ctrl+Enter, the cooldown after TOO_FAST or a quota, and the Russmedia confirmation when kb-api
6
+ * asks for one (the same text goes again with `confirmations`).
7
+ */
8
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
9
+ import { COMMUNITY_MESSAGE_LINKS_MAX, COMMUNITY_MESSAGE_MAX, confirmMessageKey } from '@blogmcp/sdk/community'
10
+ import type { Draft } from '../core/discussion'
11
+ import { COUNTDOWN_MAX_S, failureText } from '../core/outcomes'
12
+ import { codePointLength, normalizeBody } from '../core/text'
13
+ import { uid, useText } from './context'
14
+
15
+ const props = defineProps<{
16
+ draft: Draft
17
+ kind: 'new' | 'reply' | 'edit'
18
+ /** Seconds the draft still waits (the controller's clock). */
19
+ wait: number
20
+ replyingTo?: string | null
21
+ /** The host's rules page. */
22
+ rulesHref?: string
23
+ }>()
24
+ const emit = defineEmits<{ submit: []; cancel: []; confirmed: []; confirm: [code: string, checked: boolean] }>()
25
+ const { t, lang } = useText()
26
+
27
+ const id = uid('bcm-composer')
28
+ const input = ref<HTMLTextAreaElement | null>(null)
29
+ const length = computed(() => codePointLength(normalizeBody(props.draft.body)))
30
+ const over = computed(() => length.value > COMMUNITY_MESSAGE_MAX)
31
+ const canSend = computed(() => !props.draft.sending && length.value > 0 && !over.value && props.wait === 0 && props.draft.confirm === null)
32
+ const allConfirmed = computed(() => {
33
+ const c = props.draft.confirm
34
+ return c !== null && c.codes.every((code) => c.checked.includes(code))
35
+ })
36
+ const label = computed(() => t(props.kind === 'reply' ? 'composer.replyLabel' : props.kind === 'edit' ? 'composer.editLabel' : 'composer.label'))
37
+ const keys = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent) ? '⌘ + Enter' : 'Ctrl + Enter'
38
+ const failure = computed(() => {
39
+ const f = props.draft.failure
40
+ // The confirmation box says what CONFIRMATION_REQUIRED meant; any later refusal (the confirmed send's) shows.
41
+ if (!f || (props.draft.confirm && f.code === 'confirm')) return ''
42
+ // A short wait reads as the countdown instead.
43
+ if ((f.code === 'fast' || f.code === 'quota') && props.wait > 0 && props.wait <= COUNTDOWN_MAX_S) return ''
44
+ const ref = failureText(f, lang.value)
45
+ return t(ref.key, ref.params)
46
+ })
47
+
48
+ // The counter is read on focus (aria-describedby); near the limit it is also announced, once typing pauses.
49
+ const counterLive = ref('')
50
+ let liveTimer: ReturnType<typeof setTimeout> | null = null
51
+ watch(length, (n) => {
52
+ if (liveTimer) clearTimeout(liveTimer)
53
+ liveTimer = setTimeout(() => {
54
+ counterLive.value = n >= COMMUNITY_MESSAGE_MAX * 0.9 ? t('composer.counter', { length: n, max: COMMUNITY_MESSAGE_MAX }) : ''
55
+ }, 700)
56
+ })
57
+ onBeforeUnmount(() => {
58
+ if (liveTimer) clearTimeout(liveTimer)
59
+ })
60
+
61
+ // A changed text is a new attempt: the old refusal and the confirmation it asked for no longer apply
62
+ // (a cooldown still does).
63
+ watch(
64
+ () => props.draft.body,
65
+ () => {
66
+ const d = props.draft
67
+ if (d.confirm) d.confirm = null
68
+ if (d.failure && d.failure.code !== 'fast' && d.failure.code !== 'quota') d.failure = null
69
+ },
70
+ )
71
+
72
+ function onKey(e: KeyboardEvent): void {
73
+ if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return
74
+ e.preventDefault()
75
+ if (canSend.value) emit('submit')
76
+ }
77
+
78
+ function onSubmit(): void {
79
+ if (canSend.value) emit('submit')
80
+ }
81
+
82
+ defineExpose({ focus: () => input.value?.focus() })
83
+ </script>
84
+
85
+ <template>
86
+ <form class="bcm-composer" @submit.prevent="onSubmit">
87
+ <label :for="id" class="bcm-label">{{ label }}</label>
88
+ <p v-if="replyingTo" class="bcm-hint">{{ t('composer.replyingTo', { name: replyingTo }) }}</p>
89
+ <textarea
90
+ :id="id"
91
+ ref="input"
92
+ v-model="draft.body"
93
+ class="bcm-input bcm-textarea"
94
+ rows="4"
95
+ :placeholder="t('composer.placeholder')"
96
+ :aria-describedby="`${id}-counter ${id}-hints`"
97
+ :aria-invalid="over"
98
+ :readonly="draft.sending"
99
+ @keydown="onKey"
100
+ />
101
+ <div class="bcm-composer-foot">
102
+ <span :id="`${id}-counter`" class="bcm-counter" :class="{ 'bcm-counter-over': over }">{{ t('composer.counter', { length, max: COMMUNITY_MESSAGE_MAX }) }}</span>
103
+ <span class="bcm-sr" aria-live="polite">{{ counterLive }}</span>
104
+ <span class="bcm-spacer" />
105
+ <button v-if="kind !== 'new'" type="button" class="bcm-btn" :disabled="draft.sending" @click="emit('cancel')">{{ t('composer.cancel') }}</button>
106
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">
107
+ {{ draft.sending ? t('composer.sending') : kind === 'edit' ? t('composer.save') : t('composer.submit') }}
108
+ </button>
109
+ </div>
110
+ <div :id="`${id}-hints`" class="bcm-hint">
111
+ <p>{{ t('composer.linksNote', { count: COMMUNITY_MESSAGE_LINKS_MAX }) }}</p>
112
+ <p>{{ t('composer.shortcut', { keys }) }}</p>
113
+ <p v-if="rulesHref"><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesTitle') }}</a></p>
114
+ </div>
115
+ <p v-if="over" class="bcm-error">{{ t('composer.tooLong', { max: COMMUNITY_MESSAGE_MAX }) }}</p>
116
+ <p v-if="wait > 0 && wait <= COUNTDOWN_MAX_S" class="bcm-warning" role="status">{{ t('composer.cooldown', { count: wait }) }}</p>
117
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
118
+ <fieldset v-if="draft.confirm" class="bcm-confirm">
119
+ <legend class="bcm-label">{{ t('confirm.title') }}</legend>
120
+ <p>{{ t('error.confirm') }}</p>
121
+ <label v-for="code in draft.confirm.codes" :key="code" class="bcm-check">
122
+ <input type="checkbox" :checked="draft.confirm.checked.includes(code)" @change="emit('confirm', code, ($event.target as HTMLInputElement).checked)" />
123
+ <span>{{ t(confirmMessageKey(code)) }}</span>
124
+ </label>
125
+ <div class="bcm-row">
126
+ <button type="button" class="bcm-btn bcm-primary" :disabled="!allConfirmed || draft.sending || wait > 0" @click="emit('confirmed')">{{ t('confirm.submit') }}</button>
127
+ </div>
128
+ </fieldset>
129
+ </form>
130
+ </template>
@@ -0,0 +1,205 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One message of the thread, and on a top-level message its replies (two levels). What it shows depends on
4
+ * its state, by community.md §5 (no shadow bans): the author sees a held or removed message WITH its state
5
+ * („Čeká na kontrolu", and for a removed one the way to the statement in „Moje komunita"); others see a
6
+ * placeholder, which kb-api sends only while replies hang on it; a blocked author's message is collapsed
7
+ * until the reader opens it.
8
+ *
9
+ * The author's own message the tenant's art. 9 policy keeps from others until they consent (`hiddenUntilConsent`
10
+ * on a `held` message, m1-contracts.md §7.14 P-RV3) is not awaiting review: it says so („Čeká na váš souhlas"),
11
+ * and while the discussion asks this member for the consent it offers the onboarding's consent step in place
12
+ * (`beginOnboarding`, the composer's own). kb-api sends the flag on the author's read only, so another reader sees
13
+ * the plain placeholder.
14
+ */
15
+ import { computed } from 'vue'
16
+ import { hasCommunityMessage, noteMessageKey, warningMessageKey, type CommunityMessageKey, type DiscussionMessage } from '@blogmcp/sdk/community'
17
+ import { reportLink } from '../core/attrs'
18
+ import { formatDate } from '../core/format'
19
+ import AuthorAvatar from './AuthorAvatar.vue'
20
+ import LinkedText from './LinkedText.vue'
21
+ import MessageComposer from './MessageComposer.vue'
22
+ import MessageMenu from './MessageMenu.vue'
23
+ import ReactionBar from './ReactionBar.vue'
24
+ import { useText } from './context'
25
+ import { useDiscussion } from './discussion-context'
26
+ import type { MenuItem } from './types'
27
+
28
+ defineOptions({ name: 'MessageItem' })
29
+
30
+ const props = defineProps<{ message: DiscussionMessage }>()
31
+ const { t, lang } = useText()
32
+ const { ctl, routes, opened, originals, allReplies, deleteMessage } = useDiscussion()
33
+
34
+ /** Replies shown before „Zobrazit další odpovědi". */
35
+ const REPLIES_SHOWN = 3
36
+
37
+ const m = computed(() => props.message)
38
+ const isReply = computed(() => m.value.replyToSeq !== null)
39
+ const own = computed(() => ctl.isOwn(m.value))
40
+ const placeholder = computed(() => m.value.body === null)
41
+ /** The author's own message held back from others until their art. 9 consent (P-RV3) — not one awaiting review. */
42
+ const untilConsent = computed(() => own.value && m.value.state === 'held' && m.value.hiddenUntilConsent === true)
43
+ /** The consent step can be opened from here: the discussion asks this member for it, and the onboarding is not open yet. */
44
+ const canGiveConsent = computed(
45
+ () => untilConsent.value && ctl.mode() === 'onboarding' && ctl.needs().art9 && !ctl.onboarding.value && ctl.state.refusedAge === null,
46
+ )
47
+
48
+ const authorName = computed(() => {
49
+ const a = m.value.author
50
+ if (a.kind === 'system') return t('author.system')
51
+ if (a.kind === 'staff') return a.displayName
52
+ return a.displayName ?? t('author.deleted')
53
+ })
54
+ const handle = computed(() => (m.value.author.kind === 'member' ? m.value.author.handle : null))
55
+ const badge = computed(() => {
56
+ const a = m.value.author
57
+ if (a.kind !== 'staff') return null
58
+ const key = `author.badge.${a.badge}`
59
+ return hasCommunityMessage(key) ? t(key) : null
60
+ })
61
+
62
+ const placeholderKey = computed<CommunityMessageKey>(() =>
63
+ m.value.state === 'held' ? 'discussion.placeholderHeld' : m.value.state === 'removed' ? 'discussion.placeholderRemoved' : 'discussion.placeholderDeleted',
64
+ )
65
+ const hiddenByBlock = computed(() => m.value.collapsed && !opened.has(m.value.seq))
66
+ const showOriginal = computed(() => originals.has(m.value.seq))
67
+ const text = computed(() => (m.value.isTranslated && !showOriginal.value ? m.value.displayBody : m.value.body) ?? m.value.displayBody)
68
+
69
+ const notes = computed(() =>
70
+ (own.value ? (m.value.notes ?? []) : []).map((code) => noteMessageKey(code)).filter((k): k is CommunityMessageKey => k !== null),
71
+ )
72
+ const warnings = computed(() => (own.value ? [...new Set((m.value.warnings ?? []).map((code) => warningMessageKey(code)))] : []))
73
+
74
+ const editing = computed(() => ctl.state.inline?.kind === 'edit' && ctl.state.inline.seq === m.value.seq)
75
+ const replying = computed(() => !isReply.value && ctl.state.inline?.kind === 'reply' && ctl.state.inline.seq === m.value.seq)
76
+
77
+ const shownReplies = computed(() => (allReplies.has(m.value.seq) ? m.value.replies : m.value.replies.slice(0, REPLIES_SHOWN)))
78
+ const moreReplies = computed(() => m.value.replies.length - shownReplies.value.length)
79
+
80
+ const target = computed(() => ({ conversationId: m.value.conversationId, seq: m.value.seq }))
81
+
82
+ const menu = computed<MenuItem[]>(() => {
83
+ const items: MenuItem[] = []
84
+ const msg = m.value
85
+ if (ctl.canEdit(msg)) items.push({ id: 'edit', label: t('discussion.edit') })
86
+ if (ctl.canDelete(msg)) items.push({ id: 'delete', label: t('discussion.delete'), danger: true })
87
+ if (ctl.canReport(msg)) items.push({ id: 'report', label: t('discussion.report') })
88
+ else if (ctl.canReportAsNotifier(msg)) {
89
+ const href = reportLink(routes.value.reportPath, target.value)
90
+ if (href) items.push({ id: 'report', label: t('discussion.report'), href })
91
+ }
92
+ if (ctl.canBlock(msg)) items.push({ id: msg.collapsed ? 'unblock' : 'block', label: msg.collapsed ? t('discussion.unblock') : t('discussion.block') })
93
+ return items
94
+ })
95
+
96
+ function onMenu(id: string): void {
97
+ const msg = m.value
98
+ if (id === 'edit') ctl.openEdit(msg.seq)
99
+ else if (id === 'delete') deleteMessage(msg.seq)
100
+ else if (id === 'report') ctl.openReport(msg)
101
+ else if ((id === 'block' || id === 'unblock') && msg.author.kind === 'member' && msg.author.memberId) void ctl.setBlocked(msg.author.memberId, id === 'block')
102
+ }
103
+
104
+ function reply(): void {
105
+ ctl.openReply(m.value.seq, isReply.value ? authorName.value : null)
106
+ }
107
+
108
+ function toggle(set: Set<number>): void {
109
+ if (set.has(m.value.seq)) set.delete(m.value.seq)
110
+ else set.add(m.value.seq)
111
+ }
112
+ </script>
113
+
114
+ <template>
115
+ <li class="bcm-msg-item">
116
+ <article class="bcm-msg" :class="{ 'bcm-msg-pending': !!m.pending }" :aria-busy="!!m.pending">
117
+ <AuthorAvatar :author="m.author" />
118
+ <div class="bcm-msg-main">
119
+ <header class="bcm-meta">
120
+ <span class="bcm-author">{{ authorName }}</span>
121
+ <span v-if="badge" class="bcm-badge bcm-badge-team">{{ badge }}</span>
122
+ <span v-if="handle" class="bcm-handle">{{ handle }}</span>
123
+ <time v-if="m.createdAt" class="bcm-time" :datetime="m.createdAt">{{ formatDate(lang, m.createdAt) }}</time>
124
+ <span v-if="m.isEdited && !placeholder" class="bcm-muted">{{ t('discussion.edited') }}</span>
125
+ <span v-if="m.pending" class="bcm-muted" role="status">{{ t('discussion.pending') }}</span>
126
+ </header>
127
+
128
+ <p v-if="placeholder" class="bcm-placeholder">{{ t(placeholderKey) }}</p>
129
+ <p v-else-if="hiddenByBlock" class="bcm-muted">
130
+ {{ t('discussion.collapsed') }}
131
+ <button type="button" class="bcm-link" @click="toggle(opened)">{{ t('discussion.show') }}</button>
132
+ </p>
133
+ <template v-else>
134
+ <div v-if="untilConsent" class="bcm-own-state bcm-own-consent">
135
+ <span class="bcm-badge">{{ t('state.hiddenUntilConsent') }}</span>
136
+ <p class="bcm-hint">{{ t('discussion.hiddenUntilConsentNote') }}</p>
137
+ <button v-if="canGiveConsent" type="button" class="bcm-link" @click="ctl.beginOnboarding()">{{ t('discussion.giveConsent') }}</button>
138
+ </div>
139
+ <div v-else-if="own && (m.state === 'held' || m.state === 'removed')" class="bcm-own-state">
140
+ <span class="bcm-badge">{{ m.state === 'held' ? t('state.held') : t('state.removed') }}</span>
141
+ <p class="bcm-hint">{{ m.state === 'held' ? t('discussion.heldNote') : t('discussion.removedNote') }}</p>
142
+ <a v-if="m.state === 'removed'" :href="routes.myCommunityPath">{{ t('discussion.why') }}</a>
143
+ </div>
144
+ <MessageComposer
145
+ v-if="editing && ctl.state.inline"
146
+ kind="edit"
147
+ :draft="ctl.state.inline"
148
+ :wait="ctl.waitLeft(ctl.state.inline)"
149
+ :rules-href="routes.rulesPath"
150
+ @submit="ctl.submitInline()"
151
+ @cancel="ctl.closeInline()"
152
+ @confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
153
+ @confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
154
+ />
155
+ <p v-else class="bcm-text"><LinkedText :text="text" /></p>
156
+ <p v-if="m.isTranslated && !editing" class="bcm-hint">
157
+ {{ t('discussion.translated') }}
158
+ <button type="button" class="bcm-link" @click="toggle(originals)">{{ showOriginal ? t('discussion.showTranslation') : t('discussion.showOriginal') }}</button>
159
+ </p>
160
+ <button v-if="m.collapsed" type="button" class="bcm-link" @click="toggle(opened)">{{ t('discussion.hide') }}</button>
161
+ <p v-for="key in notes" :key="key" class="bcm-note">
162
+ <!-- The adverse-event note leads to the site's consultation page when the site names one (§7.6). -->
163
+ <a v-if="key === 'note.report_to_manufacturer' && routes.consultationPath" :href="routes.consultationPath">{{ t(key) }}</a>
164
+ <template v-else>{{ t(key) }}</template>
165
+ </p>
166
+ <p v-for="key in warnings" :key="key" class="bcm-warning">{{ t(key) }}</p>
167
+ </template>
168
+
169
+ <div v-if="!placeholder && !hiddenByBlock && !editing" class="bcm-actions">
170
+ <ReactionBar
171
+ :reaction-set="ctl.state.thread.reactionSet"
172
+ :summary="m.reactions"
173
+ :interactive="ctl.canReact() && !m.pending && m.seq > 0 && m.state === 'visible'"
174
+ :busy="!!ctl.state.busy[`react:${m.seq}`]"
175
+ @react="(emoji: string) => ctl.react(target, emoji)"
176
+ />
177
+ <button v-if="ctl.canReply(m)" type="button" class="bcm-quiet" @click="reply">{{ t('discussion.reply') }}</button>
178
+ <MessageMenu :items="menu" @select="onMenu" />
179
+ </div>
180
+
181
+ <template v-if="!isReply">
182
+ <ol v-if="m.replies.length" class="bcm-replies">
183
+ <MessageItem v-for="r in shownReplies" :key="r.id" :message="r" />
184
+ </ol>
185
+ <button v-if="moreReplies > 0" type="button" class="bcm-link" @click="allReplies.add(m.seq)">
186
+ {{ t('discussion.showReplies', { count: moreReplies }) }}
187
+ </button>
188
+ <p v-if="m.repliesTruncated" class="bcm-hint">{{ t('discussion.repliesTruncated') }}</p>
189
+ <MessageComposer
190
+ v-if="replying && ctl.state.inline"
191
+ kind="reply"
192
+ :draft="ctl.state.inline"
193
+ :wait="ctl.waitLeft(ctl.state.inline)"
194
+ :rules-href="routes.rulesPath"
195
+ :replying-to="ctl.state.inline.replyingTo"
196
+ @submit="ctl.submitInline()"
197
+ @cancel="ctl.closeInline()"
198
+ @confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
199
+ @confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
200
+ />
201
+ </template>
202
+ </div>
203
+ </article>
204
+ </li>
205
+ </template>