/** * One message of a FabriChat room, rendered: `FabriChatMessageRow`, with its * reviewed surfaces for reacting, editing, deleting, and obliterating, and the * reaction tallies and body text it shows. */ import { action, type Cell, computed, equals, pattern, type PerSession, type ReadonlyCell, Stream, UI, type VNode, Writable, } from "commonfabric"; import type { ViewPlace } from "./logic.ts"; import { commitDelete, commitDeleteReaction, commitEdit, commitObliterate, commitReply, commitSendReaction, type ComposerCell, entityKeyOf, isDeleted, type JoiningManager, type MessageCell, messageRightsOf, type MessagesCell, NO_MESSAGE_RIGHTS, type ReactionsValue, type RepliedMessage, type RoomStreamEvent, tellManagerJoined, viewKeyOf, } from "./room-records.tsx"; import { CHAT_DELETE_ACTION, CHAT_DELETE_SURFACE, CHAT_EDIT_ACTION, CHAT_EDIT_SURFACE, CHAT_OBLITERATE_ACTION, CHAT_OBLITERATE_SURFACE, CHAT_REACT_ACTION, CHAT_REACT_SURFACE, CHAT_UNREACT_ACTION, type ChatContainer, type ChatDisplay, type ChatReaction, type ChatReactionTally, type ChatRoomKind, type ChatRoomLink, type ProfileCell, reactionTalliesOf, } from "./schemas.tsx"; /** The emoji a message's reaction picker puts within easy reach. */ export const FABRICHAT_QUICK_REACTIONS = [ "👍", "❤️", "😂", "😮", "😢", "🎉", ] as const; /** How one emoji stands on one message, and the card listing its reactors. */ export interface ReactionTally extends ChatReactionTally { /** * An id for the card that lists them, unique on the page, so the count can * name the card as its description. */ cardId: string; /** * The viewer's reaction with this emoji, the one `deleteReaction` removes; * absent where `mine` is false. */ own?: Cell; } /** * The reactions `list` holds, tallied as `reactionTalliesOf()` does, each * tally with a card id made from `cardIdPrefix` and, where the viewer is * among its reactors, the viewer's reaction by its cell. */ export const reactionTallies = ( list: Cell | undefined, viewer: ProfileCell | undefined, cardIdPrefix: string, ): ReactionTally[] => { const reactions = (list?.get() ?? []) as ChatReaction[]; return reactionTalliesOf(reactions, viewer).map((tally, index) => { const own = viewer === undefined ? -1 : reactions.findIndex((reaction) => reaction?.emoji === tally.emoji && equals(reaction.reactorProfile, viewer) ); return { ...tally, cardId: `${cardIdPrefix}-${index}`, ...(own < 0 || list === undefined ? {} : { own: list.key(own) }), }; }); }; /** How a message's body reads, for a quote or a deleted message. */ export const bodyText = (record: RepliedMessage | undefined): string => record === undefined ? "" : typeof record.body === "string" ? record.body : record.authorProfile === undefined ? "This message was removed." : "This message was deleted."; /** How a reaction's emoji is drawn. */ const EMOJI_STYLE = { fontSize: "18px", lineHeight: "1" }; /** What a message row needs. */ export interface FabriChatMessageRowInput { /** The message. */ message: MessageCell; /** The viewer's profile, which holds no value while it is unknown. */ myProfile: ProfileCell | undefined; /** * Whether the row belongs to the open thread's list rather than the main * conversation's. The main conversation's list holds a row for each message * shown there, and the thread's list for each message of the session's open * thread; a row's list decides which of the row's controls it offers. */ inThread: boolean; /** The room's kind. */ kind: ChatRoomKind; /** The session's composer state. */ composer: PerSession; /** * Every message's place in the room's views, by the message's key, as the * room derives them from its messages. The row reads its own message's. */ places: ReadonlyCell>; /** Whether the viewer can't send, edit, delete, or react in the room. */ cannotWrite: boolean; /** Whether the viewer holds OWNER in the room's space. */ isOwner: boolean; /** * Whether the room lets an OWNER of its space obliterate any message; * absent, it doesn't. */ ownersMayObliterate?: boolean; /** The room's messages, which a reply is pushed onto. */ messages: MessagesCell; /** Whether the room is one the viewer's manager can take them into. */ canJoin?: boolean; /** The container's declaration for a room held inside another space. */ container?: Cell; /** * The viewer's manager, which a write tells it joined the room; absent * while the viewer has none, or theirs hasn't resolved. */ manager?: JoiningManager; /** The room itself, as the viewer's manager links it. */ self?: Cell; } /** What a message row provides: its rendering, and its controls' streams. */ export interface FabriChatMessageRowOutput { /** The message's rendering. */ [UI]: VNode; /** The message's reactions, tallied by emoji. */ tallies: ReactionTally[]; /** Adds the viewer's reaction: `emoji`, or the text typed in. */ sendReaction: Stream; /** Removes the viewer's reaction: the one `reaction` names. */ deleteReaction: Stream; /** Records a new version of the message, from the text typed in. */ editMessage: Stream; /** Deletes the message. */ deleteMessage: Stream; /** Obliterates the message. */ obliterateMessage: Stream; /** Sends a reply to the message, shown only in its thread. */ replyInThread: Stream; /** Sends a reply to the message, shown in its thread and the conversation. */ replyInBoth: Stream; /** Sends a reply to the message, shown in the conversation, quoting it. */ replyInMain: Stream; } /** * One message: its sender, its body, what it replies to, and its reactions, * with controls that appear on hover to react, reply, edit, and delete. */ export const FabriChatMessageRow = pattern< FabriChatMessageRowInput, FabriChatMessageRowOutput >((input) => { const { message, myProfile, inThread, kind, composer, places, cannotWrite, isOwner, ownersMayObliterate, messages, canJoin, container, manager, self, } = input; const joined = tellManagerJoined({ canJoin, container, manager, self }); const pickerOpen = new Writable.perSession(false); const togglePicker = action(() => pickerOpen.set(!pickerOpen.get())); const tallies = computed(() => { const key = entityKeyOf(message.resolveAsCell()) ?? "unsaved"; return reactionTallies( message.get()?.reactions, myProfile?.get() === undefined ? undefined : myProfile, `fabrichat-reactors-${key}`, ); }); const isDeletedNow = computed(() => isDeleted(message.get())); // What the viewer may do to the message, by the rule the room's handlers // admit an act by, and none of it while they can't write in the room. The // rule reads the message's author and body, and is handed those two alone. const rights = computed(() => { if (cannotWrite || myProfile?.get() === undefined) return NO_MESSAGE_RIGHTS; const record = message.get(); return messageRightsOf( record === undefined ? undefined : { authorProfile: record.authorProfile, body: record.body }, myProfile.resolveAsCell(), kind, isOwner === true, ownersMayObliterate === true, ); }); const isEditing = computed(() => { const editing = composer.get()?.editing; return editing !== undefined && equals(editing, message); }); const text = computed(() => bodyText(message.get())); const isEdited = computed(() => { const record = message.get(); return record?.editedAt !== undefined && typeof record.body === "string"; }); const quote = computed(() => { const reply = message.get()?.replyTo; return reply?.shownIn === "main" ? bodyText(reply.message.get()) : ""; }); // How many replies the thread the message roots holds. The room derives // every message's place at once, from its messages alone, so whichever // runtime runs the room computes them for every viewer. The row reads its // own, and runs again only when that one changes. const replies = computed(() => { const key = viewKeyOf(message); return (key === undefined ? undefined : places.key(key).get())?.replies ?? 0; }); const threadLabel = computed(() => replies === 1 ? "1 reply" : `${replies} replies` ); const threadLinkDisplay = computed((): ChatDisplay => !inThread && replies > 0 ? "inline-flex" : "none" ); // What differs by viewer or by session is shown or hidden through a prop, // never by building a different tree: a branch chosen per viewer is stored // once for everyone, and runtimes that chose differently overwrite each // other without end. Each element shown or hidden this way also carries a // static `hidden`, which keeps it out of view until its display computed has // a value and is outranked by that value once it has one, so an element // whose computed has yet to run stays hidden. A computed that reads the // viewer's or the session's state has a value only once a runtime acting // for that viewer runs the room, which one that only renders it does not, // so which rows the main conversation holds, which every viewer is shown, // is decided from the room's messages alone, by the room. const editDisplay = computed((): ChatDisplay => rights.canEdit ? "inline-flex" : "none" ); const deleteDisplay = computed((): ChatDisplay => rights.canDelete ? "inline-flex" : "none" ); const obliterateDisplay = computed((): ChatDisplay => rights.canObliterate ? "inline-flex" : "none" ); // An open editor closes once the message no longer takes an edit from the // viewer, as when an OWNER deletes it. const editorDisplay = computed((): ChatDisplay => isEditing && rights.canEdit ? "block" : "none" ); const pickerDisplay = computed((): ChatDisplay => pickerOpen.get() === true ? "flex" : "none" ); const pickerLabel = computed(() => (pickerOpen.get() === true ? "✕" : "☺+")); const startReply = action(() => composer.key("replyTo").set(message)); const openThread = action(() => composer.key("thread").set(message)); const startEdit = action(() => composer.key("editing").set(message)); const stopEdit = action(() => composer.key("editing").set(undefined)); // Each handler is bound to the cells it uses and no others; `messages` // takes the replies, and names the room's space to the acts on a message. const sendReaction = commitSendReaction({ myProfile, room: messages, message, pickerOpen, closesPicker: true, joined, }); const deleteReaction = commitDeleteReaction({ myProfile, room: messages, message, }); const editMessage = commitEdit({ myProfile, room: messages, message, composer, }); const deleteMessage = commitDelete({ myProfile, room: messages, message, }); const obliterateMessage = commitObliterate({ myProfile, room: messages, message, kind, ownersMayObliterate, }); const replyInThread = commitReply({ myProfile, messages, message, shownIn: "thread", joined, }); const replyInBoth = commitReply({ myProfile, messages, message, shownIn: "both", joined, }); const replyInMain = commitReply({ myProfile, messages, message, shownIn: "main", joined, }); return { [UI]: ( // A row is built only for a message its list shows: the main // conversation's rows are the room's shared choice, and a thread row is // the session's, for a message of its open thread.
{quote ? ( {quote} ) : null} {isDeletedNow ? ( {text} ) : ( {text} )} {isEdited ? (edited) : null}
{tallies.map((tally) => ( {tally.emoji}{" "} {tally.count} {tally.reactors.map((reactor) => ( ))} ))}
{isDeletedNow ? null : ( {pickerLabel} )} {isDeletedNow || inThread ? null : ( Reply )} {isDeletedNow || inThread ? null : ( Thread )}
), tallies, sendReaction, deleteReaction, editMessage, deleteMessage, obliterateMessage, replyInThread, replyInBoth, replyInMain, }; });