import { type Cell, computed, Default, equals, handler, NAME, pattern, type PerSpace, type PerUser, safeDateNow, Stream, UI, type VNode, wish, Writable, } from "commonfabric"; /** * Shared-profile participant roster (CT-1649). * * Reference pattern for chat rooms / multiplayer lobbies. There is no runtime * primitive for "list every user's profile in this space", so instead each user * contributes their OWN entry on join, sourced from their shared profile * (`wish({ query: "#profile" })`). See `docs/specs/shared-profile-rosters.md`. * * This pattern demonstrates the **snapshot** (self-containment) variant: name + * avatar are copied at join time, so the roster is fully self-describing inside * the shared space and never depends on other users' profile spaces being * reachable. Participants render from those snapshot strings via ``, * and the current viewer's OWN live profile is shown with the trusted * ``. * * The recommended default is now the **live-link** variant — every participant * rendered with a live, visitable `` * bound to their real profile cell (cross-space reads resolve for any authorized * viewer, CT-1667/1687). See `profile-roster-live-demo.tsx`. Prefer snapshotting * (this file) only when the roster must stay legible with remote profile spaces * offline, or when you deliberately want no live cross-space dependency. */ /** * Stable identity for a participant: the contributor's own `#profile` cell. * Display name is mutable and not unique (two different people can both be * "Alex"), so it must NOT be used as the identity key. The profile cell is the * one stable handle we have — it's a distinct entity per user (each lives in * that user's own profile space) and is compared with `equals()`, the pattern * idiom for cell identity. Only the renderable fields are surfaced; identity * comparison never depends on the value shape. */ export type ParticipantProfileCell = Cell<{ name?: string; avatar?: string }>; /** One participant's contribution to the shared roster (a profile snapshot). */ export interface Participant { /** * Link to the contributor's profile cell — the stable identity key. Two * participants are "the same" iff `equals()` matches on this, never on name. */ profile: ParticipantProfileCell; /** Display name, snapshotted from the joiner's profile at join time. */ name: string; /** Avatar URL or glyph, snapshotted from the joiner's profile (may be ""). */ avatar: string; joinedAt: number; } export interface Roster { participants: Participant[] | Default<[]>; } /** Per-user marker so a viewer only joins once and can see "joined" state. */ export interface ViewerState { /** Set once this viewer has contributed their entry to the shared roster. */ joined?: boolean; /** The display name shown on the join button after joining (cosmetic only). */ joinedName?: string; } const DEFAULT_ROSTER: Roster = { participants: [] }; const EMPTY_VIEWER: ViewerState = {}; type RosterCell = Writable>; type ViewerCell = Writable>; export type JoinEvent = Record; // The current viewer's name/avatar (plain strings) and live profile cell are // resolved from their shared profile in the pattern body (via wish). The handler // appends a snapshot to the shared roster and records that this viewer has // joined. Identity is keyed on the `profile` CELL — never the display name, // which is mutable and may collide between distinct users. // // `name`/`avatar` arrive as plain strings (named `computed` values auto-unwrap // as handler state — do not call `.get()` on them). `profile` is a live cell // reference: it round-trips through `push` as a link and is compared with // `equals()`, the pattern idiom for cell identity (see profile-group-chat / // fair-share / cfc-group-chat-demo). const join = handler((_event, { roster, viewer, profile, name, avatar }) => { const trimmed = (name ?? "").trim(); if (!trimmed) return; // No resolved profile name yet — nothing to contribute. if (!profile) return; // No resolved profile cell — no stable identity yet. // Idempotent: don't re-add this viewer. Compare by profile-cell identity so a // viewer who later renames themselves still counts as already-joined, and so // two distinct users who happen to share a display name don't block each other. const participants = roster.key("participants"); const already = participants.get().some((p) => equals(p.profile, profile)); if (!already) { participants.push({ profile, name: trimmed, avatar: (avatar ?? "").trim(), joinedAt: safeDateNow(), }); } viewer.set({ joined: true, joinedName: trimmed }); }); export interface RosterDemoInput { /** Shared roster — every user in the space reads & appends to this. */ roster?: PerSpace>; /** Current viewer's join marker — follows the user, not broadcast directly. */ viewer?: PerUser>; } export interface RosterDemoOutput { [NAME]: string; [UI]: VNode; roster: PerSpace>; viewer: PerUser>; participantCount: number; join: Stream; } export default pattern( ({ roster, viewer }) => { // Resolve THIS viewer's shared profile. `#profile` yields the viewer's own // profile cell (the default profile / picker result under PR #3830). The // cell itself is the stable identity used to dedupe the roster; the // convenience targets give just the strings for the snapshot. const profileWish = wish<{ name?: string; avatar?: string }>({ query: "#profile", }); const profileNameWish = wish({ query: "#profileName" }); const profileAvatarWish = wish({ query: "#profileAvatar" }); const myName = computed(() => profileNameWish.result ?? ""); const myAvatar = computed(() => profileAvatarWish.result ?? ""); // The live profile cell — passed to the join handler as the identity key. const myProfile = profileWish.result; const participants = roster.participants; const participantCount = participants.length; const hasJoined = computed(() => viewer.joined === true); // Inside a `computed` body, named `computed` values (hasJoined, myName) // auto-unwrap to their plain value — do NOT call `.get()` on them here. const joinLabel = computed(() => hasJoined ? "Joined" : "Join as " + (myName || "…") ); const boundJoin = join({ roster, viewer, profile: myProfile, name: myName, avatar: myAvatar, }); return { [NAME]: "Shared-profile roster", [UI]: ( { /* The current viewer's OWN identity — a live profile cell, so the trusted cf-profile-badge applies. */ } You Participants ({participantCount}) {joinLabel} {/* Other users render from the snapshot roster via cf-avatar. */} {participants.map((p) => ( {p.name} ))} ), roster, viewer, participantCount, join: boundJoin, }; }, );