@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,334 @@
|
|
|
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 { communityMessageAnchor, communityMessageSeqFromHash, 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 type { DiscussionRealtime } from '../core/realtime'
|
|
21
|
+
import { formatDate } from '../core/format'
|
|
22
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
23
|
+
import { failureText } from '../core/outcomes'
|
|
24
|
+
import ConfirmDialog from './ConfirmDialog.vue'
|
|
25
|
+
import MessageComposer from './MessageComposer.vue'
|
|
26
|
+
import MessageItem from './MessageItem.vue'
|
|
27
|
+
import OnboardingSteps from './OnboardingSteps.vue'
|
|
28
|
+
import ReactionBar from './ReactionBar.vue'
|
|
29
|
+
import ReportDialog from './ReportDialog.vue'
|
|
30
|
+
import { provideText, uid } from './context'
|
|
31
|
+
import { provideDiscussion } from './discussion-context'
|
|
32
|
+
|
|
33
|
+
defineOptions({ inheritAttrs: false })
|
|
34
|
+
|
|
35
|
+
const props = defineProps<{
|
|
36
|
+
articleId?: string
|
|
37
|
+
/** The host's relative base (default `${siteBase}/api/community`). */
|
|
38
|
+
apiBase?: string
|
|
39
|
+
/** The path the site is served under (`/blog` on a managed blog): its proxy and its pages, as the defaults. */
|
|
40
|
+
siteBase?: string
|
|
41
|
+
lang?: string
|
|
42
|
+
/** The site's sign-in page; the current path goes in its fragment. */
|
|
43
|
+
signInHref?: string
|
|
44
|
+
/** JSON (or an object): `statementPath`, `myCommunityPath`, `rulesPath`, `reportPath`, `noticesPath`. */
|
|
45
|
+
routes?: string | Record<string, unknown>
|
|
46
|
+
/** Head poll interval in ms (default 20 000; 0 = off). */
|
|
47
|
+
pollMs?: number
|
|
48
|
+
/** A replacement transport (a property, never an attribute): tests, a signing transport later. */
|
|
49
|
+
transport?: CommunityTransport
|
|
50
|
+
/**
|
|
51
|
+
* M2: live updates — the host's session factory (`createRealtimeSession` of `@blogmcp/sdk/community/realtime`), a
|
|
52
|
+
* property, never an attribute. Absent: the head poll.
|
|
53
|
+
*/
|
|
54
|
+
realtime?: DiscussionRealtime
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const host = useHost()
|
|
58
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
59
|
+
const { t, text } = provideText(lang)
|
|
60
|
+
watch(
|
|
61
|
+
() => props.lang,
|
|
62
|
+
(v) => {
|
|
63
|
+
lang.value = pickLang(v, documentLang())
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const headingId = uid('bcm-discussion')
|
|
68
|
+
const heading = ref<HTMLElement | null>(null)
|
|
69
|
+
const generation = ref(0)
|
|
70
|
+
const current = shallowRef<DiscussionController | null>(null)
|
|
71
|
+
const siteBase = computed(() => siteBaseFor(props.siteBase))
|
|
72
|
+
|
|
73
|
+
function create(): DiscussionController | null {
|
|
74
|
+
const articleId = props.articleId?.trim()
|
|
75
|
+
if (!articleId) {
|
|
76
|
+
warnOnce('article-id', '[blogmcp-community] <blogmcp-discussion> needs an article-id')
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
if (siteBase.value === null) return null
|
|
80
|
+
const client = communityClientFor(props.apiBase, props.transport, siteBase.value)
|
|
81
|
+
if (!client) return null
|
|
82
|
+
return createDiscussionController({
|
|
83
|
+
client,
|
|
84
|
+
articleId,
|
|
85
|
+
lang: lang.value,
|
|
86
|
+
pollMs: props.pollMs,
|
|
87
|
+
realtime: typeof props.realtime === 'function' ? props.realtime : null,
|
|
88
|
+
refusedAge: (host && refusedAges.get(host)) ?? null,
|
|
89
|
+
onAgeRefused: (minAge) => {
|
|
90
|
+
if (host) refusedAges.set(host, minAge)
|
|
91
|
+
},
|
|
92
|
+
onPosted: (state) => host?.dispatchEvent(new CustomEvent('bcm-posted', { detail: { state }, bubbles: true, composed: true })),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const opened = reactive(new Set<number>())
|
|
97
|
+
const originals = reactive(new Set<number>())
|
|
98
|
+
const allReplies = reactive(new Set<number>())
|
|
99
|
+
const deleting = ref<number | null>(null)
|
|
100
|
+
|
|
101
|
+
const parsedRoutes = computed(() => parseRoutes(props.routes))
|
|
102
|
+
const routes = computed(() => routesWithDefaults(parsedRoutes.value, current.value?.state.me?.links, siteBase.value ?? ''))
|
|
103
|
+
|
|
104
|
+
provideDiscussion({
|
|
105
|
+
get ctl() {
|
|
106
|
+
return current.value as DiscussionController
|
|
107
|
+
},
|
|
108
|
+
routes,
|
|
109
|
+
opened,
|
|
110
|
+
originals,
|
|
111
|
+
allReplies,
|
|
112
|
+
focusHeading: () => heading.value,
|
|
113
|
+
deleteMessage: (seq) => {
|
|
114
|
+
deleting.value = seq
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
let observer: IntersectionObserver | null = null
|
|
119
|
+
const section = ref<HTMLElement | null>(null)
|
|
120
|
+
/** The page's `#bcm-msg-<seq>` was looked for once this discussion's first page was in. */
|
|
121
|
+
let anchored = false
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* M2 T10: a link to one message (the notification centre's: `<article>#bcm-msg-<seq>`). The rows live in this shadow
|
|
125
|
+
* root, so the browser cannot scroll to the fragment: once the first page is in, the row is scrolled into view — a
|
|
126
|
+
* reply hidden behind „more replies" is shown first. A message beyond the first page is not looked for.
|
|
127
|
+
*/
|
|
128
|
+
async function scrollToAnchor(): Promise<void> {
|
|
129
|
+
if (anchored || typeof location === 'undefined') return
|
|
130
|
+
anchored = true
|
|
131
|
+
const seq = communityMessageSeqFromHash(location.hash)
|
|
132
|
+
const id = seq === null ? null : communityMessageAnchor(seq)
|
|
133
|
+
if (seq === null || id === null) return
|
|
134
|
+
const parent = state.value?.thread.messages.find((m) => m.replies.some((r) => r.seq === seq))
|
|
135
|
+
if (parent) allReplies.add(parent.seq)
|
|
136
|
+
await nextTick()
|
|
137
|
+
const row = section.value?.querySelector<HTMLElement>(`#${id}`)
|
|
138
|
+
if (typeof row?.scrollIntoView === 'function') row.scrollIntoView({ block: 'start' })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function start(): void {
|
|
142
|
+
anchored = false
|
|
143
|
+
current.value?.dispose()
|
|
144
|
+
opened.clear()
|
|
145
|
+
originals.clear()
|
|
146
|
+
allReplies.clear()
|
|
147
|
+
deleting.value = null
|
|
148
|
+
current.value = create()
|
|
149
|
+
generation.value += 1
|
|
150
|
+
void current.value?.start()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onMounted(() => {
|
|
154
|
+
start()
|
|
155
|
+
if (host && typeof IntersectionObserver !== 'undefined') {
|
|
156
|
+
observer = new IntersectionObserver((entries) => {
|
|
157
|
+
const e = entries[entries.length - 1]
|
|
158
|
+
if (e) current.value?.setInView(e.isIntersecting)
|
|
159
|
+
}, { rootMargin: '200px' })
|
|
160
|
+
observer.observe(host)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
// Another article, base or language is another discussion (the language picks the translations and the posts' languageCode).
|
|
164
|
+
watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.realtime, props.pollMs, lang.value] as const, () => start())
|
|
165
|
+
onBeforeUnmount(() => {
|
|
166
|
+
observer?.disconnect()
|
|
167
|
+
current.value?.dispose()
|
|
168
|
+
current.value = null
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const ctl = computed(() => current.value)
|
|
172
|
+
const state = computed(() => current.value?.state ?? null)
|
|
173
|
+
watch(
|
|
174
|
+
() => state.value?.phase,
|
|
175
|
+
(phase) => {
|
|
176
|
+
if (phase === 'ready') void scrollToAnchor()
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
const mode = computed(() => current.value?.mode() ?? 'pending')
|
|
180
|
+
const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
|
|
181
|
+
// Joined: the reader goes on where the onboarding was, in the composer. A refusal closed: to the discussion's heading
|
|
182
|
+
// (the onboarding's button the focus was on is gone).
|
|
183
|
+
watch(mode, (now, before) => {
|
|
184
|
+
if (now === 'write' && before === 'onboarding') void nextTick(() => composer.value?.focus())
|
|
185
|
+
if (now === 'ageRefused' && before === 'onboarding') void nextTick(() => heading.value?.focus())
|
|
186
|
+
})
|
|
187
|
+
const needKey = computed(() => {
|
|
188
|
+
const n = current.value?.needs()
|
|
189
|
+
return n?.onboarding ? 'need.onboarding' : n?.rules ? 'need.reconsent_rules' : 'need.art9_consent'
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
function signInHref(): string | null {
|
|
193
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sanctionText(r: DiscussionRestriction): string {
|
|
197
|
+
const date = r.endsAt ? formatDate(lang.value, r.endsAt) : ''
|
|
198
|
+
if (r.kind === 'ban') return t('sanction.ban')
|
|
199
|
+
if (r.kind === 'suspension') return date ? t('sanction.suspensionUntil', { date }) : t('sanction.suspension')
|
|
200
|
+
return date ? t('sanction.restrictionUntil', { date }) : t('sanction.restriction')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const loadFailure = computed(() => {
|
|
204
|
+
const f = state.value?.failure
|
|
205
|
+
if (!f) return ''
|
|
206
|
+
const r = failureText(f, lang.value)
|
|
207
|
+
return t(r.key, r.params)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// One live region, present from the start; the same outcome twice is announced twice (cleared in between).
|
|
211
|
+
const liveText = ref('')
|
|
212
|
+
watch(
|
|
213
|
+
() => state.value?.announceId,
|
|
214
|
+
() => {
|
|
215
|
+
liveText.value = ''
|
|
216
|
+
const next = text(state.value?.announcement)
|
|
217
|
+
void nextTick(() => {
|
|
218
|
+
liveText.value = next
|
|
219
|
+
})
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async function confirmDelete(): Promise<void> {
|
|
224
|
+
const seq = deleting.value
|
|
225
|
+
if (seq === null || !current.value) return
|
|
226
|
+
await current.value.remove(seq)
|
|
227
|
+
deleting.value = null
|
|
228
|
+
}
|
|
229
|
+
</script>
|
|
230
|
+
|
|
231
|
+
<template>
|
|
232
|
+
<section v-if="ctl && state && state.phase !== 'hidden'" ref="section" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
|
|
233
|
+
<header class="bcm-head">
|
|
234
|
+
<h2 :id="headingId" ref="heading" tabindex="-1">{{ t('discussion.title') }}</h2>
|
|
235
|
+
<span v-if="state.phase === 'ready'" class="bcm-count">{{ t('discussion.count', { count: state.thread.count }) }}</span>
|
|
236
|
+
</header>
|
|
237
|
+
|
|
238
|
+
<p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('discussion.loading') }}</p>
|
|
239
|
+
|
|
240
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
241
|
+
<span>{{ loadFailure }}</span>
|
|
242
|
+
<button type="button" class="bcm-btn" @click="ctl.refresh()">{{ t('common.retry') }}</button>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<template v-else>
|
|
246
|
+
<ReactionBar
|
|
247
|
+
:reaction-set="state.thread.reactionSet"
|
|
248
|
+
:summary="state.thread.articleReactions"
|
|
249
|
+
:interactive="ctl.canReact()"
|
|
250
|
+
:busy="!!state.busy['react:article']"
|
|
251
|
+
@react="(emoji: string) => ctl && ctl.react({ articleId: state!.thread.articleId ?? articleId ?? '' }, emoji)"
|
|
252
|
+
/>
|
|
253
|
+
|
|
254
|
+
<div v-if="state.newMessages" class="bcm-banner" role="status">
|
|
255
|
+
<span>{{ t('discussion.newMessages') }}</span>
|
|
256
|
+
<button type="button" class="bcm-btn" :disabled="state.refreshing" @click="ctl.refresh()">{{ t('discussion.refresh') }}</button>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<p v-if="state.realtimeOff" class="bcm-hint" role="status">{{ t('realtime.off') }}</p>
|
|
260
|
+
|
|
261
|
+
<p v-if="state.thread.messages.length === 0" class="bcm-muted">{{ t('discussion.empty') }}</p>
|
|
262
|
+
<ol v-else class="bcm-thread">
|
|
263
|
+
<MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
|
|
264
|
+
</ol>
|
|
265
|
+
|
|
266
|
+
<div v-if="state.thread.nextCursor !== null" class="bcm-row">
|
|
267
|
+
<button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('discussion.loadMore') }}</button>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div class="bcm-section">
|
|
271
|
+
<p v-if="mode === 'write' && state.art9RefusedNote" class="bcm-hint">{{ t('onboarding.art9RefusedDone') }}</p>
|
|
272
|
+
<MessageComposer
|
|
273
|
+
v-if="mode === 'write'"
|
|
274
|
+
ref="composer"
|
|
275
|
+
kind="new"
|
|
276
|
+
:draft="state.composer"
|
|
277
|
+
:wait="ctl.waitLeft(state.composer)"
|
|
278
|
+
:rules-href="routes.rulesPath"
|
|
279
|
+
:mentions-max="ctl.mentionCap()"
|
|
280
|
+
@submit="ctl.submit()"
|
|
281
|
+
@confirm="(code: string, checked: boolean) => ctl && ctl.setConfirmed(state!.composer, code, checked)"
|
|
282
|
+
@confirmed="ctl.sendConfirmed(state.composer)"
|
|
283
|
+
/>
|
|
284
|
+
<div v-else-if="mode === 'signIn'" class="bcm-row">
|
|
285
|
+
<p>{{ t('discussion.signInPrompt') }}</p>
|
|
286
|
+
<a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
|
|
287
|
+
</div>
|
|
288
|
+
<template v-else-if="mode === 'onboarding'">
|
|
289
|
+
<OnboardingSteps v-if="ctl.onboarding.value" :model="ctl.onboarding.value" :rules-href="routes.rulesPath" @cancel="ctl.cancelOnboarding()" />
|
|
290
|
+
<div v-else class="bcm-row">
|
|
291
|
+
<p>{{ t(needKey) }}</p>
|
|
292
|
+
<button type="button" class="bcm-btn bcm-primary" @click="ctl.beginOnboarding()">{{ t('onboarding.continue') }}</button>
|
|
293
|
+
</div>
|
|
294
|
+
</template>
|
|
295
|
+
<p v-else-if="mode === 'ageRefused'" class="bcm-note">{{ t('onboarding.ageBelow', { min: state.refusedAge ?? 18 }) }}</p>
|
|
296
|
+
<div v-else-if="mode === 'sanction'" class="bcm-section">
|
|
297
|
+
<p v-for="r in state.thread.viewer.restrictions" :key="r.decisionId" class="bcm-note">
|
|
298
|
+
{{ sanctionText(r) }}
|
|
299
|
+
<a v-if="statementPath(routes.statementPath, r.decisionId)" :href="statementPath(routes.statementPath, r.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
|
|
300
|
+
</p>
|
|
301
|
+
</div>
|
|
302
|
+
<p v-else-if="mode === 'notMoved'" class="bcm-note">{{ t('error.notMoved') }}</p>
|
|
303
|
+
<p v-else-if="mode === 'braked'" class="bcm-note" role="status">{{ t('state.braked') }}</p>
|
|
304
|
+
<p v-else-if="mode === 'restricted'" class="bcm-note">
|
|
305
|
+
{{ t('error.restricted') }}
|
|
306
|
+
<a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
|
|
307
|
+
</p>
|
|
308
|
+
</div>
|
|
309
|
+
</template>
|
|
310
|
+
|
|
311
|
+
<p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
|
|
312
|
+
|
|
313
|
+
<ReportDialog
|
|
314
|
+
v-if="state.report"
|
|
315
|
+
:report="state.report"
|
|
316
|
+
:my-community-href="routes.myCommunityPath"
|
|
317
|
+
:rules-href="routes.rulesPath"
|
|
318
|
+
:fallback-focus="() => heading"
|
|
319
|
+
@submit="ctl.submitReport()"
|
|
320
|
+
@close="ctl.closeReport()"
|
|
321
|
+
/>
|
|
322
|
+
<ConfirmDialog
|
|
323
|
+
v-if="deleting !== null"
|
|
324
|
+
title="discussion.deleteTitle"
|
|
325
|
+
explain="discussion.deleteExplain"
|
|
326
|
+
confirm="discussion.deleteConfirm"
|
|
327
|
+
danger
|
|
328
|
+
:busy="!!state.busy[`delete:${deleting}`]"
|
|
329
|
+
:fallback-focus="() => heading"
|
|
330
|
+
@confirm="confirmDelete()"
|
|
331
|
+
@cancel="deleting = null"
|
|
332
|
+
/>
|
|
333
|
+
</section>
|
|
334
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A member's text, as text: runs of text nodes, the web addresses in it as links that went through sanitizeUrl
|
|
4
|
+
* (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab — and, with `profilePath` (M2), each `@handle`
|
|
5
|
+
* as a link to that member's profile page on this site. Never HTML.
|
|
6
|
+
*/
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import { profilePath as profilePathOf } from '@blogmcp/sdk/community'
|
|
9
|
+
import { linkifyWithMentions, UGC_LINK_REL } from '../core/text'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{ text: string | null; profilePath?: string | null }>()
|
|
12
|
+
const segments = computed(() => {
|
|
13
|
+
const template = props.profilePath
|
|
14
|
+
return linkifyWithMentions(props.text ?? '', template ? (handle: string) => profilePathOf(template, handle) : null)
|
|
15
|
+
})
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<template v-for="(s, i) in segments" :key="i">
|
|
20
|
+
<a v-if="s.kind === 'link'" :href="s.href" :rel="UGC_LINK_REL" target="_blank">{{ s.text }}</a>
|
|
21
|
+
<a v-else-if="s.kind === 'mention'" class="bcm-mention" :href="s.href">{{ s.text }}</a>
|
|
22
|
+
<template v-else>{{ s.text }}</template>
|
|
23
|
+
</template>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-member-settings>`: the member's notification settings (m2-contracts.md §3.3, §3.4, §3.10) — the six
|
|
4
|
+
* switches saved one by one, this browser's push (only while the module offers it: „Zapnout upozornění" on the
|
|
5
|
+
* member's click, „Vypnout", the sentence about what a notification carries, „Otevřít v prohlížeči" in Facebook's or
|
|
6
|
+
* Instagram's in-app browser, the install guide on iOS outside the home screen), and the digest's one-click
|
|
7
|
+
* unsubscribe when the e-mail's link (`#u=`) brought the member here — which works without a session too.
|
|
8
|
+
*/
|
|
9
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useHost, watch } from 'vue'
|
|
10
|
+
import { signInLink, type CommunityMessageKey, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
11
|
+
import { isSitePath } from '../core/attrs'
|
|
12
|
+
import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
|
|
13
|
+
import { digestTokenForElement } from '../core/digest-token'
|
|
14
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
15
|
+
import { failureText } from '../core/outcomes'
|
|
16
|
+
import { createMemberSettings, SETTING_KEYS, type MemberSettingsController, type SettingKey } from '../core/settings'
|
|
17
|
+
import { provideText, uid } from './context'
|
|
18
|
+
|
|
19
|
+
defineOptions({ inheritAttrs: false })
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
apiBase?: string
|
|
23
|
+
siteBase?: string
|
|
24
|
+
lang?: string
|
|
25
|
+
/** The host's service worker script (default `<site-base>/sw.js`): served from @blogmcp/sdk/community/sw. */
|
|
26
|
+
swPath?: string
|
|
27
|
+
routes?: string | Record<string, unknown>
|
|
28
|
+
signInHref?: string
|
|
29
|
+
transport?: CommunityTransport
|
|
30
|
+
}>()
|
|
31
|
+
|
|
32
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
33
|
+
const { t, text } = provideText(lang)
|
|
34
|
+
const id = uid('bcm-settings')
|
|
35
|
+
const host = useHost()
|
|
36
|
+
|
|
37
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
38
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
39
|
+
|
|
40
|
+
function swPathOf(value: string | undefined): string | null {
|
|
41
|
+
if (value === undefined || value === '') return `${siteBase ?? ''}/sw.js`
|
|
42
|
+
if (isSitePath(value)) return value
|
|
43
|
+
warnOnce('sw-path', '[blogmcp-community] sw-path must be a path on this site (e.g. "/blog/sw.js"): the service worker is the site’s own')
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
// The digest's token is read and cleared from the address first, whatever follows: a misconfigured element (no
|
|
47
|
+
// client, a refused sw-path) renders nothing, but never leaves the member's capability in the URL or its history.
|
|
48
|
+
const digestToken = digestTokenForElement(host)
|
|
49
|
+
const swPath = swPathOf(props.swPath)
|
|
50
|
+
const ctl: MemberSettingsController | null = client && swPath ? createMemberSettings({ client, swPath, digestToken }) : null
|
|
51
|
+
const state = ctl?.state ?? null
|
|
52
|
+
|
|
53
|
+
onMounted(() => void ctl?.load())
|
|
54
|
+
onBeforeUnmount(() => ctl?.dispose())
|
|
55
|
+
|
|
56
|
+
const LABELS: Readonly<Record<SettingKey, CommunityMessageKey>> = Object.freeze({
|
|
57
|
+
notifyReply: 'settings.notifyReply',
|
|
58
|
+
notifyMention: 'settings.notifyMention',
|
|
59
|
+
notifyTopic: 'settings.notifyTopic',
|
|
60
|
+
pushEnabled: 'settings.pushEnabled',
|
|
61
|
+
digestEmail: 'settings.digestEmail',
|
|
62
|
+
quietHours: 'settings.quietHours',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const failure = computed(() => {
|
|
66
|
+
const f = state?.failure
|
|
67
|
+
if (!f) return ''
|
|
68
|
+
const r = failureText(f, lang.value)
|
|
69
|
+
return t(r.key, r.params)
|
|
70
|
+
})
|
|
71
|
+
const pushFailure = computed(() => {
|
|
72
|
+
const f = state?.push.failure
|
|
73
|
+
if (!f) return ''
|
|
74
|
+
const r = failureText(f, lang.value)
|
|
75
|
+
return t(r.key, r.params)
|
|
76
|
+
})
|
|
77
|
+
const digestFailure = computed(() => {
|
|
78
|
+
const f = state?.digest.failure
|
|
79
|
+
if (!f) return ''
|
|
80
|
+
const r = failureText(f, lang.value)
|
|
81
|
+
return t(r.key, r.params)
|
|
82
|
+
})
|
|
83
|
+
const pageUrl = computed(() => (typeof location === 'undefined' ? '' : location.href))
|
|
84
|
+
|
|
85
|
+
function signInHref(): string | null {
|
|
86
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onSwitch(key: SettingKey, e: Event): void {
|
|
90
|
+
void ctl?.toggle(key, (e.target as HTMLInputElement).checked)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const liveText = ref('')
|
|
94
|
+
watch(
|
|
95
|
+
() => state?.announceId,
|
|
96
|
+
() => {
|
|
97
|
+
liveText.value = ''
|
|
98
|
+
const next = text(state?.announcement)
|
|
99
|
+
void nextTick(() => {
|
|
100
|
+
liveText.value = next
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<section v-if="ctl && state" class="bcm bcm-settings" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
108
|
+
<h2 :id="`${id}-title`">{{ t('settings.title') }}</h2>
|
|
109
|
+
|
|
110
|
+
<!-- the digest's unsubscribe link: offered whoever is signed in (the link is the authorisation) -->
|
|
111
|
+
<section v-if="state.digest.offered" class="bcm-card" :aria-labelledby="`${id}-digest`">
|
|
112
|
+
<h3 :id="`${id}-digest`">{{ t('settings.digestEmail') }}</h3>
|
|
113
|
+
<p v-if="state.digest.outcome === 'unsubscribed'" role="status">{{ t('settings.digest.unsubscribed') }}</p>
|
|
114
|
+
<p v-else-if="state.digest.outcome === 'staleLink'" class="bcm-note" role="alert">{{ t('settings.digest.staleLink') }}</p>
|
|
115
|
+
<template v-else>
|
|
116
|
+
<p>{{ t('settings.digest.prompt') }}</p>
|
|
117
|
+
<p v-if="digestFailure" class="bcm-error" role="alert">{{ digestFailure }}</p>
|
|
118
|
+
<div class="bcm-row">
|
|
119
|
+
<button type="button" class="bcm-btn bcm-primary" :disabled="state.digest.sending" @click="ctl.unsubscribeDigest()">{{ t('settings.digest.confirm') }}</button>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|
|
122
|
+
</section>
|
|
123
|
+
|
|
124
|
+
<p v-if="state.phase === 'loading' || state.phase === 'idle'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
125
|
+
<div v-else-if="state.phase === 'signIn'" class="bcm-row">
|
|
126
|
+
<p>{{ t('settings.signIn') }}</p>
|
|
127
|
+
<a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
|
|
128
|
+
</div>
|
|
129
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
130
|
+
<span>{{ failure }}</span>
|
|
131
|
+
<button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
|
|
132
|
+
</div>
|
|
133
|
+
<template v-else-if="state.settings">
|
|
134
|
+
<ul class="bcm-switches">
|
|
135
|
+
<li v-for="key in SETTING_KEYS" :key="key">
|
|
136
|
+
<label class="bcm-check">
|
|
137
|
+
<input type="checkbox" :name="key" :checked="state.settings[key]" :disabled="!!state.saving[key]" @change="onSwitch(key, $event)" />
|
|
138
|
+
<span>{{ t(LABELS[key]) }}</span>
|
|
139
|
+
</label>
|
|
140
|
+
</li>
|
|
141
|
+
</ul>
|
|
142
|
+
|
|
143
|
+
<!-- this browser's push -->
|
|
144
|
+
<section v-if="ctl.pushShown()" class="bcm-card" :aria-labelledby="`${id}-push`">
|
|
145
|
+
<h3 :id="`${id}-push`">{{ t('settings.pushEnabled') }}</h3>
|
|
146
|
+
<p class="bcm-hint">{{ t('settings.push.carries') }}</p>
|
|
147
|
+
<template v-if="state.push.support === 'inApp'">
|
|
148
|
+
<p>{{ t('settings.push.inAppExplain') }}</p>
|
|
149
|
+
<p><a :href="pageUrl" target="_blank" rel="noopener">{{ t('settings.push.openInBrowser') }}</a></p>
|
|
150
|
+
<p class="bcm-url">{{ pageUrl }}</p>
|
|
151
|
+
</template>
|
|
152
|
+
<p v-else-if="state.push.support === 'iosInstall'">{{ t('settings.push.iosGuide') }}</p>
|
|
153
|
+
<p v-else-if="state.push.support === 'unsupported'">{{ t('settings.push.unsupported') }}</p>
|
|
154
|
+
<template v-else>
|
|
155
|
+
<p v-if="state.push.note === 'reset'" class="bcm-note">{{ t('settings.push.reset') }}</p>
|
|
156
|
+
<p v-else-if="state.push.note === 'denied'" class="bcm-note" role="alert">{{ t('settings.push.denied') }}</p>
|
|
157
|
+
<p v-else-if="state.push.note === 'refused'" class="bcm-note" role="alert">{{ t('settings.push.refused') }}</p>
|
|
158
|
+
<p v-if="pushFailure && state.push.note === null" class="bcm-error" role="alert">{{ pushFailure }}</p>
|
|
159
|
+
<div class="bcm-row">
|
|
160
|
+
<button v-if="state.push.status === 'on'" type="button" class="bcm-btn" @click="ctl.disablePush()">{{ t('settings.push.disable') }}</button>
|
|
161
|
+
<button
|
|
162
|
+
v-else
|
|
163
|
+
type="button"
|
|
164
|
+
class="bcm-btn bcm-primary"
|
|
165
|
+
:disabled="state.push.status === 'busy' || state.push.status === 'checking'"
|
|
166
|
+
@click="ctl.enablePush()"
|
|
167
|
+
>{{ t('settings.push.enable') }}</button>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|
|
170
|
+
</section>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
|
|
174
|
+
</section>
|
|
175
|
+
</template>
|