From b1f37752d6d1348bac1615c20eb77be5fc5b83ed Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 00:44:28 +0200 Subject: [PATCH] perf(P6C.T12): optimistic UI for mute / mentions-only / archive / pin / device-revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/ConversationRowMenu.tsx | 25 ++++++-- .../src/context/ConversationsContext.tsx | 24 ++++++++ apps/desktop/src/hooks/useOwnDevices.ts | 33 ++++++++++- apps/desktop/src/lib/usePinnedMessages.ts | 59 +++++++++++++++++-- apps/desktop/src/pages/ConversationPage.tsx | 16 ++++- 5 files changed, 143 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/ConversationRowMenu.tsx b/apps/desktop/src/components/ConversationRowMenu.tsx index ef6cec6..1ff8fd2 100644 --- a/apps/desktop/src/components/ConversationRowMenu.tsx +++ b/apps/desktop/src/components/ConversationRowMenu.tsx @@ -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(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 ( diff --git a/apps/desktop/src/context/ConversationsContext.tsx b/apps/desktop/src/context/ConversationsContext.tsx index cee2fe6..7cd4922 100644 --- a/apps/desktop/src/context/ConversationsContext.tsx +++ b/apps/desktop/src/context/ConversationsContext.tsx @@ -54,6 +54,15 @@ interface ConversationsContextValue { refresh: () => Promise; markRead: (conversationId: string) => void; setActiveConversation: (conversationId: string | null) => void; + // Optimistic patch for the caller's per-membership preferences (mute / + // mentions-only / archive). Mutations to `conversation_members` echo back + // via the realtime channel and `refresh()` reconciles canonically, but the + // ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch + // immediately, snapshot the previous state, and roll back on failure. + patchConversation: ( + conversationId: string, + patch: Partial>, + ) => void; } const ConversationsContext = createContext(null); @@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) { [markRead], ); + const patchConversation = useCallback( + (convId, patch) => { + setConversations((prev) => { + const idx = prev.findIndex((c) => c.id === convId); + if (idx === -1) return prev; + const next = [...prev]; + next[idx] = { ...next[idx]!, ...patch }; + return next; + }); + }, + [], + ); + useEffect(() => { if (!myId) { setConversations([]); @@ -308,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) { refresh, markRead, setActiveConversation, + patchConversation, }), [ conversations, @@ -318,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) { refresh, markRead, setActiveConversation, + patchConversation, ], ); diff --git a/apps/desktop/src/hooks/useOwnDevices.ts b/apps/desktop/src/hooks/useOwnDevices.ts index 566b6ab..02580f2 100644 --- a/apps/desktop/src/hooks/useOwnDevices.ts +++ b/apps/desktop/src/hooks/useOwnDevices.ts @@ -64,10 +64,37 @@ export function useOwnDevices(): { const revoke = useCallback( async (deviceId: string) => { - await revokeDevice(supabase, deviceId); - await refresh(); + // Optimistic: flip `revokedAt` on the row so the "Abgemeldet" badge + // appears on the same frame as the click. Capture a snapshot so we + // can restore exactly on RPC failure (the realtime subscription's + // own UPDATE echo would otherwise reconcile back to "not revoked" + // anyway). Skip if the row isn't in our list — nothing to undo. + let snapshot: DeviceRecord[] | null = null; + const stampedAt = new Date().toISOString(); + setState((prev) => { + if (!prev.devices.some((d) => d.id === deviceId)) return prev; + snapshot = prev.devices; + return { + ...prev, + devices: prev.devices.map((d) => + d.id === deviceId ? { ...d, revokedAt: d.revokedAt ?? stampedAt } : d, + ), + }; + }); + try { + await revokeDevice(supabase, deviceId); + // Skip the eager refresh: the realtime UPDATE on `devices` triggers + // refresh() via the subscription and the optimistic row already + // shows the badge. Avoids a list flicker between optimistic and + // canonical state. + } catch (err) { + if (snapshot) { + setState((prev) => ({ ...prev, devices: snapshot! })); + } + throw err; + } }, - [refresh], + [], ); return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke }; diff --git a/apps/desktop/src/lib/usePinnedMessages.ts b/apps/desktop/src/lib/usePinnedMessages.ts index dcb310c..a0b8b68 100644 --- a/apps/desktop/src/lib/usePinnedMessages.ts +++ b/apps/desktop/src/lib/usePinnedMessages.ts @@ -1,12 +1,26 @@ import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { supabase } from './supabase'; +export interface UsePinnedMessagesResult { + pins: PinnedMessage[]; + // Optimistic insert. Caller flips the UI immediately; server insert + + // realtime echo will reconcile (dedup'd by messageId). Returns the + // previous snapshot so the caller can roll back on error. + applyOptimisticPin: (messageId: string, pinnedBy: string) => PinnedMessage[]; + applyOptimisticUnpin: (messageId: string) => PinnedMessage[]; + // Hard restore for rollback after a failed server call. + restorePins: (snapshot: PinnedMessage[]) => void; +} + // Live list of pinned messages for one conversation. Subscribes to the // `pinned_messages` realtime channel for the conv so the header pill + -// side-panel update without a refetch. -export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] { +// side-panel update without a refetch. The `applyOptimistic*` helpers let +// callers flip local state synchronously on user action so the pin button +// doesn't appear unresponsive while the ~100-200ms server roundtrip + the +// realtime refetch round complete. +export function usePinnedMessages(conversationId: string | undefined): UsePinnedMessagesResult { const [pins, setPins] = useState([]); useEffect(() => { @@ -44,5 +58,42 @@ export function usePinnedMessages(conversationId: string | undefined): PinnedMes }; }, [conversationId]); - return pins; + const applyOptimisticPin = useCallback( + (messageId, pinnedBy) => { + if (!conversationId) return pins; + let snapshot: PinnedMessage[] = pins; + setPins((prev) => { + snapshot = prev; + if (prev.some((p) => p.messageId === messageId)) return prev; + const optimistic: PinnedMessage = { + conversationId, + messageId, + pinnedBy, + pinnedAt: new Date().toISOString(), + }; + // Newest first matches the listPinnedMessages order. + return [optimistic, ...prev]; + }); + return snapshot; + }, + [conversationId, pins], + ); + + const applyOptimisticUnpin = useCallback( + (messageId) => { + let snapshot: PinnedMessage[] = pins; + setPins((prev) => { + snapshot = prev; + return prev.filter((p) => p.messageId !== messageId); + }); + return snapshot; + }, + [pins], + ); + + const restorePins = useCallback((snapshot) => { + setPins(snapshot); + }, []); + + return { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins }; } diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 44a2339..2026ccb 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -241,24 +241,34 @@ export function ConversationPage() { // Auto-clears on a successful send so the composer doesn't accidentally // burn the message-after-next. const [viewOnceNext, setViewOnceNext] = useState(false); - const pins = usePinnedMessages(id); + const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id); const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]); + // Optimistic pin/unpin: flip the local list synchronously so the pin badge + // / panel updates on the same frame as the click. Realtime echo via + // usePinnedMessages will refetch and reconcile (no-op since the optimistic + // row matches the server). On error we restore the snapshot so the badge + // doesn't lie about persisted state. const handleTogglePin = useCallback( async (messageId: string) => { if (!id || !myId) return; + const wasPinned = pinnedIds.has(messageId); + const snapshot = wasPinned + ? applyOptimisticUnpin(messageId) + : applyOptimisticPin(messageId, myId); try { - if (pinnedIds.has(messageId)) { + if (wasPinned) { await unpinMessage(supabase, id, messageId); } else { await pinMessage(supabase, id, messageId, myId); } } catch (err) { + restorePins(snapshot); console.warn('pin toggle failed', err); } }, - [id, myId, pinnedIds], + [id, myId, pinnedIds, applyOptimisticPin, applyOptimisticUnpin, restorePins], ); const handleGifPick = useCallback(