@blogmcp/community 0.0.0-stage → 0.2.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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. package/src/vue.ts +242 -0
@@ -0,0 +1,141 @@
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`). M2 T10: with the member's mention cap
7
+ * (`mentionsMax`, me/get's `caps.mentionsPerMessage`), a draft that mentions more distinct `@handle`s says so as it
8
+ * is typed (`error.mentionLimit`) and is not sent; kb-api's MENTION_LIMIT stays the rule.
9
+ */
10
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
11
+ import { COMMUNITY_MESSAGE_LINKS_MAX, COMMUNITY_MESSAGE_MAX, communityMentionHandles, confirmMessageKey } from '@blogmcp/sdk/community'
12
+ import type { Draft } from '../core/discussion'
13
+ import { COUNTDOWN_MAX_S, failureText } from '../core/outcomes'
14
+ import { codePointLength, normalizeBody } from '../core/text'
15
+ import { uid, useText } from './context'
16
+
17
+ const props = defineProps<{
18
+ draft: Draft
19
+ kind: 'new' | 'reply' | 'edit'
20
+ /** Seconds the draft still waits (the controller's clock). */
21
+ wait: number
22
+ replyingTo?: string | null
23
+ /** The host's rules page. */
24
+ rulesHref?: string
25
+ /** M2 T10: the distinct mentions one message may carry; null or absent: no limit known here. */
26
+ mentionsMax?: number | null
27
+ }>()
28
+ const emit = defineEmits<{ submit: []; cancel: []; confirmed: []; confirm: [code: string, checked: boolean] }>()
29
+ const { t, lang } = useText()
30
+
31
+ const id = uid('bcm-composer')
32
+ const input = ref<HTMLTextAreaElement | null>(null)
33
+ const length = computed(() => codePointLength(normalizeBody(props.draft.body)))
34
+ const over = computed(() => length.value > COMMUNITY_MESSAGE_MAX)
35
+ const mentionsOver = computed(() => {
36
+ const max = props.mentionsMax
37
+ return typeof max === 'number' && max >= 0 && communityMentionHandles(normalizeBody(props.draft.body)).length > max
38
+ })
39
+ const canSend = computed(() => !props.draft.sending && length.value > 0 && !over.value && !mentionsOver.value && props.wait === 0 && props.draft.confirm === null)
40
+ const allConfirmed = computed(() => {
41
+ const c = props.draft.confirm
42
+ return c !== null && c.codes.every((code) => c.checked.includes(code))
43
+ })
44
+ const label = computed(() => t(props.kind === 'reply' ? 'composer.replyLabel' : props.kind === 'edit' ? 'composer.editLabel' : 'composer.label'))
45
+ const keys = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent) ? '⌘ + Enter' : 'Ctrl + Enter'
46
+ const failure = computed(() => {
47
+ const f = props.draft.failure
48
+ // The confirmation box says what CONFIRMATION_REQUIRED meant; any later refusal (the confirmed send's) shows.
49
+ if (!f || (props.draft.confirm && f.code === 'confirm')) return ''
50
+ // The mention warning below says it already, as long as the draft is over the cap.
51
+ if (f.code === 'mentionLimit' && mentionsOver.value) return ''
52
+ // A short wait reads as the countdown instead.
53
+ if ((f.code === 'fast' || f.code === 'quota') && props.wait > 0 && props.wait <= COUNTDOWN_MAX_S) return ''
54
+ const ref = failureText(f, lang.value)
55
+ return t(ref.key, ref.params)
56
+ })
57
+
58
+ // The counter is read on focus (aria-describedby); near the limit it is also announced, once typing pauses.
59
+ const counterLive = ref('')
60
+ let liveTimer: ReturnType<typeof setTimeout> | null = null
61
+ watch(length, (n) => {
62
+ if (liveTimer) clearTimeout(liveTimer)
63
+ liveTimer = setTimeout(() => {
64
+ counterLive.value = n >= COMMUNITY_MESSAGE_MAX * 0.9 ? t('composer.counter', { length: n, max: COMMUNITY_MESSAGE_MAX }) : ''
65
+ }, 700)
66
+ })
67
+ onBeforeUnmount(() => {
68
+ if (liveTimer) clearTimeout(liveTimer)
69
+ })
70
+
71
+ // A changed text is a new attempt: the old refusal and the confirmation it asked for no longer apply
72
+ // (a cooldown still does).
73
+ watch(
74
+ () => props.draft.body,
75
+ () => {
76
+ const d = props.draft
77
+ if (d.confirm) d.confirm = null
78
+ if (d.failure && d.failure.code !== 'fast' && d.failure.code !== 'quota') d.failure = null
79
+ },
80
+ )
81
+
82
+ function onKey(e: KeyboardEvent): void {
83
+ if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return
84
+ e.preventDefault()
85
+ if (canSend.value) emit('submit')
86
+ }
87
+
88
+ function onSubmit(): void {
89
+ if (canSend.value) emit('submit')
90
+ }
91
+
92
+ defineExpose({ focus: () => input.value?.focus() })
93
+ </script>
94
+
95
+ <template>
96
+ <form class="bcm-composer" @submit.prevent="onSubmit">
97
+ <label :for="id" class="bcm-label">{{ label }}</label>
98
+ <p v-if="replyingTo" class="bcm-hint">{{ t('composer.replyingTo', { name: replyingTo }) }}</p>
99
+ <textarea
100
+ :id="id"
101
+ ref="input"
102
+ v-model="draft.body"
103
+ class="bcm-input bcm-textarea"
104
+ rows="4"
105
+ :placeholder="t('composer.placeholder')"
106
+ :aria-describedby="`${id}-counter ${id}-hints`"
107
+ :aria-invalid="over || mentionsOver"
108
+ :readonly="draft.sending"
109
+ @keydown="onKey"
110
+ />
111
+ <div class="bcm-composer-foot">
112
+ <span :id="`${id}-counter`" class="bcm-counter" :class="{ 'bcm-counter-over': over }">{{ t('composer.counter', { length, max: COMMUNITY_MESSAGE_MAX }) }}</span>
113
+ <span class="bcm-sr" aria-live="polite">{{ counterLive }}</span>
114
+ <span class="bcm-spacer" />
115
+ <button v-if="kind !== 'new'" type="button" class="bcm-btn" :disabled="draft.sending" @click="emit('cancel')">{{ t('composer.cancel') }}</button>
116
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">
117
+ {{ draft.sending ? t('composer.sending') : kind === 'edit' ? t('composer.save') : t('composer.submit') }}
118
+ </button>
119
+ </div>
120
+ <div :id="`${id}-hints`" class="bcm-hint">
121
+ <p>{{ t('composer.linksNote', { count: COMMUNITY_MESSAGE_LINKS_MAX }) }}</p>
122
+ <p>{{ t('composer.shortcut', { keys }) }}</p>
123
+ <p v-if="rulesHref"><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesTitle') }}</a></p>
124
+ </div>
125
+ <p v-if="over" class="bcm-error">{{ t('composer.tooLong', { max: COMMUNITY_MESSAGE_MAX }) }}</p>
126
+ <p v-if="mentionsOver" class="bcm-error" role="alert">{{ t('error.mentionLimit') }}</p>
127
+ <p v-if="wait > 0 && wait <= COUNTDOWN_MAX_S" class="bcm-warning" role="status">{{ t('composer.cooldown', { count: wait }) }}</p>
128
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
129
+ <fieldset v-if="draft.confirm" class="bcm-confirm">
130
+ <legend class="bcm-label">{{ t('confirm.title') }}</legend>
131
+ <p>{{ t('error.confirm') }}</p>
132
+ <label v-for="code in draft.confirm.codes" :key="code" class="bcm-check">
133
+ <input type="checkbox" :checked="draft.confirm.checked.includes(code)" @change="emit('confirm', code, ($event.target as HTMLInputElement).checked)" />
134
+ <span>{{ t(confirmMessageKey(code)) }}</span>
135
+ </label>
136
+ <div class="bcm-row">
137
+ <button type="button" class="bcm-btn bcm-primary" :disabled="!allConfirmed || draft.sending || wait > 0" @click="emit('confirmed')">{{ t('confirm.submit') }}</button>
138
+ </div>
139
+ </fieldset>
140
+ </form>
141
+ </template>
@@ -0,0 +1,223 @@
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 { communityMessageAnchor, 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
+ /**
44
+ * The consent step can be opened from here: the discussion asks this member for it, or the member refused it where the
45
+ * tenant leaves the public discussion open (`acceptances.art9Refused`, M2 T9: needs name no consent then, yet the post
46
+ * stays hidden from others until it is given) — and the onboarding is not open yet.
47
+ */
48
+ const refusedConsent = computed(() => ctl.state.me?.acceptances.art9Refused === true)
49
+ const canGiveConsent = computed(
50
+ () =>
51
+ untilConsent.value &&
52
+ !ctl.onboarding.value &&
53
+ ctl.state.refusedAge === null &&
54
+ ((ctl.mode() === 'onboarding' && ctl.needs().art9) || refusedConsent.value),
55
+ )
56
+
57
+ const authorName = computed(() => {
58
+ const a = m.value.author
59
+ if (a.kind === 'system') return t('author.system')
60
+ if (a.kind === 'staff') return a.displayName
61
+ return a.displayName ?? t('author.deleted')
62
+ })
63
+ const handle = computed(() => (m.value.author.kind === 'member' ? m.value.author.handle : null))
64
+ const badge = computed(() => {
65
+ const a = m.value.author
66
+ if (a.kind !== 'staff') return null
67
+ const key = `author.badge.${a.badge}`
68
+ return hasCommunityMessage(key) ? t(key) : null
69
+ })
70
+
71
+ const placeholderKey = computed<CommunityMessageKey>(() =>
72
+ m.value.state === 'held' ? 'discussion.placeholderHeld' : m.value.state === 'removed' ? 'discussion.placeholderRemoved' : 'discussion.placeholderDeleted',
73
+ )
74
+ /** M2 T10: the row's anchor (`bcm-msg-<seq>`), the fragment the notification centre's links carry; none while pending. */
75
+ const anchorId = computed(() => (m.value.pending ? undefined : (communityMessageAnchor(m.value.seq) ?? undefined)))
76
+ const hiddenByBlock = computed(() => m.value.collapsed && !opened.has(m.value.seq))
77
+ const showOriginal = computed(() => originals.has(m.value.seq))
78
+ const text = computed(() => (m.value.isTranslated && !showOriginal.value ? m.value.displayBody : m.value.body) ?? m.value.displayBody)
79
+
80
+ const notes = computed(() =>
81
+ (own.value ? (m.value.notes ?? []) : []).map((code) => noteMessageKey(code)).filter((k): k is CommunityMessageKey => k !== null),
82
+ )
83
+ const warnings = computed(() => (own.value ? [...new Set((m.value.warnings ?? []).map((code) => warningMessageKey(code)))] : []))
84
+
85
+ const editing = computed(() => ctl.state.inline?.kind === 'edit' && ctl.state.inline.seq === m.value.seq)
86
+ const replying = computed(() => !isReply.value && ctl.state.inline?.kind === 'reply' && ctl.state.inline.seq === m.value.seq)
87
+
88
+ const shownReplies = computed(() => (allReplies.has(m.value.seq) ? m.value.replies : m.value.replies.slice(0, REPLIES_SHOWN)))
89
+ const moreReplies = computed(() => m.value.replies.length - shownReplies.value.length)
90
+
91
+ const target = computed(() => ({ conversationId: m.value.conversationId, seq: m.value.seq }))
92
+ /**
93
+ * A mention links to the member's profile for a viewer who may see profiles (a signed-in host session, m2-contracts.md
94
+ * §3.3: "never a hyperlink to a profile for a member who cannot see it"); an anonymous reader reads it as text.
95
+ */
96
+ const mentionProfiles = computed(() => (ctl.needs().signIn ? null : routes.value.profilePath))
97
+
98
+ const menu = computed<MenuItem[]>(() => {
99
+ const items: MenuItem[] = []
100
+ const msg = m.value
101
+ if (ctl.canEdit(msg)) items.push({ id: 'edit', label: t('discussion.edit') })
102
+ if (ctl.canDelete(msg)) items.push({ id: 'delete', label: t('discussion.delete'), danger: true })
103
+ if (ctl.canReport(msg)) items.push({ id: 'report', label: t('discussion.report') })
104
+ else if (ctl.canReportAsNotifier(msg)) {
105
+ const href = reportLink(routes.value.reportPath, target.value)
106
+ if (href) items.push({ id: 'report', label: t('discussion.report'), href })
107
+ }
108
+ if (ctl.canBlock(msg)) items.push({ id: msg.collapsed ? 'unblock' : 'block', label: msg.collapsed ? t('discussion.unblock') : t('discussion.block') })
109
+ return items
110
+ })
111
+
112
+ function onMenu(id: string): void {
113
+ const msg = m.value
114
+ if (id === 'edit') ctl.openEdit(msg.seq)
115
+ else if (id === 'delete') deleteMessage(msg.seq)
116
+ else if (id === 'report') ctl.openReport(msg)
117
+ else if ((id === 'block' || id === 'unblock') && msg.author.kind === 'member' && msg.author.memberId) void ctl.setBlocked(msg.author.memberId, id === 'block')
118
+ }
119
+
120
+ function reply(): void {
121
+ ctl.openReply(m.value.seq, isReply.value ? authorName.value : null)
122
+ }
123
+
124
+ function toggle(set: Set<number>): void {
125
+ if (set.has(m.value.seq)) set.delete(m.value.seq)
126
+ else set.add(m.value.seq)
127
+ }
128
+ </script>
129
+
130
+ <template>
131
+ <li :id="anchorId" class="bcm-msg-item">
132
+ <article class="bcm-msg" :class="{ 'bcm-msg-pending': !!m.pending }" :aria-busy="!!m.pending">
133
+ <AuthorAvatar :author="m.author" />
134
+ <div class="bcm-msg-main">
135
+ <header class="bcm-meta">
136
+ <span class="bcm-author">{{ authorName }}</span>
137
+ <span v-if="badge" class="bcm-badge bcm-badge-team">{{ badge }}</span>
138
+ <span v-if="handle" class="bcm-handle">{{ handle }}</span>
139
+ <time v-if="m.createdAt" class="bcm-time" :datetime="m.createdAt">{{ formatDate(lang, m.createdAt) }}</time>
140
+ <span v-if="m.isEdited && !placeholder" class="bcm-muted">{{ t('discussion.edited') }}</span>
141
+ <span v-if="m.pending" class="bcm-muted" role="status">{{ t('discussion.pending') }}</span>
142
+ </header>
143
+
144
+ <p v-if="placeholder" class="bcm-placeholder">{{ t(placeholderKey) }}</p>
145
+ <p v-else-if="hiddenByBlock" class="bcm-muted">
146
+ {{ t('discussion.collapsed') }}
147
+ <button type="button" class="bcm-link" @click="toggle(opened)">{{ t('discussion.show') }}</button>
148
+ </p>
149
+ <template v-else>
150
+ <div v-if="untilConsent" class="bcm-own-state bcm-own-consent">
151
+ <span class="bcm-badge">{{ t('state.hiddenUntilConsent') }}</span>
152
+ <p class="bcm-hint">{{ t('discussion.hiddenUntilConsentNote') }}</p>
153
+ <button v-if="canGiveConsent" type="button" class="bcm-link" @click="ctl.beginOnboarding(refusedConsent ? { offerArt9: true } : {})">{{ t('discussion.giveConsent') }}</button>
154
+ </div>
155
+ <div v-else-if="own && (m.state === 'held' || m.state === 'removed')" class="bcm-own-state">
156
+ <span class="bcm-badge">{{ m.state === 'held' ? t('state.held') : t('state.removed') }}</span>
157
+ <p class="bcm-hint">{{ m.state === 'held' ? t('discussion.heldNote') : t('discussion.removedNote') }}</p>
158
+ <a v-if="m.state === 'removed'" :href="routes.myCommunityPath">{{ t('discussion.why') }}</a>
159
+ </div>
160
+ <MessageComposer
161
+ v-if="editing && ctl.state.inline"
162
+ kind="edit"
163
+ :draft="ctl.state.inline"
164
+ :wait="ctl.waitLeft(ctl.state.inline)"
165
+ :rules-href="routes.rulesPath"
166
+ :mentions-max="ctl.mentionCap()"
167
+ @submit="ctl.submitInline()"
168
+ @cancel="ctl.closeInline()"
169
+ @confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
170
+ @confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
171
+ />
172
+ <p v-else class="bcm-text"><LinkedText :text="text" :profile-path="mentionProfiles" /></p>
173
+ <p v-if="m.isTranslated && !editing" class="bcm-hint">
174
+ {{ t('discussion.translated') }}
175
+ <button type="button" class="bcm-link" @click="toggle(originals)">{{ showOriginal ? t('discussion.showTranslation') : t('discussion.showOriginal') }}</button>
176
+ </p>
177
+ <button v-if="m.collapsed" type="button" class="bcm-link" @click="toggle(opened)">{{ t('discussion.hide') }}</button>
178
+ <p v-for="key in notes" :key="key" class="bcm-note">
179
+ <!-- The adverse-event note leads to the site's consultation page when the site names one (§7.6). -->
180
+ <a v-if="key === 'note.report_to_manufacturer' && routes.consultationPath" :href="routes.consultationPath">{{ t(key) }}</a>
181
+ <template v-else>{{ t(key) }}</template>
182
+ </p>
183
+ <p v-for="key in warnings" :key="key" class="bcm-warning">{{ t(key) }}</p>
184
+ </template>
185
+
186
+ <div v-if="!placeholder && !hiddenByBlock && !editing" class="bcm-actions">
187
+ <ReactionBar
188
+ :reaction-set="ctl.state.thread.reactionSet"
189
+ :summary="m.reactions"
190
+ :interactive="ctl.canReact() && !m.pending && m.seq > 0 && m.state === 'visible'"
191
+ :busy="!!ctl.state.busy[`react:${m.seq}`]"
192
+ @react="(emoji: string) => ctl.react(target, emoji)"
193
+ />
194
+ <button v-if="ctl.canReply(m)" type="button" class="bcm-quiet" @click="reply">{{ t('discussion.reply') }}</button>
195
+ <MessageMenu :items="menu" @select="onMenu" />
196
+ </div>
197
+
198
+ <template v-if="!isReply">
199
+ <ol v-if="m.replies.length" class="bcm-replies">
200
+ <MessageItem v-for="r in shownReplies" :key="r.id" :message="r" />
201
+ </ol>
202
+ <button v-if="moreReplies > 0" type="button" class="bcm-link" @click="allReplies.add(m.seq)">
203
+ {{ t('discussion.showReplies', { count: moreReplies }) }}
204
+ </button>
205
+ <p v-if="m.repliesTruncated" class="bcm-hint">{{ t('discussion.repliesTruncated') }}</p>
206
+ <MessageComposer
207
+ v-if="replying && ctl.state.inline"
208
+ kind="reply"
209
+ :draft="ctl.state.inline"
210
+ :wait="ctl.waitLeft(ctl.state.inline)"
211
+ :rules-href="routes.rulesPath"
212
+ :mentions-max="ctl.mentionCap()"
213
+ :replying-to="ctl.state.inline.replyingTo"
214
+ @submit="ctl.submitInline()"
215
+ @cancel="ctl.closeInline()"
216
+ @confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
217
+ @confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
218
+ />
219
+ </template>
220
+ </div>
221
+ </article>
222
+ </li>
223
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A message's „Další akce": a disclosure of real buttons (and the notice page's link for a reader without a
4
+ * session). Esc or a click elsewhere closes it; the focus goes back to its toggle before an action runs,
5
+ * so a dialog the action opens returns it there.
6
+ */
7
+ import { nextTick, onBeforeUnmount, ref } from 'vue'
8
+ import { uid, useText } from './context'
9
+ import type { MenuItem } from './types'
10
+
11
+ defineProps<{ items: MenuItem[] }>()
12
+ const emit = defineEmits<{ select: [id: string] }>()
13
+ const { t } = useText()
14
+
15
+ const open = ref(false)
16
+ const toggle = ref<HTMLButtonElement | null>(null)
17
+ const root = ref<HTMLElement | null>(null)
18
+ const listId = uid('bcm-menu')
19
+
20
+ function onOutside(e: Event): void {
21
+ if (!root.value || e.composedPath().includes(root.value)) return
22
+ close(false)
23
+ }
24
+
25
+ function setOpen(next: boolean): void {
26
+ open.value = next
27
+ if (next) {
28
+ document.addEventListener('pointerdown', onOutside, true)
29
+ void nextTick(() => root.value?.querySelector<HTMLElement>('.bcm-menu-list button, .bcm-menu-list a')?.focus())
30
+ } else {
31
+ document.removeEventListener('pointerdown', onOutside, true)
32
+ }
33
+ }
34
+
35
+ function close(focusToggle = true): void {
36
+ setOpen(false)
37
+ if (focusToggle) toggle.value?.focus()
38
+ }
39
+
40
+ function pick(id: string): void {
41
+ close(true)
42
+ emit('select', id)
43
+ }
44
+
45
+ onBeforeUnmount(() => document.removeEventListener('pointerdown', onOutside, true))
46
+ </script>
47
+
48
+ <template>
49
+ <div v-if="items.length" ref="root" class="bcm-menu" @keydown.esc.stop="close(true)">
50
+ <button
51
+ ref="toggle"
52
+ type="button"
53
+ class="bcm-quiet"
54
+ :aria-expanded="open"
55
+ :aria-controls="listId"
56
+ @click="setOpen(!open)"
57
+ >
58
+ {{ t('discussion.menu') }}
59
+ </button>
60
+ <div v-if="open" :id="listId" class="bcm-menu-list">
61
+ <template v-for="item in items" :key="item.id">
62
+ <a v-if="item.href" :href="item.href" :class="{ 'bcm-danger': item.danger }" @click="close(false)">{{ item.label }}</a>
63
+ <button v-else type="button" :class="{ 'bcm-danger': item.danger }" @click="pick(item.id)">{{ item.label }}</button>
64
+ </template>
65
+ </div>
66
+ </div>
67
+ </template>