Files
ChatApp/apps/desktop/src/components/ConversationRowMenu.tsx
T
byGalax de431386ea 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
2026-04-20 15:42:49 +02:00

265 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';