From 6aaa9f7726d96b90862f6afda7ae9a3d627f97a9 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Thu, 9 Jul 2026 00:51:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(presence):=20buildLocalPresence=20?= =?UTF-8?q?=E2=80=94=20the=20publish=20side=20of=20the=20payload=20contrac?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the presence payload contract, both directions, as pure functions. `buildLocalPresence(self, clientId)` builds THIS device's payload to publish into awareness (`setLocalStateField(PRESENCE_STATE_KEY, …)`): id = sovereign pubkey (stable across tabs); name prefers displayName, falls back to the key fingerprint (mirrors useSelfDisplayName — never blank/"Anonymous"); color via peerColor(clientId). awarenessToPeers reads the same shape back. +4 tests incl. a publish→read round-trip (buildLocalPresence → awarenessToPeers yields the same peer) — so publish/read symmetry is pinned. The remaining presence rung is pure plumbing: call buildLocalPresence on open, mount in the header, and activate the dormant 10.6 broadcaster over the relay. Co-Authored-By: Claude Fable 5 --- packages/sdk/src/presence-stack/index.ts | 8 +++- .../presence-stack/presence-awareness.test.ts | 39 ++++++++++++++++++- .../src/presence-stack/presence-awareness.ts | 30 ++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/presence-stack/index.ts b/packages/sdk/src/presence-stack/index.ts index 6e840d67..51971369 100644 --- a/packages/sdk/src/presence-stack/index.ts +++ b/packages/sdk/src/presence-stack/index.ts @@ -6,4 +6,10 @@ export { capPresence, presenceInitials, } from "./presence-stack"; -export { PRESENCE_STATE_KEY, awarenessToPeers, peerFromState } from "./presence-awareness"; +export { + type PresenceSelf, + PRESENCE_STATE_KEY, + awarenessToPeers, + buildLocalPresence, + peerFromState, +} from "./presence-awareness"; diff --git a/packages/sdk/src/presence-stack/presence-awareness.test.ts b/packages/sdk/src/presence-stack/presence-awareness.test.ts index 493511f7..9ed2f740 100644 --- a/packages/sdk/src/presence-stack/presence-awareness.test.ts +++ b/packages/sdk/src/presence-stack/presence-awareness.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { PRESENCE_STATE_KEY, awarenessToPeers, peerFromState } from "./presence-awareness"; +import { peerColor } from "../peer-presence"; +import { + PRESENCE_STATE_KEY, + awarenessToPeers, + buildLocalPresence, + peerFromState, +} from "./presence-awareness"; const payload = (id: string, name: string, color = "#2f6df6", avatarRef?: string) => ({ [PRESENCE_STATE_KEY]: { id, name, color, ...(avatarRef ? { avatarRef } : {}) }, @@ -69,3 +75,34 @@ describe("awarenessToPeers", () => { expect(awarenessToPeers(new Map([[1, payload("self", "Me")]]), 1)).toEqual([]); }); }); + +describe("buildLocalPresence (publish side)", () => { + const self = { pubkey: "u1", displayName: "Mira", fingerprint: "ab12·cd34" }; + + it("builds a payload with pubkey id, display name, and a clientId-keyed color", () => { + const p = buildLocalPresence(self, 42); + expect(p.id).toBe("u1"); + expect(p.name).toBe("Mira"); + expect(p.color).toBe(peerColor(42)); + expect(p.avatarRef).toBeUndefined(); + }); + + it("falls back to the fingerprint when the display name is blank (never Anonymous)", () => { + expect(buildLocalPresence({ ...self, displayName: " " }, 1).name).toBe("ab12·cd34"); + }); + + it("passes avatarRef through when present", () => { + expect(buildLocalPresence({ ...self, avatarRef: "brainstorm://asset/a" }, 1).avatarRef).toBe( + "brainstorm://asset/a", + ); + }); + + it("round-trips: a published payload reads back as the same peer", () => { + const clientId = 7; + const published = buildLocalPresence({ ...self, avatarRef: "brainstorm://asset/z" }, clientId); + // A remote peer sees our state under the presence key; awarenessToPeers on + // THEIR side (self = their own clientId, not ours) surfaces us unchanged. + const states = new Map([[clientId, { [PRESENCE_STATE_KEY]: published }]]); + expect(awarenessToPeers(states, 999)).toEqual([published]); + }); +}); diff --git a/packages/sdk/src/presence-stack/presence-awareness.ts b/packages/sdk/src/presence-stack/presence-awareness.ts index 480a5948..f3717966 100644 --- a/packages/sdk/src/presence-stack/presence-awareness.ts +++ b/packages/sdk/src/presence-stack/presence-awareness.ts @@ -20,11 +20,41 @@ * the SDK module needn't import `@brainstorm/react-yjs`. */ +import { peerColor } from "../peer-presence"; import type { PresencePeer } from "./presence-stack"; /** The awareness field a client publishes its stack presence under. */ export const PRESENCE_STATE_KEY = "presence"; +/** This device's identity for the presence payload — the roster self profile + * fields the builder needs. `displayName` may be blank (unset); the builder + * falls back to `fingerprint` so a peer never renders as "" / "Anonymous". */ +export type PresenceSelf = { + /** Sovereign member pubkey (base64) — the stable `PresencePeer.id`. */ + pubkey: string; + displayName: string; + fingerprint: string; + avatarRef?: string; +}; + +/** + * Build THIS device's presence payload — the PUBLISH side of the contract. + * The wiring rung does `awareness.setLocalStateField(PRESENCE_STATE_KEY, + * buildLocalPresence(self, doc.clientID))`; `awarenessToPeers` reads the same + * shape back on peers. `id` is the sovereign pubkey (stable across a member's + * tabs); `name` prefers the display name, falling back to the key fingerprint + * (mirrors `useSelfDisplayName` — never blank); `color` is keyed by the Yjs + * client id via `peerColor` (so a member's two tabs get distinct caret hues, + * while `capPresence` still collapses them to one avatar by `id`). + */ +export function buildLocalPresence(self: PresenceSelf, clientId: number): PresencePeer { + const name = self.displayName.trim() || self.fingerprint; + const color = peerColor(clientId); + return self.avatarRef + ? { id: self.pubkey, name, color, avatarRef: self.avatarRef } + : { id: self.pubkey, name, color }; +} + function readString(o: Record, key: string): string | null { const v = o[key]; return typeof v === "string" && v.length > 0 ? v : null;