Files
ChatApp/apps/desktop/src/hooks/useOwnDevices.ts
T
byGalax b1f37752d6 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.
2026-05-17 00:44:28 +02:00

102 lines
3.0 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
interface State {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
}
export function useOwnDevices(): {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
revoke: (deviceId: string) => Promise<void>;
} {
const { session } = useAuth();
const userId = session?.user.id ?? null;
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
const refresh = useCallback(async () => {
try {
setState((s) => ({ ...s, loading: true, error: null }));
const list = await listOwnDevices(supabase);
setState({ devices: list, loading: false, error: null });
} catch (err) {
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'failed to load devices',
}));
}
}, []);
useEffect(() => {
if (!userId) {
setState({ devices: [], loading: false, error: null });
return;
}
void refresh();
const channel = supabase
.channel('devices:list:' + userId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId, refresh]);
const revoke = useCallback(
async (deviceId: string) => {
// 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;
}
},
[],
);
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
}