import { computed, Default, equals, type FabricBytes, handler, ifElse, NAME, navigateTo, pattern, UI, type VNode, wish, Writable, } from "commonfabric"; import { profileLook } from "./system/profile-look.ts"; import GroupChatRoom, { Message, ParticipantProfileCell, User, } from "./group-chat-room.tsx"; /** * Group Chat Lobby Pattern * * Apple iOS-style lobby where unlimited users can join. Identity comes from * the viewer's shared profile (`wish({ query: "#profile" })`): the wish's * built-in UI lets the viewer pick one of their existing profiles or create a * new one, and joining snapshots the resolved name/picture into the shared * user roster (see docs/specs/shared-profile-rosters.md). */ interface LobbyInput { chatName: string | Default<"Group Chat">; messages: Writable>; users: Writable>; sessionId: Writable>; } export interface LobbyOutput { [NAME]: string; [UI]: VNode; chatName: string | Default<"Group Chat">; messages: Writable>; users: Writable>; sessionId: Writable>; } // Handler to reset the lobby (clear all state and generate new session) const resetLobby = handler< unknown, { messages: Writable; users: Writable; sessionId: Writable; } >((_event, { messages, users, sessionId }) => { console.log("[resetLobby] Resetting all chat state..."); // Generate new session ID to invalidate all existing chat room connections const newSessionId = `session-${Date.now()}-${ Math.random().toString(36).slice(2) }`; sessionId.set(newSessionId); messages.set([]); users.set([]); console.log( "[resetLobby] Chat state reset complete, new session:", newSessionId, ); }); // Handler for joining the chat. `name`/`picture`/`avatar` arrive as plain // values resolved from the viewer's shared profile (named `computed` values // auto-unwrap as handler state); `profile` is the live profile cell — the // STABLE identity key. Display names are mutable and not unique across users // (two profiles can both be "Alex"), so re-join detection compares profile // cells with `equals()`, never names. const joinChat = handler< unknown, { messages: Writable; users: Writable; sessionId: Writable; // May be undefined until the viewer's `#profile` wish resolves. profile: ParticipantProfileCell | undefined; name: string; picture?: FabricBytes; avatar?: string; } >((_event, { messages, users, sessionId, profile, name, picture, avatar }) => { const trimmed = (name ?? "").trim(); if (!trimmed || !profile) { console.log("[joinChat] No resolved profile yet, returning"); return; } console.log("[joinChat] Name:", trimmed); // Initialize session ID if not set (first user joining) let currentSessionId = sessionId.get(); if (!currentSessionId) { currentSessionId = `session-${Date.now()}-${ Math.random().toString(36).slice(2) }`; sessionId.set(currentSessionId); console.log("[joinChat] Initialized new session:", currentSessionId); } // Re-join check by profile-cell identity: the same user (even after a // rename) navigates back in under their previously claimed display name. const existingUsers = users.get() || []; const mine = existingUsers.find( (u) => u.profile && equals(u.profile, profile), ); let displayName = trimmed; if (mine) { displayName = mine.name; } else { // The room keys messages/reactions on the display name, so it must stay // unique within this roster — disambiguate when a DIFFERENT profile // already claimed the same name. let suffix = 2; while (existingUsers.some((u) => u.name === displayName)) { displayName = `${trimmed} ${suffix++}`; } users.push({ name: displayName, joinedAt: Date.now(), // The user is drawn with their picture, or else their avatar text. ...profileLook(picture, avatar), profile, }); console.log("[joinChat] User added:", displayName); // Add system message for join messages.push({ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`, author: "System", content: `${displayName} joined the chat`, timestamp: Date.now(), type: "system", reactions: [], }); } // Create chat room instance and navigate // Pass both the session ID at join time (mySessionId) and the Cell reference to check against (currentSessionId) console.log( "[joinChat] Navigating to chat room with session:", currentSessionId, ); const roomInstance = GroupChatRoom({ messages, users, myName: displayName, mySessionId: currentSessionId, currentSessionId: sessionId, }); return navigateTo(roomInstance); }); export default pattern( ({ chatName, messages, users, sessionId }) => { // Resolve THIS viewer's shared profile. The `#profile` wish's built-in UI // covers the whole lifecycle: a create surface when the viewer has no // profile, a link when they have one, and a picker (with inline create) // when they have several. The field targets give the snapshot values. const profileWish = wish<{ name?: string; avatar?: string }>({ query: "#profile", }); const profileNameWish = wish({ query: "#profileName" }); const profilePictureWish = wish({ query: "#profilePicture" }); const profileAvatarWish = wish({ query: "#profileAvatar" }); const myName = computed(() => profileNameWish.result ?? ""); const myPicture = computed(() => profilePictureWish.result); const myAvatar = computed(() => profileAvatarWish.result); const hasProfile = computed(() => (profileNameWish.result ?? "").trim() !== "" ); const joinLabel = computed(() => hasProfile ? `Join as ${myName}` : "Create a profile to join" ); const userCount = computed(() => users.get().length); const join = joinChat({ messages, users, sessionId, profile: profileWish.result, name: myName, picture: myPicture, avatar: myAvatar, }); return { [NAME]: computed(() => `${chatName} - Lobby`), [UI]: (

{chatName}

Join the conversation with your profile

{/* Join Form Card */}
Your profile
{ /* Built-in profile UI: create a profile when there is none, pick between (or add to) existing profiles otherwise. */ }
{profileWish[UI]}
{/* Active Users Section */} {ifElse( userCount,
Now chatting
{users.map((user) => (
{user.name}
))}
, <>, )} {/* Reset Button */}
), chatName, messages, users, sessionId, }; }, );