import { loadDevicePrivateKey } from '@chat-app/shared/auth'; 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 { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { devLocalSecretStore } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; import type { AggregatedReaction } from '../lib/useMessageReactions'; import { AttachmentImage } from './AttachmentImage'; import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏']; const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000; interface Props { message: DecryptedMessage; mine: boolean; groupedWithPrev: boolean; conversationId: string; reactions: AggregatedReaction[]; onToggleReaction: (emoji: string) => Promise; showSeen?: boolean; } export function MessageBubble({ message, mine, groupedWithPrev, conversationId, reactions, onToggleReaction, showSeen = false, }: Props) { const { t } = useTranslation(['app']); const { session, device } = useAuth(); 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 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 canEdit = parsed.kind === 'text' && mine && withinEditWindow && !message.deletedAt && attachments.length === 0; const canDelete = parsed.kind === 'text' && mine && !message.deletedAt; 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 || !device) return; const trimmed = editText.trim(); if (!trimmed || trimmed === bodyText) { setEditing(false); setEditError(null); return; } setBusy(true); setEditError(null); try { const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id); if (!priv) throw new Error('private key not loaded'); await editEncryptedMessage({ client: supabase, messageId: message.id, conversationId, newPlaintext: trimmed, senderUserId: session.user.id, senderDeviceId: device.id, 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, device, 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], ); if (message.deletedAt) { return (
{t('app:chats.deleted')}
); } if (parsed.kind === 'call_event') { return ; } return (
{editing ? (