@blogmcp/community 0.1.0 → 0.3.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/README.md +162 -13
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/mount--2_V2q3M.js +10063 -0
- package/dist/core/attrs.d.ts +42 -1
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +120 -15
- package/dist/core/follow.d.ts +26 -0
- package/dist/core/format.d.ts +7 -0
- package/dist/core/forum-category.d.ts +85 -0
- package/dist/core/forum-search.d.ts +35 -0
- package/dist/core/forum-state.d.ts +30 -0
- package/dist/core/forum-words.d.ts +8 -0
- package/dist/core/forum.d.ts +21 -0
- package/dist/core/moderation.d.ts +80 -0
- package/dist/core/onboarding.d.ts +49 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- package/dist/core/profile.d.ts +45 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +46 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +47 -1
- package/dist/global.d.ts +8 -4
- package/dist/index.d.ts +20 -3
- package/dist/index.js +62 -12
- package/dist/mount.d.ts +89 -0
- package/dist/react.d.ts +27 -3
- package/dist/react.js +48 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- package/dist/ui/FollowButtonView.vue.d.ts +17 -0
- package/dist/ui/FollowToggle.vue.d.ts +6 -0
- package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
- package/dist/ui/ForumModerationView.vue.d.ts +12 -0
- package/dist/ui/ForumNotices.vue.d.ts +7 -0
- package/dist/ui/ForumSearchView.vue.d.ts +11 -0
- package/dist/ui/ForumView.vue.d.ts +12 -0
- package/dist/ui/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +8 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +273 -3
- package/dist/vue.js +235 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +61 -2
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +497 -46
- package/src/core/follow.ts +139 -0
- package/src/core/format.ts +22 -0
- package/src/core/forum-category.ts +323 -0
- package/src/core/forum-search.ts +153 -0
- package/src/core/forum-state.ts +59 -0
- package/src/core/forum-words.ts +38 -0
- package/src/core/forum.ts +83 -0
- package/src/core/moderation.ts +414 -0
- package/src/core/onboarding.ts +177 -18
- package/src/core/outcomes.ts +23 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +155 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +52 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/core/topic.ts +234 -0
- package/src/element.ts +124 -6
- package/src/global.ts +40 -4
- package/src/index.ts +84 -2
- package/src/mount.ts +250 -1
- package/src/react.ts +90 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- package/src/ui/FollowButtonView.vue +85 -0
- package/src/ui/FollowToggle.vue +39 -0
- package/src/ui/ForumCategoryView.vue +243 -0
- package/src/ui/ForumModerationView.vue +309 -0
- package/src/ui/ForumNotices.vue +23 -0
- package/src/ui/ForumSearchView.vue +171 -0
- package/src/ui/ForumView.vue +128 -0
- package/src/ui/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +64 -11
- package/src/ui/MessageItem.vue +36 -7
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +12 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +193 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +102 -0
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +30 -0
- package/src/vue.ts +271 -3
- package/dist/chunks/mount-DzjwnHyx.js +0 -4597
package/src/core/push.ts
ADDED
|
@@ -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,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The discussion's live updates (m2-contracts.md §3.5, §3.10): `<blogmcp-discussion>` (and M3 `<blogmcp-topic>`) takes a
|
|
3
|
+
* `realtime` PROPERTY —
|
|
4
|
+
* the session factory of `@blogmcp/sdk/community/realtime`, which the HOST imports and passes:
|
|
5
|
+
*
|
|
6
|
+
* import { createRealtimeSession } from '@blogmcp/sdk/community/realtime'
|
|
7
|
+
* mountDiscussion({ articleId, realtime: createRealtimeSession }) // or el.realtime = createRealtimeSession
|
|
8
|
+
*
|
|
9
|
+
* The element calls it with its own client and discussion store after the first `discussions/list`, and `stop()`s
|
|
10
|
+
* the session when it unmounts. The element never imports the realtime entry (nor `centrifuge`): a site that passes no
|
|
11
|
+
* factory ships no socket code, and the discussion polls (`pollDiscussionHead`) as before.
|
|
12
|
+
*
|
|
13
|
+
* The types here are the factory's shape as far as the element uses it, so `createRealtimeSession` fits as it is.
|
|
14
|
+
*/
|
|
15
|
+
import type { CommunityClient, DiscussionStore, TopicStore } from '@blogmcp/sdk/community'
|
|
16
|
+
|
|
17
|
+
/** `connecting`, `live`, `polling` (live updates are off or the server cut the socket for good), `stopped`. */
|
|
18
|
+
export type DiscussionRealtimeMode = 'connecting' | 'live' | 'polling' | 'stopped' | (string & {})
|
|
19
|
+
|
|
20
|
+
/** What the element calls the factory with. */
|
|
21
|
+
export interface DiscussionRealtimeOptions {
|
|
22
|
+
client: CommunityClient
|
|
23
|
+
store: DiscussionStore
|
|
24
|
+
/** The discussion's language, for the session's re-reads. */
|
|
25
|
+
languageCode: string
|
|
26
|
+
/** Whether the discussion is seen now (the fallback polls only then). */
|
|
27
|
+
visible: () => boolean
|
|
28
|
+
/** The element's poll interval, for the session's fallback poller (absent: the SDK's default). */
|
|
29
|
+
pollIntervalMs?: number
|
|
30
|
+
onMode: (mode: DiscussionRealtimeMode) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A running session, as far as the element uses it. */
|
|
34
|
+
export interface DiscussionRealtimeSession {
|
|
35
|
+
readonly mode: DiscussionRealtimeMode
|
|
36
|
+
stop(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `createRealtimeSession` of `@blogmcp/sdk/community/realtime`, or any function of its shape. */
|
|
40
|
+
export type DiscussionRealtime = (options: DiscussionRealtimeOptions) => DiscussionRealtimeSession
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* M3 (m3-contracts.md §3.5, §5.3): what `<blogmcp-topic>` calls the same factory with — the topic's store
|
|
44
|
+
* (`createTopicStore`) in place of a discussion's; `createRealtimeSession` takes both, re-reads a topic with
|
|
45
|
+
* `forum/topics/get` and falls back to `forum/topics/head`.
|
|
46
|
+
*/
|
|
47
|
+
export interface TopicRealtimeOptions extends Omit<DiscussionRealtimeOptions, 'store'> {
|
|
48
|
+
store: TopicStore
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `createRealtimeSession`, as `<blogmcp-topic>`'s `realtime` property takes it. */
|
|
52
|
+
export type TopicRealtime = (options: TopicRealtimeOptions) => 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
|