perf(P6C.T12): optimistic UI for mute / mentions-only / archive / pin / device-revoke

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.
This commit is contained in:
byGalax
2026-05-17 00:44:28 +02:00
parent 854c4b91a8
commit b1f37752d6
5 changed files with 143 additions and 14 deletions
@@ -8,6 +8,7 @@ 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';
@@ -54,6 +55,7 @@ interface MenuPos {
// 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);
@@ -122,44 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil, ment
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],
[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, muteDurationToIso(minutes));
await setConversationMutedUntil(supabase, conversationId, nextIso);
} catch (err: unknown) {
patchConversation(conversationId, { mutedUntil: previous });
console.error('mute toggle failed', err);
}
},
[conversationId],
[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],
[conversationId, mentionsOnly, patchConversation],
);
return (