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
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
refresh: () => Promise<void>;
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<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
) => void;
}
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
[markRead],
);
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
(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,
],
);