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:
@@ -28,9 +28,10 @@ interface Props {
|
||||
conversation: ConversationSummary | null;
|
||||
peerPresence: PresenceState | null;
|
||||
onInfoClick?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
|
||||
if (!conversation) {
|
||||
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
}
|
||||
@@ -41,6 +42,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(onInfoClick ? { onInfoClick } : {})}
|
||||
{...(onSearchClick ? { onSearchClick } : {})}
|
||||
/>
|
||||
<ActiveCallBanner conversationId={conversation.id} />
|
||||
</>
|
||||
@@ -51,9 +53,10 @@ interface HeaderBarProps {
|
||||
conversation: ConversationSummary;
|
||||
peerPresence: PresenceState | null;
|
||||
onInfoClick?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) {
|
||||
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
const isDm = conversation.type === 'dm';
|
||||
@@ -108,7 +111,11 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HeaderActionButton label={t('app:chats.search', { defaultValue: 'Suche' })} icon={SearchIcon} />
|
||||
<HeaderActionButton
|
||||
label={t('app:chats.search', { defaultValue: 'Suche' })}
|
||||
icon={SearchIcon}
|
||||
{...(onSearchClick ? { onClick: onSearchClick } : {})}
|
||||
/>
|
||||
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
||||
{!isDm && onInfoClick && (
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
muteDurationToIso,
|
||||
setConversationArchived,
|
||||
setConversationMutedUntil,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
archived: boolean;
|
||||
mutedUntil: string | null;
|
||||
}
|
||||
|
||||
interface MuteOption {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
labelDefault: string;
|
||||
minutes: number | null;
|
||||
}
|
||||
|
||||
// Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted
|
||||
// until that moment; 100y is indistinguishable from "forever" at the UX level
|
||||
// without requiring a dedicated `bool muted_forever` column.
|
||||
const FOREVER_MINUTES = 100 * 365 * 24 * 60;
|
||||
|
||||
const MUTE_OPTIONS: MuteOption[] = [
|
||||
{ key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 },
|
||||
{ key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 },
|
||||
{ key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 },
|
||||
{ key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 },
|
||||
{
|
||||
key: 'forever',
|
||||
labelKey: 'app:chats.mute_forever',
|
||||
labelDefault: 'Bis auf Weiteres',
|
||||
minutes: FOREVER_MINUTES,
|
||||
},
|
||||
];
|
||||
|
||||
interface MenuPos {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
// Per-conversation row context-menu. Renders via portal so the submenu can
|
||||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||
const [submenuPos, setSubmenuPos] = useState<MenuPos | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const muteItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (triggerRef.current?.contains(target)) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
}
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onEsc);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMenuPos(null);
|
||||
setSubmenuPos(null);
|
||||
return;
|
||||
}
|
||||
const rect = triggerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Anchor: right edge aligns with trigger's right edge, menu hangs below.
|
||||
const menuWidth = 208;
|
||||
setMenuPos({
|
||||
top: rect.bottom + 4,
|
||||
left: Math.max(8, rect.right - menuWidth),
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submenuOpen !== 'mute') {
|
||||
setSubmenuPos(null);
|
||||
return;
|
||||
}
|
||||
const rect = muteItemRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const submenuWidth = 192;
|
||||
const viewportWidth = window.innerWidth;
|
||||
// Prefer right of the item. Flip to left when it would overflow viewport.
|
||||
const wantLeft = rect.right + 4;
|
||||
const flip = wantLeft + submenuWidth > viewportWidth - 8;
|
||||
setSubmenuPos({
|
||||
top: rect.top,
|
||||
left: flip ? rect.left - submenuWidth - 4 : wantLeft,
|
||||
});
|
||||
}, [submenuOpen]);
|
||||
|
||||
const isMuted =
|
||||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await setConversationArchived(supabase, conversationId, next);
|
||||
} catch (err: unknown) {
|
||||
console.error('archive toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const handleMute = useCallback(
|
||||
async (minutes: number | null) => {
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
try {
|
||||
await setConversationMutedUntil(
|
||||
supabase,
|
||||
conversationId,
|
||||
muteDurationToIso(minutes),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error('mute toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={t('app:chats.row_menu', { defaultValue: 'Aktionen' })}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
menuPos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{ top: menuPos.top, left: menuPos.left }}
|
||||
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||
>
|
||||
<MenuItem
|
||||
icon={<ArchiveIcon className="h-4 w-4" />}
|
||||
label={
|
||||
archived
|
||||
? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' })
|
||||
: t('app:chats.archive', { defaultValue: 'Archivieren' })
|
||||
}
|
||||
onClick={() => void handleArchive(!archived)}
|
||||
/>
|
||||
<MenuItem
|
||||
ref={muteItemRef}
|
||||
icon={
|
||||
isMuted ? (
|
||||
<BellOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<BellIcon className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
label={
|
||||
isMuted
|
||||
? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' })
|
||||
: t('app:chats.mute', { defaultValue: 'Stummschalten' })
|
||||
}
|
||||
onClick={() => {
|
||||
if (isMuted) void handleMute(null);
|
||||
else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute'));
|
||||
}}
|
||||
hasSubmenu={!isMuted}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{open &&
|
||||
submenuOpen === 'mute' &&
|
||||
submenuPos &&
|
||||
createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
style={{ top: submenuPos.top, left: submenuPos.left }}
|
||||
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||
>
|
||||
{MUTE_OPTIONS.map((opt) => (
|
||||
<MenuItem
|
||||
key={opt.key}
|
||||
label={t(opt.labelKey, { defaultValue: opt.labelDefault })}
|
||||
onClick={() => void handleMute(opt.minutes)}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
hasSubmenu?: boolean;
|
||||
}
|
||||
|
||||
// React 18 requires forwardRef for function components to receive refs —
|
||||
// without it the `ref` prop is stripped before reaching the component and
|
||||
// measurement-dependent submenus never position.
|
||||
const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
||||
({ icon, label, onClick, hasSubmenu }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{icon && <span className="shrink-0 text-fg-muted">{icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{hasSubmenu && <span className="shrink-0 text-xs text-fg-muted">›</span>}
|
||||
</button>
|
||||
),
|
||||
);
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react';
|
||||
|
||||
import { SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Optional scope label shown in logs / devtools. Defaults to `root` — set
|
||||
* per boundary (e.g. `route`, `conversation`) so multiple boundaries can be
|
||||
* distinguished at a glance.
|
||||
*/
|
||||
scope?: string;
|
||||
/**
|
||||
* If the retry count exceeds this, the boundary stops auto-retrying and
|
||||
* shows a more helpful message (still without a button — Discord-style,
|
||||
* the app keeps trying but hints the user to hold on or check network).
|
||||
*/
|
||||
maxAutoRetries?: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
retryKey: number;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000];
|
||||
|
||||
// Discord-style error boundary.
|
||||
// - Catches render-time errors in its subtree.
|
||||
// - Shows a centred spinner + status text. Never renders a manual "Reload"
|
||||
// button; the boundary remounts its children on an exponential-backoff
|
||||
// schedule so the UI self-heals once the underlying issue clears (typical
|
||||
// causes: a realtime reconnect, a transient network blip, or a race that
|
||||
// only fires once).
|
||||
// - Escalates the label after each failed retry so the user sees that the
|
||||
// app is trying, rather than silent infinite spinning.
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null, retryKey: 0, attempt: 0 };
|
||||
|
||||
private retryTimer: number | null = null;
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
const scope = this.props.scope ?? 'root';
|
||||
// We explicitly log here — the boundary itself swallows the error from
|
||||
// React, so without this the failure would be invisible in production.
|
||||
console.error('[ErrorBoundary:' + scope + '] caught render error', error, info);
|
||||
}
|
||||
|
||||
override componentDidUpdate(_prev: Props, prevState: State): void {
|
||||
if (this.state.error && !prevState.error) {
|
||||
this.scheduleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
if (this.retryTimer !== null) {
|
||||
window.clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer !== null) return;
|
||||
const attempt = this.state.attempt;
|
||||
const delay =
|
||||
RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ??
|
||||
RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ??
|
||||
30000;
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.setState((prev) => ({
|
||||
error: null,
|
||||
retryKey: prev.retryKey + 1,
|
||||
attempt: prev.attempt + 1,
|
||||
}));
|
||||
}, delay);
|
||||
}
|
||||
|
||||
override render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length;
|
||||
const escalated = this.state.attempt >= max;
|
||||
return <RetryingScreen escalated={escalated} attempt={this.state.attempt} />;
|
||||
}
|
||||
// `retryKey` forces a remount of the subtree so hooks re-run cleanly after
|
||||
// an error (otherwise stale state from the crashed tree can immediately
|
||||
// re-throw). Use a keyed Fragment so the boundary doesn't inject an extra
|
||||
// wrapper div — that would break `flex h-full` chains (e.g. AppShell →
|
||||
// Outlet → page column).
|
||||
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) {
|
||||
const primary = escalated
|
||||
? 'Verbindungsprobleme…'
|
||||
: attempt === 0
|
||||
? 'Einen Moment bitte'
|
||||
: 'Versuche erneut zu laden…';
|
||||
const secondary = escalated
|
||||
? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.'
|
||||
: 'Die App lädt sich gleich selbst neu.';
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex min-h-screen w-full items-center justify-center bg-surface-3 px-6"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full border-2 border-accent/20"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 rounded-full border-2 border-accent border-r-transparent border-b-transparent animate-spin"
|
||||
/>
|
||||
<SpinnerIcon className="hidden" />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-1.5">
|
||||
<p className="font-display text-lg font-semibold text-fg">{primary}</p>
|
||||
<p className="text-sm text-fg-muted">{secondary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
downloadAndDecryptAttachment,
|
||||
encryptAndUploadAttachment,
|
||||
insertAttachmentRow,
|
||||
parseMessagePayload,
|
||||
sendEncryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { bytesToPgHex } from '@chat-app/shared/supabase';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
message: DecryptedMessage | null;
|
||||
currentConversationId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Forwards a message's plaintext to one or more conversations. Attachments are
|
||||
// NOT carried over yet (would require re-uploading + re-encrypting under the
|
||||
// new conversation key); only the text payload is forwarded for now and the
|
||||
// preview hints at the dropped attachment.
|
||||
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { session, device } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelected(new Set());
|
||||
setError(null);
|
||||
setDone(false);
|
||||
}, [open, message?.id]);
|
||||
|
||||
const targets = useMemo(() => {
|
||||
return conversations
|
||||
.filter((c) => c.id !== currentConversationId && c.acceptedByMe)
|
||||
.sort((a, b) => {
|
||||
const ta = a.lastMessageAt ?? a.createdAt;
|
||||
const tb = b.lastMessageAt ?? b.createdAt;
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
}, [conversations, currentConversationId]);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!message?.plaintext) return '';
|
||||
const p = parseMessagePayload(message.plaintext);
|
||||
if (p.kind !== 'text') return '';
|
||||
return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text;
|
||||
}, [message]);
|
||||
|
||||
const sourceAttachments = useMemo<AttachmentHandle[]>(() => {
|
||||
if (!message?.plaintext) return [];
|
||||
const p = parseMessagePayload(message.plaintext);
|
||||
return p.kind === 'text' ? p.attachments : [];
|
||||
}, [message]);
|
||||
|
||||
if (!open || !message) return null;
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!session?.user.id || !device?.id || !message) return;
|
||||
if (selected.size === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
|
||||
const hasAttachments = sourceAttachments.length > 0;
|
||||
const text = preview || (hasAttachments ? '' : '');
|
||||
if (!text && !hasAttachments) throw new Error('nothing to forward');
|
||||
|
||||
// Download+decrypt source attachments ONCE (same plaintext goes to every
|
||||
// target). For each target conv we re-encrypt under fresh per-attachment
|
||||
// keys and re-upload under the target conv's storage folder — source and
|
||||
// target conv-keys differ, so the bytes must actually move.
|
||||
const decryptedBlobs: { mime: string; size: number; width?: number; height?: number; blob: Blob }[] =
|
||||
[];
|
||||
for (const h of sourceAttachments) {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle: h });
|
||||
const entry: {
|
||||
mime: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
blob: Blob;
|
||||
} = { mime: h.mimeType, size: h.sizeBytes, blob };
|
||||
if (h.width !== undefined) entry.width = h.width;
|
||||
if (h.height !== undefined) entry.height = h.height;
|
||||
decryptedBlobs.push(entry);
|
||||
}
|
||||
|
||||
for (const convId of selected) {
|
||||
const newHandles: AttachmentHandle[] = [];
|
||||
const blobNonceHex = new Map<string, string>();
|
||||
for (const src of decryptedBlobs) {
|
||||
const res = await encryptAndUploadAttachment({
|
||||
client: supabase,
|
||||
conversationId: convId,
|
||||
file: src.blob,
|
||||
mimeType: src.mime,
|
||||
sizeBytes: src.size,
|
||||
...(src.width !== undefined ? { width: src.width } : {}),
|
||||
...(src.height !== undefined ? { height: src.height } : {}),
|
||||
});
|
||||
newHandles.push(res.handle);
|
||||
blobNonceHex.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: convId,
|
||||
plaintext: text,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: priv,
|
||||
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||
});
|
||||
|
||||
for (const h of newHandles) {
|
||||
const bn = blobNonceHex.get(h.id) ?? '\\x';
|
||||
await insertAttachmentRow(supabase, msg.id, h, bn);
|
||||
}
|
||||
}
|
||||
setDone(true);
|
||||
window.setTimeout(onClose, 700);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
setError(
|
||||
code
|
||||
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('errors:generic'),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="border-b border-line bg-surface-2 px-5 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-3 break-words text-sm text-fg">
|
||||
{preview || (sourceAttachments.length > 0 ? '📎' : '…')}
|
||||
</p>
|
||||
{sourceAttachments.length > 0 && (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">
|
||||
📎{' '}
|
||||
{t('app:chats.forward_attachments_count', {
|
||||
count: sourceAttachments.length,
|
||||
defaultValue: '{{count}} Anhang wird mit weitergeleitet',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{targets.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{t('app:chats.forward_no_targets', {
|
||||
defaultValue: 'Keine anderen Unterhaltungen verfügbar.',
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{targets.map((c) => {
|
||||
const isGroup = c.type === 'group';
|
||||
const title = isGroup ? c.name ?? '?' : c.peer?.displayName ?? '?';
|
||||
const avatarUrl = isGroup ? c.avatarUrl ?? null : c.peer?.avatarUrl ?? null;
|
||||
const checked = selected.has(c.id);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(c.id)}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(checked ? 'bg-accent/15' : 'hover:bg-surface-2')
|
||||
}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={title}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
) : isGroup ? (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
url={null}
|
||||
displayName={title}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded border ' +
|
||||
(checked ? 'border-accent bg-accent text-accent-fg' : 'border-line bg-surface-2')
|
||||
}
|
||||
>
|
||||
{checked && (
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="border-t border-rose-500/30 bg-rose-500/10 px-5 py-2 text-xs text-rose-700 dark:text-rose-200"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || selected.size === 0 || done}
|
||||
onClick={() => void handleSend()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>
|
||||
{done
|
||||
? t('app:chats.forward_done', { defaultValue: 'Gesendet' })
|
||||
: t('app:chats.forward_send', {
|
||||
count: selected.size,
|
||||
defaultValue: 'An {{count}} senden',
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -409,6 +409,74 @@ export function SendIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchiveIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="5" rx="1" />
|
||||
<path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9" />
|
||||
<path d="M10 13h4" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
|
||||
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellOffIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||
<path d="M13.73 4A2 2 0 0 0 10 4" />
|
||||
<path d="M18 8c0 1-.1 1.9-.2 2.7" />
|
||||
<path d="M4 4 20 20" />
|
||||
<path d="M6 8a6 6 0 0 1 .2-1.7" />
|
||||
<path d="M4 18h13.5L18 17.4c.5-1 2-2 2-6" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoreVerticalIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="5" r="1.5" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="19" r="1.5" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplyIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="9 17 4 12 9 7" />
|
||||
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForwardIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="15 17 20 12 15 7" />
|
||||
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronUpIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddUserIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
|
||||
Reference in New Issue
Block a user