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
+13 -3
View File
@@ -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(