Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/sdk/src/presence-stack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
39 changes: 38 additions & 1 deletion packages/sdk/src/presence-stack/presence-awareness.test.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}) },
Expand Down Expand Up @@ -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<number, unknown>([[clientId, { [PRESENCE_STATE_KEY]: published }]]);
expect(awarenessToPeers(states, 999)).toEqual([published]);
});
});
30 changes: 30 additions & 0 deletions packages/sdk/src/presence-stack/presence-awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, key: string): string | null {
const v = o[key];
return typeof v === "string" && v.length > 0 ? v : null;
Expand Down
Loading