84 lines
2.9 KiB
TypeScript
84 lines
2.9 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { io } from 'socket.io-client';
|
|
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
|
|
import { useSession } from '@/lib/session';
|
|
|
|
// Server topics mapped to the dashboard queries that show that data
|
|
const TOPIC_QUERIES: Record<string, string[][]> = {
|
|
tabs: [['tabs'], ['reminder-candidates']],
|
|
commands: [['commands']],
|
|
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
|
|
device: [['device']],
|
|
reminders: [['reminder-candidates'], ['reminder-history']],
|
|
users: [['users']],
|
|
stock: [['stock'], ['stock-history']],
|
|
};
|
|
|
|
const RealtimeContext = createContext({ connected: false });
|
|
|
|
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
|
const queryClient = useQueryClient();
|
|
const { venueId } = useSession();
|
|
const [connected, setConnected] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const socket = io(`${new URL(API_URL).origin}/realtime`, {
|
|
// Read on every (re)connect so a refreshed token is used. The venue is only used for a superadmin viewing
|
|
// another venue; everyone else follows their own
|
|
auth: (cb) => cb({ token: getAccessToken(), venueId }),
|
|
transports: ['websocket', 'polling'],
|
|
});
|
|
|
|
let hasConnected = false;
|
|
let refreshing = false;
|
|
let rejections = 0;
|
|
|
|
socket.on('connect', () => {
|
|
setConnected(true);
|
|
rejections = 0;
|
|
// Changes may have been missed while disconnected
|
|
if (hasConnected) queryClient.invalidateQueries();
|
|
hasConnected = true;
|
|
});
|
|
|
|
socket.on('disconnect', () => setConnected(false));
|
|
|
|
socket.on('invalidate', ({ topics }: { topics: string[] }) => {
|
|
for (const topic of topics) {
|
|
for (const queryKey of TOPIC_QUERIES[topic] ?? []) {
|
|
queryClient.invalidateQueries({ queryKey });
|
|
}
|
|
}
|
|
});
|
|
|
|
// The access token expired: refresh it and reconnect (rejected handshakes are not retried
|
|
// automatically). If refreshing fails, the next API call sends the user to the login page.
|
|
socket.on('connect_error', async (error) => {
|
|
if (error.message !== 'unauthorized' || refreshing || ++rejections > 3) return;
|
|
refreshing = true;
|
|
const token = await refreshAccessToken();
|
|
refreshing = false;
|
|
if (token) socket.connect();
|
|
});
|
|
|
|
return () => {
|
|
socket.disconnect();
|
|
};
|
|
}, [queryClient, venueId]);
|
|
|
|
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
|
|
}
|
|
|
|
export function useRealtime() {
|
|
return useContext(RealtimeContext);
|
|
}
|
|
|
|
/** Polling interval to use only while live updates are unavailable. */
|
|
export function usePollingFallback(intervalMs: number): number | false {
|
|
const { connected } = useRealtime();
|
|
return connected ? false : intervalMs;
|
|
}
|