use-linkedin-sync-on-load.ts4.0 KBView on GitHub import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { INBOX_REFRESH_EVENT } from '@/modules/inbox/hooks/use-inbox-items';
/** Don't re-pull Unipile more than once per this window (billable / rate-limited). */
const RESYNC_THROTTLE_MS = 60 * 1000;
/**
* Best-effort live reconcile for LinkedIn — the channel analogue of email's
* on-load sync. Fires `messaging.sync` + `drain` (fire-and-forget; errors
* swallowed so they never block the inbox), then calls `onSynced` to refetch
* the feed. Runs on mount AND when the tab regains focus (throttled), so a feed
* left open doesn't go stale — the mirror is only refreshed by this sync, so a
* plain refetch alone would never surface newly-received LinkedIn messages.
*
* NOTE: this triggers a Unipile pull (billable / rate-limited) and is not
* verifiable without a live LinkedIn seat. See omni-channel-inbox.md Phase 5.
*/
export function useLinkedinSyncOnLoad(opts: { enabled: boolean; onSynced: () => void }) {
const trpc = useTRPC();
const accountsQuery = useQuery({
...trpc.outbound.linkedin.accounts.queryOptions(),
enabled: opts.enabled,
});
const sync = useMutation(trpc.outbound.linkedin.messaging.sync.mutationOptions());
const drain = useMutation(trpc.outbound.linkedin.messaging.drain.mutationOptions());
const lastSyncedAt = useRef(0);
const inFlight = useRef(false);
// Keep the latest onSynced without retriggering the focus listener effect.
const onSyncedRef = useRef(opts.onSynced);
onSyncedRef.current = opts.onSynced;
const runSync = useCallback(
(force = false) => {
if (!opts.enabled || inFlight.current) return;
if (!force && Date.now() - lastSyncedAt.current < RESYNC_THROTTLE_MS) return;
const accounts = accountsQuery.data as
| Array<{ unipileAccountId?: string; status?: string }>
| undefined;
if (!accounts) return; // wait for the account list to resolve
// Prefer the CONNECTED seat (matches LinkedInInbox); fall back to the first.
const seat = accounts.find((a) => a.status === 'connected') ?? accounts[0];
const unipileAccountId = seat?.unipileAccountId;
if (!unipileAccountId) return;
inFlight.current = true;
lastSyncedAt.current = Date.now();
void (async () => {
try {
await sync.mutateAsync({ unipileAccountId, maxChats: 50, messagesPerChat: 15 });
await drain.mutateAsync();
onSyncedRef.current();
} catch (err) {
// Don't block the inbox, but make failures diagnosable (it's a live
// Unipile call that can fail on creds / rate limits).
// eslint-disable-next-line no-console
console.warn('[inbox] LinkedIn sync failed', err);
} finally {
inFlight.current = false;
}
})();
},
// sync/drain mutation objects are stable; accountsQuery.data drives eligibility.
// eslint-disable-next-line react-hooks/exhaustive-deps
[opts.enabled, accountsQuery.data],
);
// Initial sync once the account list resolves (force past the throttle).
useEffect(() => {
if (opts.enabled && lastSyncedAt.current === 0) runSync(true);
}, [opts.enabled, runSync]);
// Re-sync when the tab regains focus (throttled) or the user hits Refresh (forced),
// so an open feed surfaces new messages.
useEffect(() => {
if (!opts.enabled) return;
const onFocus = () => runSync();
const onVisible = () => {
if (document.visibilityState === 'visible') runSync();
};
const onRefresh = () => runSync(true);
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVisible);
window.addEventListener(INBOX_REFRESH_EVENT, onRefresh);
return () => {
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener(INBOX_REFRESH_EVENT, onRefresh);
};
}, [opts.enabled, runSync]);
}