initial
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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<void>;
|
||||
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<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(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,
|
||||
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 (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500">
|
||||
{t('app:chats.deleted')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.kind === 'call_event') {
|
||||
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={
|
||||
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleEditSave();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setEditError(null);
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
autoFocus
|
||||
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
|
||||
/>
|
||||
{editError && (
|
||||
<p className="mt-1.5 break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setEditError(null);
|
||||
}}
|
||||
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
|
||||
>
|
||||
{t('app:friends.action_cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleEditSave()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
|
||||
(mine
|
||||
? 'bg-brand-500/85 text-white'
|
||||
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
|
||||
}
|
||||
>
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic text-neutral-400">…cannot decrypt</span>
|
||||
) : (
|
||||
<>
|
||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
||||
{attachments.map((a) => (
|
||||
<AttachmentImage key={a.id} handle={a} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
'mt-1 flex items-center gap-1.5 text-[10px] ' +
|
||||
(mine ? 'text-brand-100/70' : 'text-neutral-500')
|
||||
}
|
||||
>
|
||||
<span>{time}</span>
|
||||
{message.editedAt && !message.deletedAt && (
|
||||
<span className="italic">· {t('app:chats.edited')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSeen && mine && !editing && !message.deletedAt && (
|
||||
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
|
||||
{t('app:chats.seen')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reactions.length > 0 && !editing && (
|
||||
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
{reactions.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
className={
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(r.mine
|
||||
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
|
||||
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
|
||||
}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
<span className="text-[10px] font-medium">{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!editing && (
|
||||
<div
|
||||
className={
|
||||
'pointer-events-none absolute top-0 z-20 opacity-0 transition group-hover:pointer-events-auto group-hover:opacity-100 ' +
|
||||
(mine ? 'right-full pr-2' : 'left-full pl-2')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 bg-ink-900/90 p-1 shadow-lg backdrop-blur-xl">
|
||||
<ActionButton
|
||||
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
icon={<SmileIcon className="h-4 w-4" />}
|
||||
/>
|
||||
{canEdit && (
|
||||
<ActionButton
|
||||
label="Edit"
|
||||
onClick={() => {
|
||||
setEditText(message.plaintext ?? '');
|
||||
setEditing(true);
|
||||
}}
|
||||
icon={<PencilIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ActionButton
|
||||
label="Delete"
|
||||
onClick={() => void handleDelete()}
|
||||
icon={<TrashIcon className="h-4 w-4" />}
|
||||
tone="danger"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pickerOpen && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
role="menu"
|
||||
className={
|
||||
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
|
||||
(mine ? 'right-0' : 'left-0')
|
||||
}
|
||||
>
|
||||
{EMOJI_CHOICES.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => void handlePickEmoji(e)}
|
||||
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CallEventRow({
|
||||
parsed,
|
||||
mine,
|
||||
time,
|
||||
}: {
|
||||
parsed: { status: string; mediaKind: string; durationSec: number };
|
||||
mine: boolean;
|
||||
time: string;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const status = parsed.status;
|
||||
const isMissed = status === 'missed' || status === 'declined';
|
||||
|
||||
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
||||
const tone = isMissed
|
||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
|
||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
|
||||
|
||||
const label =
|
||||
status === 'ended'
|
||||
? mine
|
||||
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
|
||||
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
||||
: status === 'missed'
|
||||
? mine
|
||||
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
||||
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
||||
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
||||
|
||||
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
|
||||
|
||||
return (
|
||||
<div className="my-2 flex justify-center">
|
||||
<div
|
||||
className={
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium ' + tone
|
||||
}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{label}</span>
|
||||
{duration && <span className="font-mono text-[11px] opacity-80">· {duration}</span>}
|
||||
<span className="text-[10px] opacity-60">· {time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(totalSec: number): string {
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
if (m === 0) return s + 's';
|
||||
return m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
tone?: 'danger';
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(tone === 'danger'
|
||||
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
|
||||
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user