import { parseMessagePayload, serializeMessagePayload, type AttachmentHandle, type DecryptedMessage, type PollOption, type WhiteboardPayload, type WatchTogetherPayload, type GamePayload, } from '@chat-app/shared/chat'; export type AttachmentBucket = 'media' | 'audio' | 'files'; export interface ConversationAttachmentItem { messageId: string; senderId: string; createdAt: string; handle: AttachmentHandle; bucket: AttachmentBucket; } export interface ConversationAttachmentIndex { all: ConversationAttachmentItem[]; media: ConversationAttachmentItem[]; audio: ConversationAttachmentItem[]; files: ConversationAttachmentItem[]; } interface ReactionSummaryInput { emoji: string; count: number; mine: boolean; } export interface PollVoteOptionSummary extends PollOption { count: number; mine: boolean; percent: number; } export interface PollVoteSummary { totalVotes: number; options: PollVoteOptionSummary[]; } export const POLL_OPTION_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; export function collectConversationAttachments( messages: DecryptedMessage[], ): ConversationAttachmentIndex { const all: ConversationAttachmentItem[] = []; for (const message of messages) { if (message.deletedAt || !message.plaintext) continue; const parsed = parseMessagePayload(message.plaintext); if (parsed.kind !== 'text') continue; for (const handle of parsed.attachments) { all.push({ messageId: message.id, senderId: message.senderId, createdAt: message.createdAt, handle, bucket: bucketForMime(handle.mimeType), }); } } all.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); return { all, media: all.filter((item) => item.bucket === 'media'), audio: all.filter((item) => item.bucket === 'audio'), files: all.filter((item) => item.bucket === 'files'), }; } export function createPollPayload(question: string, optionTexts: string[]): string { const trimmedQuestion = question.trim(); if (!trimmedQuestion) throw new Error('poll question is required'); const options: PollOption[] = optionTexts .map((text) => text.trim()) .filter(Boolean) .slice(0, POLL_OPTION_EMOJIS.length) .map((text, idx) => ({ id: 'option-' + (idx + 1), emoji: POLL_OPTION_EMOJIS[idx]!, text, })); if (options.length < 2) throw new Error('poll requires at least two options'); return serializeMessagePayload({ v: 1, type: 'poll', question: trimmedQuestion, options, }); } export function createWhiteboardPayload(whiteboardId: string): string { const payload: WhiteboardPayload = { v: 1, type: 'whiteboard', whiteboard_id: whiteboardId, }; return serializeMessagePayload(payload); } export function createWatchTogetherPayload(sessionId: string): string { const payload: WatchTogetherPayload = { v: 1, type: 'watch_together', session_id: sessionId, }; return serializeMessagePayload(payload); } export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string { const payload: GamePayload = { v: 1, type: 'game', game_id: gameId, game_type: gameType, }; return serializeMessagePayload(payload); } export function summarizePollVotes( options: PollOption[], reactions: ReactionSummaryInput[], ): PollVoteSummary { const reactionByEmoji = new Map(reactions.map((reaction) => [reaction.emoji, reaction])); const totalVotes = options.reduce( (sum, option) => sum + (reactionByEmoji.get(option.emoji)?.count ?? 0), 0, ); return { totalVotes, options: options.map((option) => { const reaction = reactionByEmoji.get(option.emoji); const count = reaction?.count ?? 0; return { ...option, count, mine: reaction?.mine ?? false, percent: totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0, }; }), }; } function bucketForMime(mimeType: string): AttachmentBucket { if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) return 'media'; if (mimeType.startsWith('audio/')) return 'audio'; return 'files'; }