@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.
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-WadUnaTa.js +6436 -0
- package/dist/core/attrs.d.ts +99 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +175 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +24 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +92 -0
- package/dist/core/outcomes.d.ts +29 -0
- package/dist/core/panel.d.ts +96 -0
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +35 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +47 -0
- package/dist/global.d.ts +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +45 -0
- package/dist/mount.d.ts +105 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +55 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +44 -0
- package/dist/ui/LinkedText.vue.d.ts +6 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +26 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +200 -0
- package/dist/vue.js +201 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +186 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/client.ts +54 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +876 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +112 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +492 -0
- package/src/core/outcomes.ts +86 -0
- package/src/core/panel.ts +446 -0
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +174 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +165 -0
- package/src/global.ts +26 -0
- package/src/index.ts +70 -0
- package/src/mount.ts +234 -0
- package/src/react.ts +97 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +334 -0
- package/src/ui/LinkedText.vue +24 -0
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +141 -0
- package/src/ui/MessageItem.vue +223 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +378 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +174 -0
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +259 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- 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>
|