import { type DecryptedMessage, editEncryptedMessage, parseMessagePayload, softDeleteMessage, } from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useNickname } from '../lib/friendNicknames'; import { ensureInstallId } from '../lib/installId'; import { supabase } from '../lib/supabase'; import { cachedUserKey } from '../lib/userIdentity'; import type { AggregatedReaction } from '../lib/useMessageReactions'; import { summarizePollVotes } from '../lib/conversationFeatures'; import { extractFirstUrl } from '../lib/useLinkPreview'; import { AttachmentAudio } from './AttachmentAudio'; import { AttachmentGeneric } from './AttachmentGeneric'; import { AttachmentImage } from './AttachmentImage'; import { AttachmentPdf } from './AttachmentPdf'; import { AttachmentVideo } from './AttachmentVideo'; import { LinkPreviewCard } from './LinkPreviewCard'; import { Avatar } from './Avatar'; import { CopyIcon, ForwardIcon, MoreVerticalIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon, } from './icons'; const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏']; const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000; export interface QuotedRef { id: string; senderName: string; snippet: string; isAttachment: boolean; deleted: boolean; } interface Props { message: DecryptedMessage; mine: boolean; groupedWithPrev: boolean; /** Last message of a run from this sender — anchor avatar on this row. */ isLastOfRun?: boolean; senderDisplayName?: string | null | undefined; senderAvatarUrl?: string | null | undefined; conversationId: string; reactions: AggregatedReaction[]; onToggleReaction: (emoji: string) => Promise; onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise; showSeen?: boolean; /** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */ deliveryState?: 'sent' | 'delivered' | 'read'; /** Resolved quoted message info (parent does the lookup). */ quoted?: QuotedRef | null; /** Tap-to-jump on quote bubble. Receives the quoted message's id. */ onJumpToMessage?: (id: string) => void; /** Hover action: parent receives current message to start a reply. */ onReply?: (m: DecryptedMessage) => void; /** Hover action: parent opens forward dialog for current message. */ onForward?: (m: DecryptedMessage) => void; /** Click on the message's avatar surfaces the author's profile card. */ onAvatarClick?: (userId: string, ev: React.MouseEvent) => void; /** Highlighted state — set briefly after a jump. */ highlighted?: boolean; } export function MessageBubble({ message, mine, groupedWithPrev, isLastOfRun = false, senderDisplayName, senderAvatarUrl, conversationId, reactions, onToggleReaction, onVotePoll, showSeen = false, deliveryState, quoted = null, onJumpToMessage, onReply, onForward, onAvatarClick, highlighted = false, }: Props) { const { t } = useTranslation(['app']); const { session } = useAuth(); // Per-viewer nickname override for the message sender. Fallback chain // keeps the existing behavior when no nickname is set. Skipped for the // quoted-sender snippet — that's a different user. const senderName = useNickname(message.senderId, senderDisplayName ?? ''); const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null; const parsed = parseMessagePayload(message.plaintext); const initialText = parsed.kind === 'text' ? parsed.text : ''; const initialAttachments = parsed.kind === 'text' ? parsed.attachments : []; const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(initialText); const [busy, setBusy] = useState(false); const [editError, setEditError] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; source: 'context' | 'more'; } | null>(null); const pickerRef = useRef(null); const createdAt = new Date(message.createdAt); const age = Date.now() - createdAt.getTime(); const withinEditWindow = age < EDIT_WINDOW_MS; const bodyText = initialText; const attachments = initialAttachments; const pollOptionEmojis = parsed.kind === 'poll' ? parsed.options.map((option) => option.emoji) : []; const visibleReactions = pollOptionEmojis.length === 0 ? reactions : reactions.filter((reaction) => !pollOptionEmojis.includes(reaction.emoji)); // /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender // fires the soft-delete when the clock runs out. Receivers just watch // the deletedAt flip via realtime. const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined; const [tickNow, setTickNow] = useState(() => Date.now()); useEffect(() => { if (expireMs === undefined) return; const id = window.setInterval(() => setTickNow(Date.now()), 1000); return () => window.clearInterval(id); }, [expireMs]); const msLeft = expireMs !== undefined ? Math.max(0, expireMs - (tickNow - createdAt.getTime())) : null; useEffect(() => { if (!mine) return; if (expireMs === undefined) return; if (message.deletedAt) return; const remaining = Math.max(0, expireMs - age); const timer = window.setTimeout(() => { void softDeleteMessage(supabase, message.id).catch((err: unknown) => { console.warn('ephemeral auto-delete failed', err); }); }, remaining); return () => window.clearTimeout(timer); }, [mine, expireMs, age, message.id, message.deletedAt]); // Receiver-side auto-hide when the expiry window elapses even if the // sender's delete hasn't propagated yet (network hiccup, offline-sender). const [localExpired, setLocalExpired] = useState(msLeft !== null && msLeft <= 0); useEffect(() => { if (expireMs === undefined) return; if (localExpired) return; const remaining = Math.max(0, expireMs - age); const t = window.setTimeout(() => setLocalExpired(true), remaining); return () => window.clearTimeout(t); }, [expireMs, age, localExpired]); const canEdit = parsed.kind === 'text' && mine && withinEditWindow && !message.deletedAt && attachments.length === 0; const canDelete = (parsed.kind === 'text' || parsed.kind === 'poll') && mine && !message.deletedAt; const canCopy = (parsed.kind === 'text' && bodyText.length > 0) || parsed.kind === 'poll'; const hasMoreMenuActions = canCopy || canEdit || canDelete; const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format( createdAt, ); useEffect(() => { if (!pickerOpen) return; function onClickOutside(e: MouseEvent) { if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { setPickerOpen(false); } } document.addEventListener('mousedown', onClickOutside); return () => document.removeEventListener('mousedown', onClickOutside); }, [pickerOpen]); const handleEditSave = useCallback(async () => { if (!session) return; const trimmed = editText.trim(); if (!trimmed || trimmed === bodyText) { setEditing(false); setEditError(null); return; } setBusy(true); setEditError(null); try { const priv = await cachedUserKey(session.user.id); if (!priv) throw new Error('user key not unlocked'); await editEncryptedMessage({ client: supabase, messageId: message.id, conversationId, newPlaintext: trimmed, senderUserId: session.user.id, senderDeviceId: ensureInstallId(), senderPrivateKey: priv, }); setEditing(false); } catch (err: unknown) { const code = extractErrorCode(err); setEditError( code ? t('errors:' + code, { defaultValue: t('errors:generic') }) : err instanceof Error ? err.message : t('errors:generic'), ); } finally { setBusy(false); } }, [editText, message.id, message.plaintext, conversationId, session, t]); const handleDelete = useCallback(async () => { if (busy) return; setBusy(true); try { await softDeleteMessage(supabase, message.id); } catch (err: unknown) { console.error('delete failed', err); } finally { setBusy(false); } }, [busy, message.id]); const handlePickEmoji = useCallback( async (emoji: string) => { setPickerOpen(false); try { await onToggleReaction(emoji); } catch (err: unknown) { console.error('toggleReaction failed', err); } }, [onToggleReaction], ); const copyableText = parsed.kind === 'poll' ? [parsed.question, ...parsed.options.map((option) => '- ' + option.text)].join('\n') : bodyText; const handleCopy = useCallback(async () => { if (!copyableText.trim()) return; try { await navigator.clipboard.writeText(copyableText); } catch (err: unknown) { console.warn('copy message failed', err); } }, [copyableText]); const openContextMenu = useCallback((ev: React.MouseEvent) => { ev.preventDefault(); setContextMenu({ x: ev.clientX, y: ev.clientY, source: 'context' }); }, []); if (message.deletedAt || localExpired) { return (
onAvatarClick(message.senderId, ev) } : {})} />
{t('app:chats.deleted')}
); } if (parsed.kind === 'call_event') { return ( ); } return (
onAvatarClick(message.senderId, ev) } : {})} />
{editing ? (