/// import { Cell, computed, Default, handler, ifElse, ImageData, NAME, pattern, UI, } from "commontools"; /** * Group Chat Room Pattern v9 * * New features: * 1. Click avatar to set/change avatar * 2. Camera icon sends images to chat * 3. Emoji reactions on messages with hover UI */ // Emoji reaction type export interface Reaction { emoji: string; userNames: string[]; } export interface Message { id: string; author: string; content: string; timestamp: number; type: "chat" | "system" | "image"; imageUrl?: string; reactions: Reaction[]; // Required - workaround for transformer bug } export interface User { name: string; joinedAt: number; color: string; avatarImage?: { url: string }; } interface RoomInput { messages: Cell>; users: Cell>; myName: Default; mySessionId: Default; currentSessionId: Cell>; } interface RoomOutput { myName: Default; } // Common reaction emojis const REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "😡"]; // Utility function to get initials from a name function getInitials(name: string): string { if (!name || typeof name !== "string") return "?"; return name .trim() .split(/\s+/) .map((word) => word[0]) .join("") .toUpperCase() .slice(0, 2); } // Handler to send a text message const sendMessage = handler< unknown, { messages: Cell; myName: string; contentInput: Cell } >((_event, { messages, myName, contentInput }) => { const content = contentInput.get().trim(); if (!content || !myName) return; messages.push({ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`, author: myName, content, timestamp: Date.now(), type: "chat", reactions: [], }); contentInput.set(""); }); // Handler to send an image message const sendImageMessage = handler< unknown, { messages: Cell; myName: string; chatImages: Cell } >((_event, { messages, myName, chatImages }) => { const images = chatImages.get() || []; if (images.length === 0 || !myName) return; const image = images[0]; messages.push({ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`, author: myName, content: "", imageUrl: image.url, timestamp: Date.now(), type: "image", reactions: [], }); chatImages.set([]); }); // Handler to confirm and save the avatar from avatarImages cell const confirmAvatar = handler< unknown, { users: Cell; myName: string; avatarImages: Cell } >((_event, { users, myName, avatarImages }) => { const images = avatarImages.get() || []; if (images.length === 0) return; const newImage = images[0]; const currentUsers = users.get() || []; const myUser = currentUsers.find((usr: User) => usr.name === myName); if (!myUser) return; const updatedUsers = currentUsers.map((usr: User) => usr.name === myName ? { ...usr, avatarImage: { url: newImage.url } } : usr ); users.set(updatedUsers); avatarImages.set([]); }); // Handler to cancel pending avatar const cancelAvatar = handler< unknown, { avatarImages: Cell } >((_event, { avatarImages }) => { avatarImages.set([]); }); // Handler to toggle emoji picker for a message const toggleEmojiPicker = handler< unknown, { emojiPickerMessageId: Cell; msgId: string } >((_event, { emojiPickerMessageId, msgId }) => { const current = emojiPickerMessageId.get(); // Toggle: if already open for this message, close it; otherwise open for this message emojiPickerMessageId.set(current === msgId ? "" : msgId); }); // Handler to add/toggle a reaction on a message const toggleReaction = handler< unknown, { messages: Cell; msgId: string; emoji: string; myName: string; emojiPickerMessageId: Cell; } >((_event, { messages, msgId, emoji, myName, emojiPickerMessageId }) => { const msgs = messages.get() || []; const msgIndex = msgs.findIndex((m: Message) => m && m.id === msgId); if (msgIndex < 0) return; const msg = msgs[msgIndex]; const reactions = [...(msg.reactions || [])]; const existingIdx = reactions.findIndex((r: Reaction) => r.emoji === emoji); if (existingIdx >= 0) { const reaction = { ...reactions[existingIdx] }; if (reaction.userNames.includes(myName)) { reaction.userNames = reaction.userNames.filter((n: string) => n !== myName ); if (reaction.userNames.length === 0) { reactions.splice(existingIdx, 1); } else { reactions[existingIdx] = reaction; } } else { reaction.userNames = [...reaction.userNames, myName]; reactions[existingIdx] = reaction; } } else { reactions.push({ emoji, userNames: [myName] }); } const updatedMsgs = [...msgs]; updatedMsgs[msgIndex] = { ...msg, reactions }; messages.set(updatedMsgs); // Close the emoji picker after selecting emojiPickerMessageId.set(""); }); export default pattern( ({ messages, users, myName, mySessionId, currentSessionId }) => { const contentInput = Cell.of(""); const avatarImages = Cell.of([]); const chatImages = Cell.of([]); const emojiPickerMessageId = Cell.of(""); const userList = computed( () => (users.get() || []).filter((user: User) => user && user.name), ); const myNameResolved = computed(() => myName || ""); const hasPendingAvatar = computed( () => avatarImages.get() && avatarImages.get().length > 0, ); const pendingAvatarUrl = computed(() => { const imgs = avatarImages.get(); if (!imgs || imgs.length === 0) { return ""; } return imgs[0].url || imgs[0].data || ""; }); const hasPendingChatImage = computed( () => chatImages.get() && chatImages.get().length > 0, ); const pendingChatImageUrl = computed(() => { const imgs = chatImages.get(); if (!imgs || imgs.length === 0) { return ""; } return imgs[0].url || imgs[0].data || ""; }); const isSessionValid = computed(() => { const currentSessId = currentSessionId.get(); if (!mySessionId || !currentSessId) return true; return mySessionId === currentSessId; }); const myUser = computed(() => { const resolved = myNameResolved; return (users.get() || []).find((user: User) => user.name === resolved); }); const myAvatarUrl = computed(() => myUser?.avatarImage?.url || ""); const myColor = computed(() => myUser?.color || "#007AFF"); return { [NAME]: computed(() => `Chat: ${myName}`), [UI]: ( {ifElse( isSessionValid, <> {/* Header with user list */} Now chatting {userList.map((user) => { const hasAvatar = computed(() => user && !!user.avatarImage?.url ); return ( {ifElse( hasAvatar, , {computed(() => user ? getInitials(user.name) : "?" )} , )} {user.name} ); })} {/* Messages Container */} {messages.map((msg) => { // Guard against undefined messages in the array const isValidMessage = computed(() => msg && msg.id); const isMyMessage = computed(() => msg && msg.author === myNameResolved ); const isSystemMessage = computed(() => msg && msg.type === "system" ); const isImageMessage = computed(() => msg && msg.type === "image" ); const authorColor = computed(() => { if (!msg) return "#6b7280"; const user = (users.get() || []).find((usr: User) => usr && usr.name === msg.author ); return user?.color || "#6b7280"; }); const authorAvatarUrl = computed(() => { if (!msg) return ""; const user = (users.get() || []).find((usr: User) => usr && usr.name === msg.author ); return user?.avatarImage?.url || ""; }); const isFirstInAuthorBlock = computed(() => { if (!msg) return true; const msgArray = (messages.get() || []).filter(( message: Message, ) => message && message.id); const currentIndex = msgArray.findIndex(( message: Message, ) => message && message.id === msg.id); if (currentIndex <= 0) return true; const prevMessage = msgArray[currentIndex - 1]; if (!prevMessage) return true; return prevMessage.author !== msg.author || prevMessage.type === "system"; }); const shouldShowAvatar = computed(() => !isMyMessage && isFirstInAuthorBlock ); // Check if emoji picker is open for this message const isPickerOpen = computed( () => msg.id && emojiPickerMessageId.get() === msg.id, ); // Note: Use direct property access to avoid transformer bug // with || [] fallback (see computed-var-then-map.issue.md) return ( isValidMessage ? "flex" : "none" ), marginBottom: computed(() => { if (!msg) { return "8px"; } const msgArray = (messages.get() || []).filter(( message: Message, ) => message && message.id); const currentIndex = msgArray.findIndex(( message: Message, ) => message && message.id === msg.id); if ( currentIndex < 0 || currentIndex >= msgArray.length - 1 ) return "8px"; const nextMessage = msgArray[currentIndex + 1]; if (!nextMessage) return "8px"; return nextMessage.author === msg.author && nextMessage.type !== "system" ? "2px" : "8px"; }), flexDirection: computed(() => !msg ? "row" : (msg.type === "system" ? "column" : (msg.author === myNameResolved ? "row-reverse" : "row")) ), alignItems: "flex-end", gap: "8px", }} > {ifElse( isSystemMessage, {msg.content} , <> {/* Avatar */} {ifElse( isMyMessage, null, ifElse( shouldShowAvatar, ifElse( authorAvatarUrl, , {computed(() => msg ? getInitials(msg.author) : "?" )} , ), , ), )} {/* Message bubble and reactions */} msg && msg.author === myNameResolved ? "flex-end" : "flex-start" ), }} > {/* Author name */} {ifElse( shouldShowAvatar, {msg.author} , null, )} {/* Bubble - text or image */} {ifElse( isImageMessage, , msg && msg.author === myNameResolved ? "4px" : "18px" ), borderBottomLeftRadius: computed(() => msg && msg.author === myNameResolved ? "18px" : "4px" ), backgroundColor: computed(() => msg && msg.author === myNameResolved ? "#007AFF" : "#E5E5EA" ), color: computed(() => msg && msg.author === myNameResolved ? "white" : "#1d1d1f" ), fontSize: "15px", lineHeight: "1.4", }} > {msg.content} , )} {/* Reactions row */} {/* Existing reactions */} {msg.reactions.map((reaction) => ( {reaction.emoji} {computed(() => reaction && reaction.userNames ? reaction.userNames.length : 0 )} ))} {/* Add reaction button - always visible, click to toggle picker */} + {/* Emoji picker (visible when toggled) - positioned inline to avoid clipping */} {ifElse( isPickerOpen, {REACTION_EMOJIS.map((emoji) => ( {emoji} ))} , null, )} >, )} ); })} {/* Input Area */} {/* Clickable Avatar - click to change */} {ifElse( myAvatarUrl, , {computed(() => getInitials(myNameResolved))} , )} {/* Hidden ct-image-input overlaid on avatar */} Chatting as:{" "} {myName} {/* Attachment button for sending images to chat */} {/* Pending avatar preview */} {ifElse( hasPendingAvatar, ✓ ✗ , null, )} {/* Pending chat image preview */} {ifElse( hasPendingChatImage, Send ✗ , null, )} Send >, // Expired session ! Session Expired This chat session has been reset. Please return to the lobby to join a new session. You were chatting as:{" "} {myName} Use the back button to return to the lobby , )} ), myName, }; }, );
This chat session has been reset. Please return to the lobby to join a new session.