b1f37752d6
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.
102 lines
3.0 KiB
TypeScript
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 };
|
|
}
|