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:
@@ -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';
|
||||
Reference in New Issue
Block a user