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]);
}