b1f37752d6
Audit of write-actions revealed that send (and edit via realtime UPDATE) are already optimistic via local state insertion in `useConversationMessages`, and friend nicknames are pure-local localStorage. Five user-write actions were waiting on the ~100-200 ms server roundtrip + realtime echo: * Toggle mute (`setConversationMutedUntil`) * Toggle mentions-only (`setConversationMentionsOnly`) * Toggle archive (`setConversationArchived`) * Pin / unpin message (`pinMessage` / `unpinMessage`) * Revoke device (`revokeDevice` RPC) All five now flip local state synchronously and roll back on failure. The existing realtime subscriptions reconcile canonically (no-op when the optimistic patch already matches the server row), so this is purely a UX latency improvement — no protocol or persistence changes. Reactions (`toggleReaction` / `voteExclusive`) were intentionally skipped this round: rollback semantics for the exclusive-vote path with multiple sequential awaits are messy enough to warrant a dedicated pass.
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import {
|
||
muteDurationToIso,
|
||
setConversationArchived,
|
||
setConversationMentionsOnly,
|
||
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 { useConversationsContext } from '../context/ConversationsContext';
|
||
import { supabase } from '../lib/supabase';
|
||
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||
|
||
interface Props {
|
||
conversationId: string;
|
||
archived: boolean;
|
||
mutedUntil: string | null;
|
||
mentionsOnly: boolean;
|
||
}
|
||
|
||
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, mentionsOnly }: Props) {
|
||
const { t } = useTranslation(['app']);
|
||
const { patchConversation } = useConversationsContext();
|
||
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();
|
||
|
||
// Optimistic updates: flip the local state before the server RPC so the
|
||
// bell / archive icon / checkmark update on the same frame as the click.
|
||
// Realtime echo via ConversationsContext will reconcile (no-op since the
|
||
// optimistic patch already matches the server row). On error we restore
|
||
// the previous value so the menu doesn't lie about persisted state.
|
||
const handleArchive = useCallback(
|
||
async (next: boolean) => {
|
||
setOpen(false);
|
||
const previous = archived;
|
||
patchConversation(conversationId, { archived: next });
|
||
try {
|
||
await setConversationArchived(supabase, conversationId, next);
|
||
} catch (err: unknown) {
|
||
patchConversation(conversationId, { archived: previous });
|
||
console.error('archive toggle failed', err);
|
||
}
|
||
},
|
||
[conversationId, archived, patchConversation],
|
||
);
|
||
|
||
const handleMute = useCallback(
|
||
async (minutes: number | null) => {
|
||
setOpen(false);
|
||
setSubmenuOpen(null);
|
||
const previous = mutedUntil;
|
||
const nextIso = muteDurationToIso(minutes);
|
||
patchConversation(conversationId, { mutedUntil: nextIso });
|
||
try {
|
||
await setConversationMutedUntil(supabase, conversationId, nextIso);
|
||
} catch (err: unknown) {
|
||
patchConversation(conversationId, { mutedUntil: previous });
|
||
console.error('mute toggle failed', err);
|
||
}
|
||
},
|
||
[conversationId, mutedUntil, patchConversation],
|
||
);
|
||
|
||
const handleMentionsOnly = useCallback(
|
||
async (next: boolean) => {
|
||
setOpen(false);
|
||
const previous = mentionsOnly;
|
||
patchConversation(conversationId, { mentionsOnly: next });
|
||
try {
|
||
await setConversationMentionsOnly(supabase, {
|
||
conversationId,
|
||
mentionsOnly: next,
|
||
});
|
||
} catch (err: unknown) {
|
||
patchConversation(conversationId, { mentionsOnly: previous });
|
||
console.warn('mentions-only toggle failed', err);
|
||
}
|
||
},
|
||
[conversationId, mentionsOnly, patchConversation],
|
||
);
|
||
|
||
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 dark:hover:bg-[#383a40]"
|
||
>
|
||
<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 dark:bg-[#313338]"
|
||
>
|
||
<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}
|
||
/>
|
||
<MenuItem
|
||
icon={<AtIcon className="h-4 w-4" />}
|
||
label={
|
||
(mentionsOnly ? '✓ ' : '') +
|
||
t('app:chats.mentions_only', {
|
||
defaultValue: 'Nur bei @Mentions benachrichtigen',
|
||
})
|
||
}
|
||
onClick={() => void handleMentionsOnly(!mentionsOnly)}
|
||
/>
|
||
</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 dark:bg-[#313338]"
|
||
>
|
||
{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 dark:hover:bg-[#383a40]"
|
||
>
|
||
{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';
|