@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-badge>`'s brain (m2-contracts.md §3.3, §3.10): the unread count of the member's notifications for a
|
|
3
|
+
* site's header. `me/get` once (who is viewing, the module's state); then `me/notifications/unread-count` at once
|
|
4
|
+
* when the page is visible, every `intervalMs` (60 s, never less) while it is visible, and again when it is shown again (`visibilitychange`). A
|
|
5
|
+
* visitor, a reader who never joined and a module that is `dark` get nothing: no count, no further request.
|
|
6
|
+
*/
|
|
7
|
+
import { reactive } from 'vue'
|
|
8
|
+
import { isKnownModuleState, type CommunityCallOptions, type CommunityClient } from '@blogmcp/sdk/community'
|
|
9
|
+
|
|
10
|
+
/** The badge's poll (the contract's 60 s). */
|
|
11
|
+
export const BADGE_INTERVAL_MS = 60_000
|
|
12
|
+
/** A count above it reads `99+`. */
|
|
13
|
+
export const BADGE_MAX = 99
|
|
14
|
+
|
|
15
|
+
export interface BadgeUi {
|
|
16
|
+
/** `hidden`: nothing to show (a visitor, no member, a dark module, or not decided yet). */
|
|
17
|
+
phase: 'pending' | 'hidden' | 'shown'
|
|
18
|
+
count: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BadgeController {
|
|
22
|
+
readonly state: BadgeUi
|
|
23
|
+
start(): Promise<void>
|
|
24
|
+
/** Read the count now (coalesced with one on its way). */
|
|
25
|
+
refresh(): Promise<void>
|
|
26
|
+
dispose(): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BadgeOptions {
|
|
30
|
+
client: CommunityClient
|
|
31
|
+
/**
|
|
32
|
+
* Default 60 s, and never more often (a smaller value above 0 reads as 60 s: the contract's rate per open tab);
|
|
33
|
+
* 0 or less = no poll (the count is read once and on visibility).
|
|
34
|
+
*/
|
|
35
|
+
intervalMs?: number
|
|
36
|
+
/** Whether the page is visible now (default: document.visibilityState). */
|
|
37
|
+
visible?: () => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The count as the badge shows it. */
|
|
41
|
+
export const badgeText = (count: number): string => (count > BADGE_MAX ? `${BADGE_MAX}+` : String(count))
|
|
42
|
+
|
|
43
|
+
export function createBadge(o: BadgeOptions): BadgeController {
|
|
44
|
+
const { client } = o
|
|
45
|
+
const asked = typeof o.intervalMs === 'number' && Number.isFinite(o.intervalMs) ? o.intervalMs : BADGE_INTERVAL_MS
|
|
46
|
+
const interval = asked > 0 ? Math.max(asked, BADGE_INTERVAL_MS) : 0
|
|
47
|
+
// `!== 'hidden'` as the discussion's head poll reads it: a browser without the API (or a prerendered page) counts.
|
|
48
|
+
const visible = o.visible ?? ((): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
|
49
|
+
const abort = new AbortController()
|
|
50
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
51
|
+
const state = reactive<BadgeUi>({ phase: 'pending', count: 0 }) as BadgeUi
|
|
52
|
+
let disposed = false
|
|
53
|
+
let active = false
|
|
54
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
55
|
+
let reading: Promise<void> | null = null
|
|
56
|
+
|
|
57
|
+
function stop(): void {
|
|
58
|
+
active = false
|
|
59
|
+
if (timer) clearInterval(timer)
|
|
60
|
+
timer = null
|
|
61
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hide(): void {
|
|
65
|
+
state.phase = 'hidden'
|
|
66
|
+
state.count = 0
|
|
67
|
+
stop()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function refresh(): Promise<void> {
|
|
71
|
+
if (disposed || !active) return Promise.resolve()
|
|
72
|
+
reading ??= (async () => {
|
|
73
|
+
const r = await client.me.notifications.unreadCount(call)
|
|
74
|
+
if (disposed || !active) return
|
|
75
|
+
if (r.ok) {
|
|
76
|
+
state.count = Math.max(0, Math.floor(r.value.count))
|
|
77
|
+
state.phase = 'shown'
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
// The session ended (signed out elsewhere): nothing to count any more.
|
|
81
|
+
if (r.code === 'signIn') hide()
|
|
82
|
+
// Any other failure keeps what the badge shows; the next tick tries again.
|
|
83
|
+
})().finally(() => {
|
|
84
|
+
reading = null
|
|
85
|
+
})
|
|
86
|
+
return reading
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onVisibility(): void {
|
|
90
|
+
if (visible()) void refresh()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
state,
|
|
95
|
+
async start() {
|
|
96
|
+
const me = await client.me.get(call)
|
|
97
|
+
if (disposed) return
|
|
98
|
+
const moduleState = me.ok ? me.value.module?.state : undefined
|
|
99
|
+
// An unknown state reads as dark (the SDK's open enum); a kb-api older than M2 sends none: the badge counts.
|
|
100
|
+
const dark = moduleState !== undefined && (!isKnownModuleState(moduleState) || moduleState === 'dark')
|
|
101
|
+
if (!me.ok || me.value.needs.includes('sign_in') || !me.value.member || dark) return hide()
|
|
102
|
+
active = true
|
|
103
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
|
|
104
|
+
if (interval > 0) {
|
|
105
|
+
timer = setInterval(() => {
|
|
106
|
+
if (visible()) void refresh()
|
|
107
|
+
}, interval)
|
|
108
|
+
}
|
|
109
|
+
// A tab opened in the background asks nothing until it is shown (the visibilitychange listener reads it then).
|
|
110
|
+
if (visible()) await refresh()
|
|
111
|
+
},
|
|
112
|
+
refresh,
|
|
113
|
+
dispose() {
|
|
114
|
+
if (disposed) return
|
|
115
|
+
disposed = true
|
|
116
|
+
stop()
|
|
117
|
+
abort.abort()
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-notification-centre>`'s brain (m2-contracts.md §3.3, §3.10): the member's notifications newest first —
|
|
3
|
+
* `me/notifications/list` with its cursor and the unread-only filter —, one marked read at a time or all at once
|
|
4
|
+
* (`mark-read { all: true }`). Codes only, never anything a member wrote: each row is the catalogue's sentence of
|
|
5
|
+
* its code, and its subject a link the host's routes build (a decision → its statement).
|
|
6
|
+
*/
|
|
7
|
+
import { reactive } from 'vue'
|
|
8
|
+
import {
|
|
9
|
+
isKnownNotificationCode,
|
|
10
|
+
type CommunityCallOptions,
|
|
11
|
+
type CommunityClient,
|
|
12
|
+
type CommunityFailure,
|
|
13
|
+
type NotificationItem,
|
|
14
|
+
} from '@blogmcp/sdk/community'
|
|
15
|
+
import type { TextRef } from './i18n'
|
|
16
|
+
|
|
17
|
+
export type CentrePhase = 'idle' | 'loading' | 'ready' | 'failed' | 'signIn'
|
|
18
|
+
|
|
19
|
+
export interface CentreUi {
|
|
20
|
+
phase: CentrePhase
|
|
21
|
+
failure: CommunityFailure | null
|
|
22
|
+
/** Known codes only (a code a newer kb-api sends is skipped, never shown raw). */
|
|
23
|
+
items: NotificationItem[]
|
|
24
|
+
nextCursor: string | null
|
|
25
|
+
unreadOnly: boolean
|
|
26
|
+
loadingMore: boolean
|
|
27
|
+
busy: Record<string, boolean>
|
|
28
|
+
announcement: TextRef | null
|
|
29
|
+
announceId: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NotificationCentreController {
|
|
33
|
+
readonly state: CentreUi
|
|
34
|
+
load(): Promise<void>
|
|
35
|
+
loadMore(): Promise<void>
|
|
36
|
+
setUnreadOnly(on: boolean): Promise<void>
|
|
37
|
+
markRead(id: string): Promise<void>
|
|
38
|
+
markAllRead(): Promise<void>
|
|
39
|
+
unread(): number
|
|
40
|
+
dispose(): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createNotificationCentre(o: { client: CommunityClient }): NotificationCentreController {
|
|
44
|
+
const { client } = o
|
|
45
|
+
const abort = new AbortController()
|
|
46
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
47
|
+
let disposed = false
|
|
48
|
+
/** The list that is current: an older one still on its way (the filter changed meanwhile) is dropped. */
|
|
49
|
+
let ticket = 0
|
|
50
|
+
const state = reactive<CentreUi>({
|
|
51
|
+
phase: 'idle',
|
|
52
|
+
failure: null,
|
|
53
|
+
items: [],
|
|
54
|
+
nextCursor: null,
|
|
55
|
+
unreadOnly: false,
|
|
56
|
+
loadingMore: false,
|
|
57
|
+
busy: {},
|
|
58
|
+
announcement: null,
|
|
59
|
+
announceId: 0,
|
|
60
|
+
}) as CentreUi
|
|
61
|
+
|
|
62
|
+
const announce = (text: TextRef): void => {
|
|
63
|
+
state.announcement = text
|
|
64
|
+
state.announceId += 1
|
|
65
|
+
}
|
|
66
|
+
const failureRef = (f: CommunityFailure): TextRef => ({ key: `error.${f.code}` as TextRef['key'] })
|
|
67
|
+
const known = (items: readonly NotificationItem[]): NotificationItem[] => items.filter((n) => isKnownNotificationCode(n.code))
|
|
68
|
+
|
|
69
|
+
async function load(): Promise<void> {
|
|
70
|
+
const mine = ++ticket
|
|
71
|
+
state.phase = 'loading'
|
|
72
|
+
state.failure = null
|
|
73
|
+
const r = await client.me.notifications.list(state.unreadOnly ? { unreadOnly: true } : {}, call)
|
|
74
|
+
if (disposed || mine !== ticket) return
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
state.phase = r.code === 'signIn' ? 'signIn' : 'failed'
|
|
77
|
+
state.failure = r
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
state.items = known(r.value.items)
|
|
81
|
+
state.nextCursor = r.value.nextCursor
|
|
82
|
+
state.phase = 'ready'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
state,
|
|
87
|
+
load,
|
|
88
|
+
async loadMore() {
|
|
89
|
+
if (state.loadingMore || state.nextCursor === null || state.phase !== 'ready') return
|
|
90
|
+
const mine = ticket
|
|
91
|
+
state.loadingMore = true
|
|
92
|
+
const r = await client.me.notifications.list({ cursor: state.nextCursor, ...(state.unreadOnly ? { unreadOnly: true } : {}) }, call)
|
|
93
|
+
state.loadingMore = false
|
|
94
|
+
if (disposed || mine !== ticket) return
|
|
95
|
+
if (!r.ok) return announce(failureRef(r))
|
|
96
|
+
const seen = new Set(state.items.map((n) => n.id))
|
|
97
|
+
state.items = [...state.items, ...known(r.value.items).filter((n) => !seen.has(n.id))]
|
|
98
|
+
state.nextCursor = r.value.nextCursor
|
|
99
|
+
},
|
|
100
|
+
async setUnreadOnly(on) {
|
|
101
|
+
if (state.unreadOnly === on) return
|
|
102
|
+
state.unreadOnly = on
|
|
103
|
+
await load()
|
|
104
|
+
},
|
|
105
|
+
async markRead(id) {
|
|
106
|
+
const key = `read:${id}`
|
|
107
|
+
const item = state.items.find((n) => n.id === id)
|
|
108
|
+
if (!item || item.readAt !== null || state.busy[key]) return
|
|
109
|
+
state.busy[key] = true
|
|
110
|
+
const r = await client.me.notifications.markRead({ ids: [id] }, call)
|
|
111
|
+
delete state.busy[key]
|
|
112
|
+
if (disposed) return
|
|
113
|
+
if (!r.ok) return announce(failureRef(r))
|
|
114
|
+
const at = new Date().toISOString()
|
|
115
|
+
state.items = state.items.map((n) => (n.id === id ? { ...n, readAt: at } : n))
|
|
116
|
+
},
|
|
117
|
+
async markAllRead() {
|
|
118
|
+
// Nothing unread among the loaded rows and no older page: nothing to mark (older pages may hold unread rows).
|
|
119
|
+
if (state.busy.all || (!state.items.some((n) => n.readAt === null) && state.nextCursor === null)) return
|
|
120
|
+
state.busy.all = true
|
|
121
|
+
const r = await client.me.notifications.markRead({ all: true }, call)
|
|
122
|
+
delete state.busy.all
|
|
123
|
+
if (disposed) return
|
|
124
|
+
if (!r.ok) return announce(failureRef(r))
|
|
125
|
+
const at = new Date().toISOString()
|
|
126
|
+
// Under the unread-only filter nothing is left to show; otherwise every row reads as read.
|
|
127
|
+
state.items = state.unreadOnly ? [] : state.items.map((n) => (n.readAt === null ? { ...n, readAt: at } : n))
|
|
128
|
+
if (state.unreadOnly) state.nextCursor = null
|
|
129
|
+
},
|
|
130
|
+
unread: () => state.items.filter((n) => n.readAt === null).length,
|
|
131
|
+
dispose() {
|
|
132
|
+
disposed = true
|
|
133
|
+
abort.abort()
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The digest's unsubscribe token as `<blogmcp-member-settings>` takes it (m2-contracts.md §3.3, §9.4): from its OWN
|
|
3
|
+
* page's address only — `#u=` in `location.hash` when it mounts —, and the address bar cleared at once (the SDK's
|
|
4
|
+
* readDigestTokenFromHash, which also clears the router's copy in `history.state`). The token is never sent on load:
|
|
5
|
+
* the element POSTs it on the member's click only (a mail scanner that opens the link must not unsubscribe anyone).
|
|
6
|
+
*
|
|
7
|
+
* As with a notifier's receipt (receipt.ts), the element keeps what it took for itself; a later page of the same tab
|
|
8
|
+
* never inherits it.
|
|
9
|
+
*/
|
|
10
|
+
import { digestTokenFromHash, forgetDigestToken, readDigestTokenFromHash } from '@blogmcp/sdk/community'
|
|
11
|
+
|
|
12
|
+
const taken = new WeakMap<object, string | null>()
|
|
13
|
+
|
|
14
|
+
/** The token this element's page carries, or null (none, or a cut link). */
|
|
15
|
+
export function digestTokenForElement(owner: object | null): string | null {
|
|
16
|
+
if (typeof location !== 'undefined' && location.hash.startsWith('#u=')) {
|
|
17
|
+
// This address's own token (a cut link is none, never an older one the window remembers); then the bar is cleared.
|
|
18
|
+
const token = digestTokenFromHash(location.hash)
|
|
19
|
+
readDigestTokenFromHash()
|
|
20
|
+
// The element keeps its own copy (below); the SDK's per-window memory is dropped at once, so no later mount offers it.
|
|
21
|
+
forgetDigestToken()
|
|
22
|
+
if (owner) taken.set(owner, token)
|
|
23
|
+
return token
|
|
24
|
+
}
|
|
25
|
+
return owner && taken.has(owner) ? (taken.get(owner) ?? null) : null
|
|
26
|
+
}
|