/// import { type Cell, Default, derive, handler, lift, recipe, str, } from "commontools"; export interface ReactionMessageInput { id?: string; content?: string; reactions?: Record; } interface ReactionEvent { messageId?: unknown; reaction?: unknown; delta?: unknown; } export interface NormalizedMessage { id: string; content: string; reactions: Record; } export interface ReactionCountEntry { reaction: string; count: number; } export interface MessageReactionView { id: string; content: string; reactions: ReactionCountEntry[]; } export interface MessageTotalEntry { id: string; content: string; total: number; } export interface ReactionTotalEntry { reaction: string; count: number; } export interface ReactionMatrixRow { messageId: string; reaction: string; count: number; } export interface ChatReactionTrackerArgs { messages: Default; reactionCatalog: Default; } const normalizeMessageId = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const normalizeReactionKey = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const toNonNegativeInteger = (value: unknown): number | undefined => { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; } const integer = Math.trunc(value); return integer >= 0 ? integer : undefined; }; const sanitizeReactionCatalog = (value: unknown): string[] => { if (!Array.isArray(value)) return []; const seen = new Set(); for (const entry of value) { const reaction = normalizeReactionKey(entry); if (!reaction || seen.has(reaction)) continue; seen.add(reaction); } return [...seen].sort((a, b) => { if (a < b) return -1; if (a > b) return 1; return 0; }); }; const sanitizeReactionCounts = ( value: unknown, catalog: readonly string[], ): Record => { const map = new Map(); if (value && typeof value === "object") { const entries = Object.entries(value as Record); for (const [rawKey, rawValue] of entries) { const reaction = normalizeReactionKey(rawKey); const count = toNonNegativeInteger(rawValue); if (!reaction || count === undefined) continue; map.set(reaction, count); } } for (const reaction of catalog) { if (!map.has(reaction)) { map.set(reaction, 0); } } const normalized = [...map.entries()].sort((a, b) => { if (a[0] < b[0]) return -1; if (a[0] > b[0]) return 1; return 0; }); const result: Record = {}; for (const [reaction, count] of normalized) { result[reaction] = count; } return result; }; const sanitizeMessageContent = (value: unknown, fallback: string): string => { if (typeof value !== "string") return fallback; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : fallback; }; const sanitizeMessages = ( value: unknown, catalog: readonly string[], ): NormalizedMessage[] => { if (!Array.isArray(value)) return []; const seen = new Set(); const sanitized: NormalizedMessage[] = []; for (const entry of value) { if (!entry || typeof entry !== "object") continue; const base = entry as ReactionMessageInput; const id = normalizeMessageId(base.id); if (!id || seen.has(id)) continue; seen.add(id); const content = sanitizeMessageContent(base.content, id); const reactions = sanitizeReactionCounts(base.reactions, catalog); sanitized.push({ id, content, reactions }); } sanitized.sort((a, b) => a.id.localeCompare(b.id)); return sanitized; }; const cloneNormalizedMessage = ( message: NormalizedMessage, ): ReactionMessageInput => ({ id: message.id, content: message.content, reactions: { ...message.reactions }, }); const sumReactions = (counts: Record): number => { let total = 0; for (const value of Object.values(counts)) { total += value; } return total; }; const sanitizeDelta = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return 1; } const integer = Math.trunc(value); return integer === 0 ? 0 : integer; }; const recordMessageReaction = handler( ( event: ReactionEvent | undefined, context: { messages: Cell; reactionCatalog: Cell; }, ) => { const messageId = normalizeMessageId(event?.messageId); const reactionKey = normalizeReactionKey(event?.reaction); const delta = sanitizeDelta(event?.delta); if (!messageId || !reactionKey || delta === 0) { return; } const baseCatalog = sanitizeReactionCatalog( context.reactionCatalog.get(), ); const nextCatalog = sanitizeReactionCatalog([ ...baseCatalog, reactionKey, ]); context.reactionCatalog.set([...nextCatalog]); const currentMessages = sanitizeMessages( context.messages.get(), nextCatalog, ); const index = currentMessages.findIndex((item) => item.id === messageId); if (index === -1) return; const target = currentMessages[index]; const currentCount = target.reactions[reactionKey] ?? 0; const updatedCount = currentCount + delta; const nextCount = updatedCount < 0 ? 0 : updatedCount; const updatedMessages = currentMessages.map((item, itemIndex) => { if (itemIndex !== index) return item; const reactions = sanitizeReactionCounts( { ...item.reactions, [reactionKey]: nextCount }, nextCatalog, ); return { ...item, reactions }; }); context.messages.set( updatedMessages.map((message) => cloneNormalizedMessage(message)), ); }, ); export const chatReactionTracker = recipe( "Chat Reaction Tracker", ({ messages, reactionCatalog }) => { const catalogView = lift(sanitizeReactionCatalog)(reactionCatalog); const normalizedMessages = lift( ( { list, catalog }: { list: ReactionMessageInput[] | undefined; catalog: string[]; }, ) => sanitizeMessages(list, catalog), )({ list: messages, catalog: catalogView, }); const messagesView = lift((entries: NormalizedMessage[]) => entries.map((message) => { const reactions = Object.entries(message.reactions) .map(([reaction, count]) => ({ reaction, count })) .sort((a, b) => { if (a.reaction < b.reaction) return -1; if (a.reaction > b.reaction) return 1; return 0; }); return { id: message.id, content: message.content, reactions }; }) )(normalizedMessages); const messageTotals = lift((entries: NormalizedMessage[]) => entries.map((message) => ({ id: message.id, content: message.content, total: sumReactions(message.reactions), })) )(normalizedMessages); const reactionTotals = lift((entries: NormalizedMessage[]) => { const totals = new Map(); for (const message of entries) { for (const [reaction, count] of Object.entries(message.reactions)) { totals.set(reaction, (totals.get(reaction) ?? 0) + count); } } return [...totals.entries()] .sort((a, b) => { if (a[0] < b[0]) return -1; if (a[0] > b[0]) return 1; return 0; }) .map(([reaction, count]) => ({ reaction, count })); })(normalizedMessages); const reactionMatrix = derive(normalizedMessages, (entries) => { const rows: ReactionMatrixRow[] = []; for (const message of entries) { const reactions = Object.entries(message.reactions).sort((a, b) => { if (a[0] < b[0]) return -1; if (a[0] > b[0]) return 1; return 0; }); for (const [reaction, count] of reactions) { rows.push({ messageId: message.id, reaction, count }); } } return rows; }); const messageCount = lift((entries: NormalizedMessage[]) => entries.length)( normalizedMessages, ); const totalReactions = lift((entries: ReactionTotalEntry[]) => entries.reduce((sum, entry) => sum + entry.count, 0) )(reactionTotals); const summary = str`${totalReactions} reactions across ${messageCount} \ messages`; return { messages: messagesView, reactionCatalog: catalogView, messageTotals, reactionTotals, reactionMatrix, messageCount, totalReactions, summary, recordReaction: recordMessageReaction({ messages, reactionCatalog }), }; }, );