@blogmcp/community 0.1.0 → 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 (68) hide show
  1. package/README.md +108 -11
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/{mount-DzjwnHyx.js → mount-WadUnaTa.js} +3909 -2070
  4. package/dist/core/attrs.d.ts +27 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +36 -5
  9. package/dist/core/format.d.ts +7 -0
  10. package/dist/core/onboarding.d.ts +42 -2
  11. package/dist/core/outcomes.d.ts +2 -1
  12. package/dist/core/panel.d.ts +17 -1
  13. package/dist/core/profile.d.ts +40 -0
  14. package/dist/core/push.d.ts +63 -0
  15. package/dist/core/realtime.d.ts +35 -0
  16. package/dist/core/settings.d.ts +53 -0
  17. package/dist/core/text.d.ts +13 -0
  18. package/dist/element.d.ts +22 -1
  19. package/dist/global.d.ts +7 -4
  20. package/dist/index.d.ts +10 -2
  21. package/dist/index.js +34 -11
  22. package/dist/mount.d.ts +40 -0
  23. package/dist/react.d.ts +14 -3
  24. package/dist/react.js +24 -4
  25. package/dist/ui/BadgeView.vue.d.ts +13 -0
  26. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  27. package/dist/ui/LinkedText.vue.d.ts +1 -0
  28. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  29. package/dist/ui/MessageComposer.vue.d.ts +2 -0
  30. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  31. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  32. package/dist/ui/ProfileView.vue.d.ts +13 -0
  33. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  34. package/dist/vue.d.ts +114 -3
  35. package/dist/vue.js +109 -5
  36. package/package.json +3 -3
  37. package/src/core/attrs.ts +41 -2
  38. package/src/core/badge.ts +120 -0
  39. package/src/core/centre.ts +136 -0
  40. package/src/core/digest-token.ts +26 -0
  41. package/src/core/discussion.ts +203 -18
  42. package/src/core/format.ts +22 -0
  43. package/src/core/onboarding.ts +165 -18
  44. package/src/core/outcomes.ts +13 -1
  45. package/src/core/panel.ts +39 -4
  46. package/src/core/profile.ts +144 -0
  47. package/src/core/push.ts +174 -0
  48. package/src/core/realtime.ts +39 -0
  49. package/src/core/settings.ts +363 -0
  50. package/src/core/text.ts +32 -0
  51. package/src/element.ts +62 -6
  52. package/src/global.ts +18 -4
  53. package/src/index.ts +36 -1
  54. package/src/mount.ts +108 -1
  55. package/src/react.ts +47 -3
  56. package/src/ui/BadgeView.vue +51 -0
  57. package/src/ui/DiscussionView.vue +43 -3
  58. package/src/ui/LinkedText.vue +11 -5
  59. package/src/ui/MemberSettingsView.vue +175 -0
  60. package/src/ui/MessageComposer.vue +15 -4
  61. package/src/ui/MessageItem.vue +24 -6
  62. package/src/ui/MyCommunityView.vue +9 -0
  63. package/src/ui/NotificationCentreView.vue +159 -0
  64. package/src/ui/OnboardingSteps.vue +17 -6
  65. package/src/ui/ProfileView.vue +91 -0
  66. package/src/ui/WelcomeView.vue +72 -0
  67. package/src/ui/styles.css +16 -0
  68. package/src/vue.ts +129 -3
@@ -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>
@@ -3,10 +3,12 @@
3
3
  * The composer of a new message, a reply or an edit: 4 000 characters with a counter (announced near the
4
4
  * end), the links note, a link to the rules (community.md §4.5: by every composer; a new tab, so the text
5
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`).
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.
7
9
  */
8
10
  import { computed, onBeforeUnmount, ref, watch } from 'vue'
9
- import { COMMUNITY_MESSAGE_LINKS_MAX, COMMUNITY_MESSAGE_MAX, confirmMessageKey } from '@blogmcp/sdk/community'
11
+ import { COMMUNITY_MESSAGE_LINKS_MAX, COMMUNITY_MESSAGE_MAX, communityMentionHandles, confirmMessageKey } from '@blogmcp/sdk/community'
10
12
  import type { Draft } from '../core/discussion'
11
13
  import { COUNTDOWN_MAX_S, failureText } from '../core/outcomes'
12
14
  import { codePointLength, normalizeBody } from '../core/text'
@@ -20,6 +22,8 @@ const props = defineProps<{
20
22
  replyingTo?: string | null
21
23
  /** The host's rules page. */
22
24
  rulesHref?: string
25
+ /** M2 T10: the distinct mentions one message may carry; null or absent: no limit known here. */
26
+ mentionsMax?: number | null
23
27
  }>()
24
28
  const emit = defineEmits<{ submit: []; cancel: []; confirmed: []; confirm: [code: string, checked: boolean] }>()
25
29
  const { t, lang } = useText()
@@ -28,7 +32,11 @@ const id = uid('bcm-composer')
28
32
  const input = ref<HTMLTextAreaElement | null>(null)
29
33
  const length = computed(() => codePointLength(normalizeBody(props.draft.body)))
30
34
  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)
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)
32
40
  const allConfirmed = computed(() => {
33
41
  const c = props.draft.confirm
34
42
  return c !== null && c.codes.every((code) => c.checked.includes(code))
@@ -39,6 +47,8 @@ const failure = computed(() => {
39
47
  const f = props.draft.failure
40
48
  // The confirmation box says what CONFIRMATION_REQUIRED meant; any later refusal (the confirmed send's) shows.
41
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 ''
42
52
  // A short wait reads as the countdown instead.
43
53
  if ((f.code === 'fast' || f.code === 'quota') && props.wait > 0 && props.wait <= COUNTDOWN_MAX_S) return ''
44
54
  const ref = failureText(f, lang.value)
@@ -94,7 +104,7 @@ defineExpose({ focus: () => input.value?.focus() })
94
104
  rows="4"
95
105
  :placeholder="t('composer.placeholder')"
96
106
  :aria-describedby="`${id}-counter ${id}-hints`"
97
- :aria-invalid="over"
107
+ :aria-invalid="over || mentionsOver"
98
108
  :readonly="draft.sending"
99
109
  @keydown="onKey"
100
110
  />
@@ -113,6 +123,7 @@ defineExpose({ focus: () => input.value?.focus() })
113
123
  <p v-if="rulesHref"><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesTitle') }}</a></p>
114
124
  </div>
115
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>
116
127
  <p v-if="wait > 0 && wait <= COUNTDOWN_MAX_S" class="bcm-warning" role="status">{{ t('composer.cooldown', { count: wait }) }}</p>
117
128
  <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
118
129
  <fieldset v-if="draft.confirm" class="bcm-confirm">
@@ -13,7 +13,7 @@
13
13
  * the plain placeholder.
14
14
  */
15
15
  import { computed } from 'vue'
16
- import { hasCommunityMessage, noteMessageKey, warningMessageKey, type CommunityMessageKey, type DiscussionMessage } from '@blogmcp/sdk/community'
16
+ import { communityMessageAnchor, hasCommunityMessage, noteMessageKey, warningMessageKey, type CommunityMessageKey, type DiscussionMessage } from '@blogmcp/sdk/community'
17
17
  import { reportLink } from '../core/attrs'
18
18
  import { formatDate } from '../core/format'
19
19
  import AuthorAvatar from './AuthorAvatar.vue'
@@ -40,9 +40,18 @@ const own = computed(() => ctl.isOwn(m.value))
40
40
  const placeholder = computed(() => m.value.body === null)
41
41
  /** The author's own message held back from others until their art. 9 consent (P-RV3) — not one awaiting review. */
42
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. */
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)
44
49
  const canGiveConsent = computed(
45
- () => untilConsent.value && ctl.mode() === 'onboarding' && ctl.needs().art9 && !ctl.onboarding.value && ctl.state.refusedAge === null,
50
+ () =>
51
+ untilConsent.value &&
52
+ !ctl.onboarding.value &&
53
+ ctl.state.refusedAge === null &&
54
+ ((ctl.mode() === 'onboarding' && ctl.needs().art9) || refusedConsent.value),
46
55
  )
47
56
 
48
57
  const authorName = computed(() => {
@@ -62,6 +71,8 @@ const badge = computed(() => {
62
71
  const placeholderKey = computed<CommunityMessageKey>(() =>
63
72
  m.value.state === 'held' ? 'discussion.placeholderHeld' : m.value.state === 'removed' ? 'discussion.placeholderRemoved' : 'discussion.placeholderDeleted',
64
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)))
65
76
  const hiddenByBlock = computed(() => m.value.collapsed && !opened.has(m.value.seq))
66
77
  const showOriginal = computed(() => originals.has(m.value.seq))
67
78
  const text = computed(() => (m.value.isTranslated && !showOriginal.value ? m.value.displayBody : m.value.body) ?? m.value.displayBody)
@@ -78,6 +89,11 @@ const shownReplies = computed(() => (allReplies.has(m.value.seq) ? m.value.repli
78
89
  const moreReplies = computed(() => m.value.replies.length - shownReplies.value.length)
79
90
 
80
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))
81
97
 
82
98
  const menu = computed<MenuItem[]>(() => {
83
99
  const items: MenuItem[] = []
@@ -112,7 +128,7 @@ function toggle(set: Set<number>): void {
112
128
  </script>
113
129
 
114
130
  <template>
115
- <li class="bcm-msg-item">
131
+ <li :id="anchorId" class="bcm-msg-item">
116
132
  <article class="bcm-msg" :class="{ 'bcm-msg-pending': !!m.pending }" :aria-busy="!!m.pending">
117
133
  <AuthorAvatar :author="m.author" />
118
134
  <div class="bcm-msg-main">
@@ -134,7 +150,7 @@ function toggle(set: Set<number>): void {
134
150
  <div v-if="untilConsent" class="bcm-own-state bcm-own-consent">
135
151
  <span class="bcm-badge">{{ t('state.hiddenUntilConsent') }}</span>
136
152
  <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>
153
+ <button v-if="canGiveConsent" type="button" class="bcm-link" @click="ctl.beginOnboarding(refusedConsent ? { offerArt9: true } : {})">{{ t('discussion.giveConsent') }}</button>
138
154
  </div>
139
155
  <div v-else-if="own && (m.state === 'held' || m.state === 'removed')" class="bcm-own-state">
140
156
  <span class="bcm-badge">{{ m.state === 'held' ? t('state.held') : t('state.removed') }}</span>
@@ -147,12 +163,13 @@ function toggle(set: Set<number>): void {
147
163
  :draft="ctl.state.inline"
148
164
  :wait="ctl.waitLeft(ctl.state.inline)"
149
165
  :rules-href="routes.rulesPath"
166
+ :mentions-max="ctl.mentionCap()"
150
167
  @submit="ctl.submitInline()"
151
168
  @cancel="ctl.closeInline()"
152
169
  @confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
153
170
  @confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
154
171
  />
155
- <p v-else class="bcm-text"><LinkedText :text="text" /></p>
172
+ <p v-else class="bcm-text"><LinkedText :text="text" :profile-path="mentionProfiles" /></p>
156
173
  <p v-if="m.isTranslated && !editing" class="bcm-hint">
157
174
  {{ t('discussion.translated') }}
158
175
  <button type="button" class="bcm-link" @click="toggle(originals)">{{ showOriginal ? t('discussion.showTranslation') : t('discussion.showOriginal') }}</button>
@@ -192,6 +209,7 @@ function toggle(set: Set<number>): void {
192
209
  :draft="ctl.state.inline"
193
210
  :wait="ctl.waitLeft(ctl.state.inline)"
194
211
  :rules-href="routes.rulesPath"
212
+ :mentions-max="ctl.mentionCap()"
195
213
  :replying-to="ctl.state.inline.replyingTo"
196
214
  @submit="ctl.submitInline()"
197
215
  @cancel="ctl.closeInline()"
@@ -29,6 +29,7 @@ import { receiptForElement } from '../core/receipt'
29
29
  import AuthorAvatar from './AuthorAvatar.vue'
30
30
  import ConfirmDialog from './ConfirmDialog.vue'
31
31
  import NoticeStatusView from './NoticeStatusView.vue'
32
+ import OnboardingSteps from './OnboardingSteps.vue'
32
33
  import StatementView from './StatementView.vue'
33
34
  import { provideText, uid } from './context'
34
35
 
@@ -120,6 +121,7 @@ const appealStatusKey = (decisionId: string): CommunityMessageKey | null => {
120
121
  }
121
122
 
122
123
  const consent = computed(() => state?.me?.acceptances.art9Consent ?? null)
124
+ const consentRefused = computed(() => state?.me?.acceptances.art9Refused === true)
123
125
  const member = computed(() => state?.me?.member ?? null)
124
126
  const profile = computed(() => state?.me?.profile ?? null)
125
127
 
@@ -309,6 +311,13 @@ watch(
309
311
  <button type="button" class="bcm-btn" :disabled="!!state.busy.consent" @click="confirming = 'consent'">{{ t('panel.art9Withdraw') }}</button>
310
312
  </div>
311
313
  </template>
314
+ <template v-else-if="consentRefused">
315
+ <p>{{ t('panel.art9Refused') }}</p>
316
+ <OnboardingSteps v-if="ctl.consent.value" :model="ctl.consent.value" :rules-href="routes.rulesPath" @cancel="ctl.closeConsent()" />
317
+ <div v-else class="bcm-row">
318
+ <button type="button" class="bcm-btn bcm-primary" @click="ctl.giveConsent()">{{ t('discussion.giveConsent') }}</button>
319
+ </div>
320
+ </template>
312
321
  <p v-else>{{ t('panel.art9NotGiven') }}</p>
313
322
  </section>
314
323
 
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-notification-centre>`: the member's notifications (m2-contracts.md §3.3, §3.10) — newest first, older
4
+ * ones a page at a time, „Jen nepřečtené", one marked read or all, and the link to the settings page. Each row is the
5
+ * catalogue's sentence of its code (never anything a member wrote) and, where the host has a page for it, its
6
+ * subject's link: a decision's statement, the settings (push reset), „Moje komunita" (the module's and the account's
7
+ * notices), and (M2 T10) a reply's or a mention's message on its article — the host's `articlePath` with the slug
8
+ * and the message's anchor (`#bcm-msg-<seq>`); a host without `articlePath`, or a row without a slug or seq (an
9
+ * article no longer live, an older kb-api), keeps the sentence unlinked. A visitor gets the sign-in sentence.
10
+ */
11
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
12
+ import {
13
+ communityMessageLink,
14
+ notificationMessageKey,
15
+ signInLink,
16
+ statementPath,
17
+ type CommunityMessageKey,
18
+ type CommunityTransport,
19
+ type NotificationItem,
20
+ } from '@blogmcp/sdk/community'
21
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
22
+ import { createNotificationCentre, type NotificationCentreController } from '../core/centre'
23
+ import { communityClientFor, siteBaseFor } from '../core/client'
24
+ import { formatDate } from '../core/format'
25
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
26
+ import { failureText } from '../core/outcomes'
27
+ import { provideText, uid } from './context'
28
+
29
+ defineOptions({ inheritAttrs: false })
30
+
31
+ const props = defineProps<{
32
+ apiBase?: string
33
+ siteBase?: string
34
+ lang?: string
35
+ /** JSON (or an object): `statementPath`, `myCommunityPath`, `settingsPath`, `articlePath`, … */
36
+ routes?: string | Record<string, unknown>
37
+ signInHref?: string
38
+ transport?: CommunityTransport
39
+ }>()
40
+
41
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
42
+ const { t, text } = provideText(lang)
43
+ const id = uid('bcm-centre')
44
+
45
+ const siteBase = siteBaseFor(props.siteBase)
46
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
47
+ const ctl: NotificationCentreController | null = client ? createNotificationCentre({ client }) : null
48
+ const state = ctl?.state ?? null
49
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
50
+
51
+ onMounted(() => void ctl?.load())
52
+ onBeforeUnmount(() => ctl?.dispose())
53
+
54
+ const failure = computed(() => {
55
+ const f = state?.failure
56
+ if (!f) return ''
57
+ const r = failureText(f, lang.value)
58
+ return t(r.key, r.params)
59
+ })
60
+
61
+ function signInHref(): string | null {
62
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
63
+ }
64
+
65
+ const sentence = (n: NotificationItem): CommunityMessageKey | null => notificationMessageKey(n.code)
66
+
67
+ /** The page a notification's subject is on, with its link text; null when the host has none for it. */
68
+ function subjectOf(n: NotificationItem): { href: string; key: CommunityMessageKey } | null {
69
+ if (n.decisionId) {
70
+ const href = statementPath(routes.value.statementPath, n.decisionId)
71
+ return href ? { href, key: 'sanction.statement' } : null
72
+ }
73
+ if (n.code === 'reply' || n.code === 'mention') {
74
+ const href = communityMessageLink(routes.value.articlePath, n.articleSlug, n.seq)
75
+ return href ? { href, key: 'centre.openMessage' } : null
76
+ }
77
+ if (n.code === 'module_push_reset') return { href: routes.value.settingsPath, key: 'centre.settings' }
78
+ if (n.code === 'module_read_only' || n.code === 'module_braked' || n.code === 'age_limit_changed' || n.code === 'erasure_scheduled' || n.code === 'inactivity_warning') {
79
+ return { href: routes.value.myCommunityPath, key: 'panel.title' }
80
+ }
81
+ return null
82
+ }
83
+
84
+ function onUnreadOnly(e: Event): void {
85
+ void ctl?.setUnreadOnly((e.target as HTMLInputElement).checked)
86
+ }
87
+
88
+ const liveText = ref('')
89
+ watch(
90
+ () => state?.announceId,
91
+ () => {
92
+ liveText.value = ''
93
+ const next = text(state?.announcement)
94
+ void nextTick(() => {
95
+ liveText.value = next
96
+ })
97
+ },
98
+ )
99
+ </script>
100
+
101
+ <template>
102
+ <section v-if="ctl && state" class="bcm bcm-centre" :lang="lang" :aria-labelledby="`${id}-title`">
103
+ <div class="bcm-row">
104
+ <h2 :id="`${id}-title`">{{ t('centre.title') }}</h2>
105
+ <span v-if="state.phase === 'ready' && ctl.unread() > 0" class="bcm-badge">{{ t('panel.unread', { count: ctl.unread() }) }}</span>
106
+ </div>
107
+
108
+ <p v-if="state.phase === 'loading' || state.phase === 'idle'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
109
+ <div v-else-if="state.phase === 'signIn'" class="bcm-row">
110
+ <p>{{ t('centre.signIn') }}</p>
111
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
112
+ </div>
113
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
114
+ <span>{{ failure }}</span>
115
+ <button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
116
+ </div>
117
+ <template v-else>
118
+ <div class="bcm-row">
119
+ <label class="bcm-check">
120
+ <input type="checkbox" :checked="state.unreadOnly" @change="onUnreadOnly" />
121
+ <span>{{ t('centre.unreadOnly') }}</span>
122
+ </label>
123
+ <span class="bcm-spacer" />
124
+ <!-- Also while older pages are not loaded yet: their unread rows are marked by { all: true } too. -->
125
+ <button v-if="ctl.unread() > 0 || state.nextCursor !== null" type="button" class="bcm-btn bcm-small" :disabled="!!state.busy.all" @click="ctl.markAllRead()">{{ t('centre.markAllRead') }}</button>
126
+ </div>
127
+
128
+ <p v-if="state.items.length === 0" class="bcm-muted">{{ t('centre.empty') }}</p>
129
+ <ul v-else class="bcm-list">
130
+ <li v-for="n in state.items" :key="n.id" :class="{ 'bcm-unread': n.readAt === null }">
131
+ <p v-if="sentence(n)" :id="`${id}-s-${n.id}`">{{ t(sentence(n)!) }}</p>
132
+ <time :id="`${id}-t-${n.id}`" class="bcm-time" :datetime="n.createdAt">{{ formatDate(lang, n.createdAt) }}</time>
133
+ <div class="bcm-row">
134
+ <a v-if="subjectOf(n)" :href="subjectOf(n)!.href">{{ t(subjectOf(n)!.key) }}</a>
135
+ <span class="bcm-spacer" />
136
+ <!-- Every row's button reads the same: its row's sentence and time tell a screen reader which one it is. -->
137
+ <button
138
+ v-if="n.readAt === null"
139
+ type="button"
140
+ class="bcm-btn bcm-small"
141
+ :aria-describedby="sentence(n) ? `${id}-s-${n.id} ${id}-t-${n.id}` : `${id}-t-${n.id}`"
142
+ :disabled="!!state.busy[`read:${n.id}`]"
143
+ @click="ctl.markRead(n.id)"
144
+ >
145
+ {{ t('panel.markRead') }}
146
+ </button>
147
+ </div>
148
+ </li>
149
+ </ul>
150
+
151
+ <div v-if="state.nextCursor !== null" class="bcm-row">
152
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('centre.loadMore') }}</button>
153
+ </div>
154
+ <p><a :href="routes.settingsPath">{{ t('centre.settings') }}</a></p>
155
+ </template>
156
+
157
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
158
+ </section>
159
+ </template>
@@ -1,14 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * The inline onboarding (core/onboarding.ts): one step at a time, the focus moved to each step's heading,
4
- * the rules as text with a link to the full page, the art. 9 text as text paragraphs.
4
+ * the rules as text with a link to the full page, the art. 9 text as text paragraphs — shown once, with one consent
5
+ * under `joint` and one per controller under `split` (each labelled with its controller, all required), and
6
+ * „Nesouhlasím" where the tenant's refusal leaves the public discussion open (m2-contracts.md §3.8).
5
7
  */
6
8
  import { computed, nextTick, onMounted, ref, watch } from 'vue'
7
9
  import { AGE_BANDS, COMMUNITY_DISPLAY_NAME_MAX, COMMUNITY_HANDLE_CHANGE_DAYS, type AgeBand, type CommunityMessageKey } from '@blogmcp/sdk/community'
8
10
  import { AVATAR_PRESETS } from '../core/avatars'
9
11
  import { HANDLE_MAX, HANDLE_MIN } from '../core/names'
10
- import type { OnboardingModel } from '../core/onboarding'
11
- import { failureText } from '../core/outcomes'
12
+ import { onboardingFailureText, type OnboardingModel } from '../core/onboarding'
12
13
  import { uid, useText } from './context'
13
14
 
14
15
  const props = defineProps<{ model: OnboardingModel; rulesHref: string }>()
@@ -24,7 +25,7 @@ const fresh = computed(() => s.steps.includes('profile'))
24
25
  const failure = computed(() => {
25
26
  const f = s.failure
26
27
  if (!f) return ''
27
- const ref = failureText(f, lang.value)
28
+ const ref = onboardingFailureText(f, lang.value)
28
29
  return t(ref.key, ref.params)
29
30
  })
30
31
  const ageKey = (band: AgeBand): CommunityMessageKey => `onboarding.age.${band}`
@@ -91,11 +92,20 @@ onMounted(() => void nextTick(() => heading.value?.focus()))
91
92
 
92
93
  <div v-else-if="step === 'art9'" class="bcm-section">
93
94
  <h4>{{ t('onboarding.art9Title') }}</h4>
94
- <p>{{ t('onboarding.art9Lede') }}</p>
95
+ <!-- Where a refusal leaves the public discussion open, the lede does not make writing depend on the consent. -->
96
+ <p>{{ model.refusalOffered() ? t('onboarding.art9LedeOptional') : t('onboarding.art9Lede') }}</p>
95
97
  <div class="bcm-consent" tabindex="0" role="document" :aria-label="t('onboarding.art9Title')">
96
98
  <p v-for="(paragraph, i) in s.art9Paragraphs" :key="i" class="bcm-text">{{ paragraph }}</p>
97
99
  </div>
98
- <label class="bcm-check">
100
+ <!-- split: one consent per controller, grouped, each one required (the form is novalidate: `required` is announced). -->
101
+ <fieldset v-if="s.art9Controllers.length > 1" class="bcm-fieldset">
102
+ <legend class="bcm-sr">{{ t('onboarding.art9Title') }}</legend>
103
+ <label v-for="c in s.art9Controllers" :key="c.entity" class="bcm-check">
104
+ <input v-model="c.accepted" type="checkbox" required />
105
+ <span>{{ t('onboarding.art9Controller', { label: c.label }) }}</span>
106
+ </label>
107
+ </fieldset>
108
+ <label v-else class="bcm-check">
99
109
  <input v-model="s.art9Accepted" type="checkbox" />
100
110
  <span>{{ t('onboarding.art9Accept') }}</span>
101
111
  </label>
@@ -154,6 +164,7 @@ onMounted(() => void nextTick(() => heading.value?.focus()))
154
164
  <button v-if="s.index > 0" type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="model.back()">{{ t('onboarding.back') }}</button>
155
165
  <button type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="emit('cancel')">{{ t('common.cancel') }}</button>
156
166
  <span class="bcm-spacer" />
167
+ <button v-if="model.canRefuse()" type="button" class="bcm-btn" @click="model.refuse()">{{ t('onboarding.art9Refuse') }}</button>
157
168
  <button type="submit" class="bcm-btn bcm-primary" :disabled="!model.canContinue()">
158
169
  {{ s.phase === 'sending' ? t('composer.sending') : model.isLast() ? t('onboarding.submit') : t('onboarding.continue') }}
159
170
  </button>