feat: reply + search + forward + archive/mute + error boundary
Messages: - Reply-to: hover action, composer chip with cancel, quote bubble inside the replying message with tap-to-jump + amber highlight ring - Search: header search button toggles in-conversation search bar with prev/next + match counter, auto-jump to active match - Forward: multi-select conversation picker. Attachments are now carried over: download + decrypt source, re-encrypt under each target conv-key, re-upload with fresh per-attachment keys, insert new attachment rows Conversations: - Archive + mute per member. New migration 20260420000001 adds `archived` + `muted_until` on conversation_members. Shared helpers: setConversationArchived / setConversationMutedUntil / isConversationMuted - ChatsPage: archive toggle in header with unread badge for archived bucket, split active/archived lists, muted indicator (BellOff icon, dimmed unread badge) - ConversationRowMenu via createPortal (escapes sidebar overflow clip), forwardRef-based MenuItem so submenu positioning refs survive React 18 - ConversationsContext: suppresses notification sound + OS notif when target conversation is muted - Refresh on `profiles UPDATE` realtime so peer avatar / displayName changes flow to conversation.members without manual refresh Resilience: - ErrorBoundary (Discord-style): centred spinner + escalating copy, no manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s]. Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive - App wrapped root + per-route RouteBoundary, conversation-level boundary - AuthContext: flip `ready` immediately on cached session read; validate getUser in background so a stalled/offline Supabase doesn't freeze the app on the loading spinner Crypto: - Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was missing crypto_pwhash so Argon2id vault KDF threw, falling back to plaintext localStorage on every launch) - Shim d.ts for sumo types (sumo is API superset, no official types ship) - vite optimizeDeps includes sumo with the "require" condition - secureFileStore: exists(dir) check before mkdir; surface genuine permission errors instead of silent catch Tauri: - fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app data directory itself can be mkdir'd on first launch Chat layout: - Skip call_event messages when computing avatar run boundaries so a regular bubble followed by a call event from the same sender still shows its avatar
This commit is contained in:
@@ -15,11 +15,19 @@ import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
import { ForwardIcon, 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;
|
||||
@@ -32,6 +40,16 @@ interface Props {
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
showSeen?: boolean;
|
||||
/** 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;
|
||||
/** Highlighted state — set briefly after a jump. */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -45,6 +63,11 @@ export function MessageBubble({
|
||||
reactions,
|
||||
onToggleReaction,
|
||||
showSeen = false,
|
||||
quoted = null,
|
||||
onJumpToMessage,
|
||||
onReply,
|
||||
onForward,
|
||||
highlighted = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session, device } = useAuth();
|
||||
@@ -228,13 +251,46 @@ export function MessageBubble({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-message-id={message.id}
|
||||
className={
|
||||
'break-words px-3.5 py-2 text-sm ' +
|
||||
'break-words px-3.5 py-2 text-sm transition ' +
|
||||
(highlighted ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-surface-3 ' : '') +
|
||||
(mine
|
||||
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
|
||||
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
|
||||
}
|
||||
>
|
||||
{quoted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpToMessage?.(quoted.id)}
|
||||
className={
|
||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
|
||||
(mine
|
||||
? 'bg-white/15 text-accent-fg/90'
|
||||
: 'bg-surface-2 text-fg-muted')
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
|
||||
{quoted.senderName}
|
||||
</span>
|
||||
<span className="block truncate italic opacity-90">
|
||||
{quoted.deleted
|
||||
? t('app:chats.deleted')
|
||||
: quoted.isAttachment && !quoted.snippet
|
||||
? '📎 ' + t('app:chats.attachment', { defaultValue: 'Anhang' })
|
||||
: quoted.snippet}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
) : (
|
||||
@@ -299,6 +355,20 @@ export function MessageBubble({
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
icon={<SmileIcon className="h-4 w-4" />}
|
||||
/>
|
||||
{onReply && !message.deletedAt && (
|
||||
<ActionButton
|
||||
label={t('app:chats.reply', { defaultValue: 'Antworten' })}
|
||||
onClick={() => onReply(message)}
|
||||
icon={<ReplyIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{onForward && !message.deletedAt && (
|
||||
<ActionButton
|
||||
label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||
onClick={() => onForward(message)}
|
||||
icon={<ForwardIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<ActionButton
|
||||
label="Edit"
|
||||
|
||||
Reference in New Issue
Block a user