@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,174 @@
1
+ /**
2
+ * The browser's half of web push (m2-contracts.md §3.4): the one module of the elements that touches the service
3
+ * worker and the PushManager (test/closed-paths.spec.ts lets `serviceWorker` appear here and nowhere else).
4
+ *
5
+ * The worker is registered only from `<blogmcp-member-settings>`, on the member's click („Zapnout upozornění"),
6
+ * never on page load; the worker's script is the host's route (`sw-path`, default `<site-base>/sw.js`, served from
7
+ * `@blogmcp/sdk/community/sw`). The subscription's keys go to kb-api through the SDK client only.
8
+ *
9
+ * Feature checks read the browser's globals on call (never on import), so a server render and a test can swap them.
10
+ */
11
+ import { isInAppBrowser, type PushUserAgentClass } from '@blogmcp/sdk/community'
12
+
13
+ /** What the browser can do about push, in the order the settings element asks. */
14
+ export type PushSupport =
15
+ /** Facebook's or Instagram's in-app browser: „Otevřít v prohlížeči" with the page's URL instead. */
16
+ | 'inApp'
17
+ /** iOS outside an installed web app (`navigator.standalone !== true`): the install guide instead. */
18
+ | 'iosInstall'
19
+ /** No service worker, PushManager or Notification: one sentence. */
20
+ | 'unsupported'
21
+ | 'supported'
22
+
23
+ /** The navigator as far as push reads it (Safari's `standalone` included). */
24
+ interface PushNavigator {
25
+ readonly userAgent?: string
26
+ readonly platform?: string
27
+ readonly maxTouchPoints?: number
28
+ readonly standalone?: boolean
29
+ readonly serviceWorker?: ServiceWorkerContainer
30
+ }
31
+
32
+ const nav = (): PushNavigator | null => (typeof navigator === 'undefined' ? null : (navigator as unknown as PushNavigator))
33
+
34
+ /** The global Notification API, or null. */
35
+ function notificationApi(): { permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> } | null {
36
+ const g = globalThis as { Notification?: { permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> } }
37
+ return g.Notification && typeof g.Notification.requestPermission === 'function' ? g.Notification : null
38
+ }
39
+
40
+ /**
41
+ * iOS (an iPhone, an iPod, an iPad — which reports itself as a Mac with a touch screen): its browsers take web push
42
+ * only from a website installed to the home screen. A layout hint, never sent anywhere but as `userAgentClass`.
43
+ */
44
+ export function isIos(n: PushNavigator | null = nav()): boolean {
45
+ if (!n) return false
46
+ const ua = n.userAgent ?? ''
47
+ return /iPhone|iPad|iPod/.test(ua) || (n.platform === 'MacIntel' && (n.maxTouchPoints ?? 0) > 1)
48
+ }
49
+
50
+ /** What `me/push/subscribe` is told this browser is (a class, never the user agent itself). */
51
+ export function userAgentClass(n: PushNavigator | null = nav()): PushUserAgentClass {
52
+ if (!n) return 'other'
53
+ if (isIos(n)) return 'ios'
54
+ const ua = n.userAgent ?? ''
55
+ if (/Android/.test(ua)) return 'android'
56
+ if (/Mobi|Tablet/.test(ua)) return 'other'
57
+ return ua === '' ? 'other' : 'desktop'
58
+ }
59
+
60
+ /** What this browser can do about push (the in-app browser first, then iOS outside the home screen). */
61
+ export function pushSupport(): PushSupport {
62
+ const n = nav()
63
+ if (!n) return 'unsupported'
64
+ if (isInAppBrowser(n.userAgent ?? '')) return 'inApp'
65
+ if (isIos(n) && n.standalone !== true) return 'iosInstall'
66
+ const g = globalThis as { PushManager?: unknown }
67
+ if (!n.serviceWorker || typeof n.serviceWorker.register !== 'function' || typeof g.PushManager === 'undefined' || !notificationApi()) return 'unsupported'
68
+ return 'supported'
69
+ }
70
+
71
+ /** The browser's notification permission (`default` where there is no Notification API). */
72
+ export function notificationPermission(): NotificationPermission {
73
+ return notificationApi()?.permission ?? 'default'
74
+ }
75
+
76
+ /** Ask for the permission (only on the member's click); `denied` where it cannot be asked. */
77
+ export async function requestNotificationPermission(): Promise<NotificationPermission> {
78
+ const api = notificationApi()
79
+ if (!api) return 'denied'
80
+ if (api.permission !== 'default') return api.permission
81
+ try {
82
+ return await api.requestPermission()
83
+ } catch {
84
+ return 'denied'
85
+ }
86
+ }
87
+
88
+ /** The worker's registration for this page (null where none is registered or it cannot be asked). */
89
+ export async function currentRegistration(): Promise<ServiceWorkerRegistration | null> {
90
+ const sw = nav()?.serviceWorker
91
+ if (!sw || typeof sw.getRegistration !== 'function') return null
92
+ try {
93
+ return (await sw.getRegistration()) ?? null
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+
99
+ /** Register the host's worker script (the member's click). Throws what the browser throws. */
100
+ export async function registerWorker(swPath: string): Promise<ServiceWorkerRegistration> {
101
+ const sw = nav()?.serviceWorker
102
+ if (!sw) throw new Error('[blogmcp-community] no service worker support')
103
+ return sw.register(swPath)
104
+ }
105
+
106
+ /** The browser's current subscription of a registration, or null. */
107
+ export async function currentSubscription(reg: ServiceWorkerRegistration | null): Promise<PushSubscription | null> {
108
+ if (!reg?.pushManager || typeof reg.pushManager.getSubscription !== 'function') return null
109
+ try {
110
+ return (await reg.pushManager.getSubscription()) ?? null
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ /** base64url (the VAPID public key `me/push/vapid` sends) → the bytes `applicationServerKey` takes; null when malformed. */
117
+ export function base64UrlBytes(value: string): Uint8Array | null {
118
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+={0,2}$/.test(value)) return null
119
+ const b64 = value.replace(/=+$/, '').replace(/-/g, '+').replace(/_/g, '/')
120
+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
121
+ try {
122
+ const raw = atob(padded)
123
+ const out = new Uint8Array(raw.length)
124
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i)
125
+ return out
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ /** A subscription as `me/push/subscribe` takes it (`toJSON()`'s endpoint and keys), or null when one is missing. */
132
+ export function subscriptionInput(sub: PushSubscription): { endpoint: string; keys: { p256dh: string; auth: string } } | null {
133
+ const json = typeof sub.toJSON === 'function' ? sub.toJSON() : null
134
+ const endpoint = json?.endpoint ?? sub.endpoint
135
+ const p256dh = json?.keys?.p256dh
136
+ const auth = json?.keys?.auth
137
+ if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') return null
138
+ return { endpoint, keys: { p256dh, auth } }
139
+ }
140
+
141
+ /**
142
+ * Whose subscription this browser holds and the VAPID key id it was made for, kept per browser (a convenience):
143
+ * `{ memberId, keyId }` under one key. A subscription another member made here (a shared browser), or one without
144
+ * a record (blocked storage, made before), is not shown as this member's; one made for another key id is gone after
145
+ * a rotation (kb-api deleted it), so the switch asks to be turned on again. Every access is guarded: storage may be
146
+ * blocked.
147
+ */
148
+ const PUSH_STORAGE = 'blogmcp-community:push-key-id'
149
+
150
+ export interface StoredPush {
151
+ readonly memberId: string
152
+ readonly keyId: string
153
+ }
154
+
155
+ export function storedPush(): StoredPush | null {
156
+ try {
157
+ const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(PUSH_STORAGE)
158
+ if (raw === null) return null
159
+ const v = JSON.parse(raw) as { memberId?: unknown; keyId?: unknown } | null
160
+ return v && typeof v.memberId === 'string' && typeof v.keyId === 'string' ? { memberId: v.memberId, keyId: v.keyId } : null
161
+ } catch {
162
+ return null
163
+ }
164
+ }
165
+
166
+ export function storePush(value: StoredPush | null): void {
167
+ try {
168
+ if (typeof localStorage === 'undefined') return
169
+ if (value === null) localStorage.removeItem(PUSH_STORAGE)
170
+ else localStorage.setItem(PUSH_STORAGE, JSON.stringify({ memberId: value.memberId, keyId: value.keyId }))
171
+ } catch {
172
+ // Blocked storage: the next visit shows the switch off (whose it is cannot be told); kb-api's module_push_reset still tells a rotation.
173
+ }
174
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The discussion's live updates (m2-contracts.md §3.5, §3.10): `<blogmcp-discussion>` takes a `realtime` PROPERTY —
3
+ * the session factory of `@blogmcp/sdk/community/realtime`, which the HOST imports and passes:
4
+ *
5
+ * import { createRealtimeSession } from '@blogmcp/sdk/community/realtime'
6
+ * mountDiscussion({ articleId, realtime: createRealtimeSession }) // or el.realtime = createRealtimeSession
7
+ *
8
+ * The element calls it with its own client and discussion store after the first `discussions/list`, and `stop()`s
9
+ * the session when it unmounts. The element never imports the realtime entry (nor `centrifuge`): a site that passes no
10
+ * factory ships no socket code, and the discussion polls (`pollDiscussionHead`) as before.
11
+ *
12
+ * The types here are the factory's shape as far as the element uses it, so `createRealtimeSession` fits as it is.
13
+ */
14
+ import type { CommunityClient, DiscussionStore } from '@blogmcp/sdk/community'
15
+
16
+ /** `connecting`, `live`, `polling` (live updates are off or the server cut the socket for good), `stopped`. */
17
+ export type DiscussionRealtimeMode = 'connecting' | 'live' | 'polling' | 'stopped' | (string & {})
18
+
19
+ /** What the element calls the factory with. */
20
+ export interface DiscussionRealtimeOptions {
21
+ client: CommunityClient
22
+ store: DiscussionStore
23
+ /** The discussion's language, for the session's re-reads. */
24
+ languageCode: string
25
+ /** Whether the discussion is seen now (the fallback polls only then). */
26
+ visible: () => boolean
27
+ /** The element's poll interval, for the session's fallback poller (absent: the SDK's default). */
28
+ pollIntervalMs?: number
29
+ onMode: (mode: DiscussionRealtimeMode) => void
30
+ }
31
+
32
+ /** A running session, as far as the element uses it. */
33
+ export interface DiscussionRealtimeSession {
34
+ readonly mode: DiscussionRealtimeMode
35
+ stop(): void
36
+ }
37
+
38
+ /** `createRealtimeSession` of `@blogmcp/sdk/community/realtime`, or any function of its shape. */
39
+ export type DiscussionRealtime = (options: DiscussionRealtimeOptions) => DiscussionRealtimeSession
@@ -0,0 +1,363 @@
1
+ /**
2
+ * `<blogmcp-member-settings>`'s brain (m2-contracts.md §3.3, §3.4, §3.10):
3
+ *
4
+ * - the six switches of `me/settings/get`, each saved on its own (`me/settings/update` with that one switch);
5
+ * - this browser's push: shown only while `me/get`'s `module.features.push`; „Zapnout upozornění" (the member's
6
+ * click, never on load) registers the host's worker (`swPath`), asks `me/push/vapid`, subscribes the PushManager
7
+ * (`userVisibleOnly`, the VAPID key) and hands the subscription to `me/push/subscribe`; „Vypnout" ends the browser's
8
+ * subscription and `me/push/unsubscribe`s it. A subscription made for another VAPID key id (a rotation), or an
9
+ * unread `module_push_reset` notification, asks the member to switch it on again. The switch reads on only for a
10
+ * subscription this member made here (another member's on a shared browser reads off), for the current key,
11
+ * with the permission granted, that kb-api still holds: on load the page ASKS `me/push/status { endpoint }`
12
+ * (M2 T10, D-M2-10) — no worker registered, no permission asked, nothing subscribed. kb-api drops a
13
+ * subscription without telling the page (a revoked session, „Odhlásit všude", a dead endpoint): `subscribed:
14
+ * false` reads off and stays off until the member presses „Zapnout upozornění" (the subscribe flow below) — a
15
+ * sign-out everywhere never brings push back on its own. A status kb-api cannot give (unavailable, a refusal)
16
+ * reads off too, without a sentence;
17
+ * - a signed-in reader who has not joined yet sees the switches too (kb-api answers the defaults, the first save
18
+ * makes the member); this browser's push waits for the member;
19
+ * - the digest's unsubscribe link: the `#u=<token>` the e-mail's link carries is read once and cleared from the
20
+ * address bar (the SDK's readDigestTokenFromHash); it is POSTed only on the member's click — a mail scanner that
21
+ * opens the link must not unsubscribe anyone — and it needs no session.
22
+ */
23
+ import { reactive } from 'vue'
24
+ import {
25
+ isKnownModuleState,
26
+ type CommunityCallOptions,
27
+ type CommunityClient,
28
+ type CommunityFailure,
29
+ type MemberSettings,
30
+ type ModuleInfo,
31
+ } from '@blogmcp/sdk/community'
32
+ import type { TextRef } from './i18n'
33
+ import {
34
+ base64UrlBytes,
35
+ currentRegistration,
36
+ currentSubscription,
37
+ notificationPermission,
38
+ pushSupport,
39
+ registerWorker,
40
+ requestNotificationPermission,
41
+ storedPush,
42
+ storePush,
43
+ subscriptionInput,
44
+ userAgentClass,
45
+ type PushSupport,
46
+ } from './push'
47
+
48
+ /** The six switches, in the order the page shows them. */
49
+ export const SETTING_KEYS = ['notifyReply', 'notifyMention', 'notifyTopic', 'pushEnabled', 'digestEmail', 'quietHours'] as const
50
+ export type SettingKey = (typeof SETTING_KEYS)[number]
51
+
52
+ export type SettingsPhase = 'idle' | 'loading' | 'ready' | 'failed' | 'signIn'
53
+
54
+ /** This browser's push: off, on, or on its way (`busy`), and what the page tells the member about it. */
55
+ export interface PushUi {
56
+ support: PushSupport
57
+ /** `checking` until the browser's own subscription was looked at. */
58
+ status: 'checking' | 'off' | 'on' | 'busy'
59
+ /** `denied` the browser's permission; `refused` kb-api's (pushRefused); `reset` switch it on again. */
60
+ note: 'denied' | 'refused' | 'reset' | null
61
+ failure: CommunityFailure | null
62
+ }
63
+
64
+ export interface DigestUi {
65
+ /** An unsubscribe link brought the member here: the button is offered. */
66
+ offered: boolean
67
+ sending: boolean
68
+ outcome: 'unsubscribed' | 'staleLink' | null
69
+ failure: CommunityFailure | null
70
+ }
71
+
72
+ export interface SettingsUi {
73
+ phase: SettingsPhase
74
+ failure: CommunityFailure | null
75
+ module: ModuleInfo | null
76
+ settings: MemberSettings | null
77
+ saving: Partial<Record<SettingKey, boolean>>
78
+ push: PushUi
79
+ digest: DigestUi
80
+ announcement: TextRef | null
81
+ announceId: number
82
+ }
83
+
84
+ export interface MemberSettingsController {
85
+ readonly state: SettingsUi
86
+ load(): Promise<void>
87
+ toggle(key: SettingKey, value: boolean): Promise<void>
88
+ /** Whether this browser's push block is shown (the module's feature; never for a visitor). */
89
+ pushShown(): boolean
90
+ enablePush(): Promise<void>
91
+ disablePush(): Promise<void>
92
+ unsubscribeDigest(): Promise<void>
93
+ dispose(): void
94
+ }
95
+
96
+ export interface MemberSettingsOptions {
97
+ client: CommunityClient
98
+ /** The host's worker script (`<site-base>/sw.js`). */
99
+ swPath: string
100
+ /** The digest's unsubscribe token this page's address carried (read and cleared by the element), or null. */
101
+ digestToken: string | null
102
+ }
103
+
104
+ export function createMemberSettings(o: MemberSettingsOptions): MemberSettingsController {
105
+ const { client, swPath } = o
106
+ // The token is the member's capability: kept in this closure only, never in the state a view renders.
107
+ const digestToken = o.digestToken
108
+ const abort = new AbortController()
109
+ const call: CommunityCallOptions = { signal: abort.signal }
110
+ let disposed = false
111
+ /** The member viewing (me/get), or null for a reader who has not joined yet. */
112
+ let memberId: string | null = null
113
+ /** Unread module_push_reset notifications: marked read once push is on again. */
114
+ let resetNotices: string[] = []
115
+
116
+ const state = reactive<SettingsUi>({
117
+ phase: 'idle',
118
+ failure: null,
119
+ module: null,
120
+ settings: null,
121
+ saving: {},
122
+ push: { support: pushSupport(), status: 'checking', note: null, failure: null },
123
+ digest: { offered: digestToken !== null, sending: false, outcome: null, failure: null },
124
+ announcement: null,
125
+ announceId: 0,
126
+ }) as SettingsUi
127
+
128
+ const announce = (text: TextRef): void => {
129
+ state.announcement = text
130
+ state.announceId += 1
131
+ }
132
+ const failureRef = (f: CommunityFailure): TextRef => ({ key: `error.${f.code}` as TextRef['key'] })
133
+
134
+ const moduleOn = (): boolean => {
135
+ const s = state.module?.state
136
+ return s === undefined || (isKnownModuleState(s) && s !== 'dark')
137
+ }
138
+ const pushShown = (): boolean => state.phase === 'ready' && memberId !== null && state.module?.features.push === true
139
+
140
+ /** me/get: who is viewing and what the module switches on (`reader`: signed in, not a member yet). */
141
+ async function readMe(): Promise<'member' | 'reader' | 'signIn' | CommunityFailure> {
142
+ const me = await client.me.get(call)
143
+ if (!me.ok) return me.code === 'signIn' ? 'signIn' : me
144
+ state.module = me.value.module ?? null
145
+ if (me.value.needs.includes('sign_in')) return 'signIn'
146
+ memberId = me.value.member?.memberId ?? null
147
+ return memberId === null ? 'reader' : 'member'
148
+ }
149
+
150
+ /**
151
+ * Whether kb-api still holds this member's subscription of the browser's endpoint (`me/push/status`, D-M2-10): a
152
+ * read — nothing is subscribed again. Any answer but `subscribed: true` (false, a refusal, no answer) reads off.
153
+ */
154
+ async function pushHeld(sub: PushSubscription): Promise<boolean> {
155
+ const endpoint = subscriptionInput(sub)?.endpoint
156
+ if (!endpoint || memberId === null) return false
157
+ const r = await client.me.push.status({ endpoint }, call)
158
+ return r.ok && r.value.subscribed === true
159
+ }
160
+
161
+ /** The browser's own subscription, whose it is and the key it was made for, and a pending reset notice. */
162
+ async function checkPush(): Promise<void> {
163
+ if (!pushShown() || state.push.support !== 'supported') {
164
+ state.push.status = 'off'
165
+ return
166
+ }
167
+ const sub = await currentSubscription(await currentRegistration())
168
+ if (disposed) return
169
+ const notices = await client.me.notifications.list({ unreadOnly: true, codes: ['module_push_reset'] }, call)
170
+ if (disposed) return
171
+ resetNotices = notices.ok ? notices.value.items.filter((n) => n.code === 'module_push_reset' && n.readAt === null).map((n) => n.id) : []
172
+ const stored = storedPush()
173
+ // Only a subscription this member made here counts as theirs (a shared browser keeps another member's).
174
+ const mine = sub !== null && stored !== null && stored.memberId === memberId
175
+ let rotated = false
176
+ // D-M2-10: the stored keyId must be confirmed current before kb-api is asked; me/push/vapid unanswered → off, no
177
+ // sentence (as a failed status read), and nothing is asked or posted until the member presses the button.
178
+ let keyCurrent = false
179
+ if (mine) {
180
+ const vapid = await client.me.push.vapid(call)
181
+ if (disposed) return
182
+ rotated = vapid.ok && vapid.value.keyId !== stored.keyId
183
+ keyCurrent = vapid.ok && !rotated
184
+ }
185
+ const permission = notificationPermission()
186
+ let on = mine && keyCurrent && permission === 'granted'
187
+ if (on && sub) {
188
+ on = await pushHeld(sub)
189
+ if (disposed) return
190
+ }
191
+ state.push.status = on ? 'on' : 'off'
192
+ state.push.note = rotated || resetNotices.length > 0 ? 'reset' : permission === 'denied' ? 'denied' : null
193
+ }
194
+
195
+ async function load(): Promise<void> {
196
+ state.phase = 'loading'
197
+ state.failure = null
198
+ const who = await readMe()
199
+ if (disposed) return
200
+ if (who === 'signIn') {
201
+ state.phase = 'signIn'
202
+ return
203
+ }
204
+ if (who !== 'member' && who !== 'reader') {
205
+ state.phase = 'failed'
206
+ state.failure = who
207
+ return
208
+ }
209
+ if (!moduleOn()) {
210
+ // The module is dark: the settings answer MODULE_DARK (`unavailable`) — say so without asking.
211
+ state.phase = 'failed'
212
+ state.failure = { ok: false, code: 'unavailable' }
213
+ return
214
+ }
215
+ const r = await client.me.settings.get(call)
216
+ if (disposed) return
217
+ if (!r.ok) {
218
+ state.phase = r.code === 'signIn' ? 'signIn' : 'failed'
219
+ state.failure = r
220
+ return
221
+ }
222
+ state.settings = { ...r.value.settings }
223
+ state.phase = 'ready'
224
+ await checkPush()
225
+ }
226
+
227
+ async function toggle(key: SettingKey, value: boolean): Promise<void> {
228
+ const s = state.settings
229
+ if (!s || state.saving[key] || s[key] === value) return
230
+ state.saving[key] = true
231
+ const before = s[key]
232
+ state.settings = { ...s, [key]: value }
233
+ const r = await client.me.settings.update({ [key]: value }, call)
234
+ delete state.saving[key]
235
+ if (disposed) return
236
+ if (!r.ok) {
237
+ if (state.settings) state.settings = { ...state.settings, [key]: before }
238
+ return announce(failureRef(r))
239
+ }
240
+ state.settings = { ...r.value.settings }
241
+ announce({ key: 'settings.saved' })
242
+ // The master switch decides `module.features.push`: read it again, so the browser's block shows or goes.
243
+ if (key === 'pushEnabled') {
244
+ const who = await readMe()
245
+ if (disposed || (who !== 'member' && who !== 'reader')) return
246
+ await checkPush()
247
+ }
248
+ }
249
+
250
+ async function enablePush(): Promise<void> {
251
+ const p = state.push
252
+ if (!pushShown() || p.support !== 'supported' || p.status === 'busy' || memberId === null) return
253
+ const me = memberId
254
+ p.status = 'busy'
255
+ p.failure = null
256
+ p.note = null
257
+ const fail = (note: PushUi['note'], failure: CommunityFailure | null = null): void => {
258
+ p.status = 'off'
259
+ p.note = note
260
+ p.failure = failure
261
+ }
262
+ try {
263
+ if ((await requestNotificationPermission()) !== 'granted') return fail('denied')
264
+ if (disposed) return
265
+ const reg = await registerWorker(swPath)
266
+ if (disposed) return
267
+ const vapid = await client.me.push.vapid(call)
268
+ if (disposed) return
269
+ if (!vapid.ok) return fail(vapid.code === 'pushRefused' ? 'refused' : null, vapid)
270
+ const applicationServerKey = base64UrlBytes(vapid.value.publicKey)
271
+ if (!applicationServerKey) return fail(null, { ok: false, code: 'unavailable' })
272
+ // A subscription made for another key cannot be changed in place: end it first.
273
+ const old = await currentSubscription(reg)
274
+ if (old) await old.unsubscribe().catch(() => false)
275
+ const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey as BufferSource })
276
+ if (disposed) return
277
+ const input = subscriptionInput(sub)
278
+ if (!input) {
279
+ await sub.unsubscribe().catch(() => false)
280
+ return fail('refused')
281
+ }
282
+ const r = await client.me.push.subscribe({ ...input, userAgentClass: userAgentClass() }, call)
283
+ if (disposed) return
284
+ if (!r.ok) {
285
+ // kb-api keeps nothing of a refused subscription: the browser's goes too.
286
+ await sub.unsubscribe().catch(() => false)
287
+ return fail(r.code === 'pushRefused' ? 'refused' : null, r)
288
+ }
289
+ storePush({ memberId: me, keyId: r.value.keyId })
290
+ p.status = 'on'
291
+ announce({ key: 'settings.saved' })
292
+ if (resetNotices.length > 0) {
293
+ const ids = resetNotices
294
+ resetNotices = []
295
+ void client.me.notifications.markRead({ ids }, call)
296
+ }
297
+ } catch {
298
+ // The browser refused (a permission it withdrew, no push service, the worker's script missing).
299
+ if (!disposed) fail(notificationPermission() === 'denied' ? 'denied' : 'refused')
300
+ }
301
+ }
302
+
303
+ async function disablePush(): Promise<void> {
304
+ const p = state.push
305
+ if (p.status === 'busy') return
306
+ p.status = 'busy'
307
+ p.failure = null
308
+ p.note = null
309
+ let endpoint: string | null = null
310
+ try {
311
+ const sub = await currentSubscription(await currentRegistration())
312
+ endpoint = sub?.endpoint ?? null
313
+ await sub?.unsubscribe()
314
+ } catch {
315
+ // The browser's subscription could not be ended: kb-api forgets it anyway.
316
+ }
317
+ if (disposed) return
318
+ if (endpoint) {
319
+ const r = await client.me.push.unsubscribe({ endpoint }, call)
320
+ if (disposed) return
321
+ if (!r.ok) p.failure = r
322
+ }
323
+ storePush(null)
324
+ p.status = 'off'
325
+ if (!p.failure) announce({ key: 'settings.saved' })
326
+ }
327
+
328
+ async function unsubscribeDigest(): Promise<void> {
329
+ const d = state.digest
330
+ if (!digestToken || d.sending || d.outcome === 'unsubscribed') return
331
+ d.sending = true
332
+ d.failure = null
333
+ const r = await client.me.settings.unsubscribeDigest({ token: digestToken }, call)
334
+ d.sending = false
335
+ if (disposed) return
336
+ if (r.ok) {
337
+ d.outcome = 'unsubscribed'
338
+ if (state.settings) state.settings = { ...state.settings, digestEmail: false }
339
+ announce({ key: 'settings.digest.unsubscribed' })
340
+ return
341
+ }
342
+ // DIGEST_UNSUBSCRIBE_INVALID reads `invalid`: a forged, cut or stale link.
343
+ if (r.code === 'invalid') {
344
+ d.outcome = 'staleLink'
345
+ return
346
+ }
347
+ d.failure = r
348
+ }
349
+
350
+ return {
351
+ state,
352
+ load,
353
+ toggle,
354
+ pushShown,
355
+ enablePush,
356
+ disablePush,
357
+ unsubscribeDigest,
358
+ dispose() {
359
+ disposed = true
360
+ abort.abort()
361
+ },
362
+ }
363
+ }
package/src/core/text.ts CHANGED
@@ -5,10 +5,13 @@
5
5
  * `target="_blank"`. test/text-only.spec.ts bans `v-html`, `innerHTML` and any HTML parser in src/.
6
6
  */
7
7
  import { sanitizeUrl } from '@blogmcp/sanitize/url'
8
+ import { communityMentionSpans } from '@blogmcp/sdk/community'
8
9
 
9
10
  export type TextSegment =
10
11
  | { readonly kind: 'text'; readonly text: string }
11
12
  | { readonly kind: 'link'; readonly text: string; readonly href: string }
13
+ /** M2: an `@handle` with the link to the member's profile page on the host's own site (never a member's URL). */
14
+ | { readonly kind: 'mention'; readonly text: string; readonly href: string }
12
15
 
13
16
  /** The rel every link to what a member wrote carries (user-generated, no referrer, no opener). */
14
17
  export const UGC_LINK_REL = 'nofollow ugc noopener noreferrer'
@@ -63,6 +66,35 @@ export function linkify(text: string): TextSegment[] {
63
66
  return out
64
67
  }
65
68
 
69
+ /**
70
+ * A member's text as text runs, web-address links and `@handle` mentions (m2-contracts.md §3.3: „the element renders
71
+ * `@handle` as text with a link to `/u/{handle}`"). The mentions are the SDK's (kb-api's token rule), found only in the
72
+ * text between addresses — an address's `/@x` stays part of its link; `profileHref` builds the host's profile page
73
+ * (profilePath) and a handle it gives no page for stays text. Joining every segment's `text` gives the body back.
74
+ */
75
+ export function linkifyWithMentions(text: string, profileHref: ((handle: string) => string | null) | null): TextSegment[] {
76
+ const segments = linkify(text)
77
+ if (!profileHref) return segments
78
+ const out: TextSegment[] = []
79
+ for (const s of segments) {
80
+ if (s.kind !== 'text' || !s.text.includes('@')) {
81
+ out.push(s)
82
+ continue
83
+ }
84
+ for (const span of communityMentionSpans(s.text)) {
85
+ const href = span.kind === 'mention' ? profileHref(span.handle) : null
86
+ if (span.kind === 'mention' && href) out.push({ kind: 'mention', text: span.text, href })
87
+ else {
88
+ const last = out[out.length - 1]
89
+ // Text next to text stays one run (a handle without a page, between two runs).
90
+ if (last && last.kind === 'text') out[out.length - 1] = { kind: 'text', text: last.text + span.text }
91
+ else out.push({ kind: 'text', text: span.text })
92
+ }
93
+ }
94
+ }
95
+ return out
96
+ }
97
+
66
98
  /** Characters in code points, as kb-api counts a body (after NFC and trimming). */
67
99
  export function codePointLength(text: string): number {
68
100
  let n = 0