import { type Cell, computed, Default, equals, handler, ifElse, NAME, pattern, type PerSpace, type PerUser, safeDateNow, Stream, UI, type VNode, wish, Writable, } from "commonfabric"; /** * Profile group chat — a real multi-user chat that identifies participants by * their **shared profile** instead of a free-text name field. * * Demonstrates the CT-1649 roster approach in a genuine multiplayer pattern: * - Messages live in a `PerSpace` array (shared by everyone in the space). * - The sender's identity is resolved from THEIR shared profile via * `wish({ query: "#profile" })`, and the live profile **cell** is stored on * each message — cross-space reads resolve for any viewer (CT-1667/1687), so * every identity in the UI renders through the trusted, first-class * ``: message gutters as `circle` badges (avatar + seal, * with a plain name label), the participant strip as `chip` badges, the * viewer's own as `full`. Each badge carries the verified-identity seal and * links to that person's profile. * - A snapshot of name/avatar rides alongside the cell as a durable fallback * label (used for participant dedup and if the live cell is momentarily * unresolved). * * Compare to `packages/patterns/scoped-group-chat/main-plain-inputs.tsx`, which * this is modeled on — the change is sourcing identity from the shared profile * (cell, not just strings) and rendering it through the badge. * See `docs/specs/shared-profile-rosters.md`. */ /** Live link to a contributor's profile cell — stable identity + live data. */ export type ChatProfileCell = Cell<{ name?: string; avatar?: string }>; export interface ChatMessage { /** Sender's live profile cell — rendered first-class via cf-profile-badge. */ authorProfile: ChatProfileCell; /** Sender's profile name, snapshotted at send time (durable fallback label). */ author: string; /** Sender's profile avatar (URL or glyph), snapshotted at send time. */ avatar: string; body: string; sentAt: number; } const DEFAULT_MESSAGES: ChatMessage[] = []; type MessagesCell = Writable; type DraftCell = Writable; export type SendEvent = Record; // Append a message. `profile` is the sender's live profile cell (the identity // rendered first-class via cf-profile-badge); it round-trips through `push` as a // link. `name`/`avatar` arrive as plain strings (named `computed` values // auto-unwrap as handler state) and are snapshotted as a durable fallback label; // `draft` is a live PerUser cell. const sendMessage = handler((_event, { messages, draft, profile, name, avatar }) => { const author = (name ?? "").trim(); const body = (draft.get() ?? "").trim(); if (!author || !body) return; // No profile yet, or empty message. if (!profile) return; // No resolved profile cell — no first-class identity. messages.push({ authorProfile: profile, author, avatar: (avatar ?? "").trim(), body, sentAt: safeDateNow(), }); draft.set(""); }); export interface ProfileGroupChatInput { /** Shared message log — every user in the space reads & appends to this. */ messages?: PerSpace>; /** Current viewer's message draft — follows the user, not broadcast. */ draft?: PerUser>; } export interface ProfileGroupChatOutput { [NAME]: string; [UI]: VNode; messages: PerSpace>; draft: PerUser>; messageCount: number; sendMessage: Stream; } export default pattern( ({ messages, draft }) => { // Resolve THIS viewer's shared profile (their default profile / picker // result under PR #3830). `#profile` is the live cell — the identity key // stored on each message and rendered via cf-profile-badge; the field // targets give the snapshot strings (fallback label + participant dedup). 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 — stored on each message and passed to the badge. const myProfile = profileWish.result; // Gate the composer on BOTH the name (for the snapshot/label) AND the live // profile CELL the send handler requires. Keying only on `#profileName` // would enable Send in the window where the name resolves but the `#profile` // cell hasn't, and the handler would then silently drop the message. const hasProfile = computed(() => (profileNameWish.result ?? "") !== "" && profileWish.result !== undefined ); const messageCount = messages.length; // Distinct participants, derived from the shared log — the roster, rendered // as a strip of `chip` profile badges bound to each contributor's live // profile cell. Dedupe by profile-CELL identity (`equals`), never the // display name: two distinct people can share a name ("Ben" + "Ben"), and // each is a separate participant — keying on the name would collapse them. const participants = computed<{ name: string; profile: ChatProfileCell }[]>( () => { const out: { name: string; profile: ChatProfileCell }[] = []; for (const m of messages ?? []) { if ( m && m.authorProfile && !out.some((p) => equals(p.profile, m.authorProfile)) ) { out.push({ name: m.author, profile: m.authorProfile }); } } return out; }, ); const participantCount = computed(() => participants.length); const send = sendMessage({ messages, draft, profile: myProfile, name: myName, avatar: myAvatar, }); return { [NAME]: "Profile group chat", [UI]: ( Profile chat You { /* Roster strip — distinct participants, each a `chip` badge bound to their live profile cell (name + DID-hued seal dot, navigable). */ } In this room ({participantCount}) { /* Plain flex row (not cf-hstack) — cf-hstack's :host clips overflow:hidden, which cuts the badges' verified glow. */ }
{participants.map((p) => ( ))}
{ /* Messages — classic chat layout: the sender's avatar as a `circle` profile badge (verified seal ring, navigable to their profile) in the gutter, with the name as a plain text label above the body. */ } {messages.map((message) => (
{message.author} {message.body}
))} {ifElse( computed(() => (messages ?? []).length === 0), , null, )}
{/* Composer — disabled until the viewer has a resolvable profile. */} !hasProfile)}> Send
), messages, draft, messageCount, sendMessage: send, }; }, );