From e61f561e81337504fedabb4ea2a373e7fa7aa23c Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 12 Jun 2026 09:00:26 -0800 Subject: [PATCH] microcosm Phase 5: Spacedust live interactions hook - Type the Spacedust link-event shape (verified live) + eventActorDid helper - Add useLiveInteractions(): websocket subscription to like/repost/reply/follow events targeting the current user's DID; live count + recent, self filtered - Verified one wantedSubjectDids= sub catches both follows and post-likes --- MICROCOSM_PLAN.md | 10 ++- src/lib/microcosm/spacedust.ts | 29 +++++- src/state/queries/microcosm/spacedust.ts | 107 +++++++++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/state/queries/microcosm/spacedust.ts diff --git a/MICROCOSM_PLAN.md b/MICROCOSM_PLAN.md index 6e60245..97cbd16 100644 --- a/MICROCOSM_PLAN.md +++ b/MICROCOSM_PLAN.md @@ -164,7 +164,15 @@ Original Phase 3 scope notes: integrate a separate search index later. ### Phase 5 — live notifications via Spacedust -- Replace notification polling with a Spacedust subscription on the user's DID. +**Status: live-interactions hook DONE (additive); feed replacement is follow-up.** +- ✅ `useLiveInteractions()` subscribes over websocket to Spacedust for + like/repost/reply/follow events targeting the current user's DID; exposes a + live count + recent interactions. Verified: event shape + `{kind,link:{operation,source,source_record,subject}}` and that one + `wantedSubjectDids=` subscription catches both follows (subject=DID) + and likes/etc (subject=at://DID/post/...). Self-interactions filtered out. +- Follow-up: wire into a live unread badge; optionally replace the poll-based + `listNotifications` feed entirely (large — it does grouping + hydration). ### Phase 6 — writes & auth (mostly already independent) - `com.atproto.server.*` and `com.atproto.repo.put/applyWrites` already target diff --git a/src/lib/microcosm/spacedust.ts b/src/lib/microcosm/spacedust.ts index 5b96299..002796c 100644 --- a/src/lib/microcosm/spacedust.ts +++ b/src/lib/microcosm/spacedust.ts @@ -22,8 +22,33 @@ export type SpacedustSubscription = { instant?: boolean } -/** A raw link event from the firehose. Shape is firehose-defined; passed through. */ -export type SpacedustEvent = Record +/** + * A link event from the firehose. Verified shape from the live instance: + * + * {kind:'link', origin:'live', link:{operation:'create'|'delete', + * source:'app.bsky.feed.like:subject.uri', + * source_record:'at:////', + * source_rev:'...', subject:'at:///...' }} + */ +export type SpacedustEvent = { + kind?: string + origin?: string + link?: { + operation?: 'create' | 'delete' + source?: string + source_record?: string + source_rev?: string + subject?: string + } + [k: string]: unknown +} + +/** Pull the actor DID out of a link event's `source_record` at-uri. */ +export function eventActorDid(e: SpacedustEvent): string | undefined { + const uri = e.link?.source_record + const m = uri?.match(/^at:\/\/([^/]+)\//) + return m?.[1] +} export type SpacedustHandlers = { onEvent: (event: SpacedustEvent) => void diff --git a/src/state/queries/microcosm/spacedust.ts b/src/state/queries/microcosm/spacedust.ts new file mode 100644 index 0000000..e4e899b --- /dev/null +++ b/src/state/queries/microcosm/spacedust.ts @@ -0,0 +1,107 @@ +/** + * Live interaction notifications via Spacedust (microcosm's interactions + * firehose). Subscribes over a websocket to like/repost/reply/follow events + * targeting the current user's DID and surfaces them in real time — instant, + * unlike the AppView's poll-based unread count. + * + * This is additive: it does not replace the notification feed, just provides a + * live signal that new interactions have arrived. + */ +import {useEffect, useRef, useState} from 'react' + +import {constellation} from '#/lib/microcosm' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import { + eventActorDid, + type SpacedustEvent, + subscribe, +} from '#/lib/microcosm/spacedust' +import {logger} from '#/logger' +import {useSession} from '#/state/session' + +/** A live interaction event, normalized for UI consumption. */ +export type LiveInteraction = { + /** like | repost | reply | follow */ + type: 'like' | 'repost' | 'reply' | 'follow' | 'other' + /** DID of the actor who performed the interaction. */ + actorDid?: string + /** at-uri of the targeted record (post/profile). */ + subject?: string + /** at-uri of the interaction record itself. */ + recordUri?: string +} + +const SOURCE_TO_TYPE: Record = { + [constellation.Sources.likes]: 'like', + [constellation.Sources.reposts]: 'repost', + [constellation.Sources.replies]: 'reply', + [constellation.Sources.followers]: 'follow', +} + +function normalize(e: SpacedustEvent): LiveInteraction | undefined { + if (e.link?.operation !== 'create') return undefined + const source = e.link?.source + if (!source) return undefined + return { + type: SOURCE_TO_TYPE[source] ?? 'other', + actorDid: eventActorDid(e), + subject: e.link?.subject, + recordUri: e.link?.source_record, + } +} + +/** + * Subscribe to live interactions targeting the current user. Returns the count + * of interactions received since mount, the most recent ones, and a reset. + */ +export function useLiveInteractions({enabled = true}: {enabled?: boolean} = {}) { + const {currentAccount} = useSession() + const did = currentAccount?.did + const [count, setCount] = useState(0) + const [recent, setRecent] = useState([]) + const subRef = useRef<{close: () => void} | null>(null) + + useEffect(() => { + if (!MICROCOSM_ENABLED || !enabled || !did) return + + const handle = subscribe( + { + wantedSources: [ + constellation.Sources.likes, + constellation.Sources.reposts, + constellation.Sources.replies, + constellation.Sources.followers, + ], + wantedSubjectDids: [did], + }, + { + onEvent(e) { + const interaction = normalize(e) + if (!interaction) return + // Ignore self-interactions. + if (interaction.actorDid === did) return + setCount(c => c + 1) + setRecent(prev => [interaction, ...prev].slice(0, 50)) + }, + onError() { + logger.debug('spacedust subscription error') + }, + }, + ) + subRef.current = handle + + return () => { + handle.close() + subRef.current = null + } + }, [did, enabled]) + + return { + count, + recent, + reset: () => { + setCount(0) + setRecent([]) + }, + } +} -- 2.51.2