@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.
Files changed (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -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 +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. 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
+ }