use-linkedin-connect.ts3.3 KBView on GitHub
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';

import { useTRPC } from '@/providers/query-provider';

/**
 * Shared LinkedIn connect+poll flow, used by both the settings connections card
 * and the inbox connect prompt. Opens Unipile hosted auth in a new tab, then polls
 * `linkedin.accounts` until a newly connected seat appears (Unipile's account-status
 * webhook upserts it server-side). Errors surface via toast.
 *
 * `enabled` gates the accounts query so callers that render the hook conditionally
 * (e.g. only on the LinkedIn inbox filter) don't fetch on unrelated surfaces.
 */
export function useLinkedinConnect({ enabled = true }: { enabled?: boolean } = {}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const [isConnecting, setIsConnecting] = useState(false);
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);

  const accountsQuery = useQuery({ ...trpc.linkedin.accounts.queryOptions(), enabled });
  const accounts = accountsQuery.data;
  const connectedCount = accounts?.filter((a) => a.status === 'connected').length ?? 0;

  const { mutateAsync: startHostedAuth } = useMutation(
    trpc.linkedin.startHostedAuth.mutationOptions(),
  );

  const stopPolling = () => {
    if (pollRef.current) {
      clearInterval(pollRef.current);
      pollRef.current = null;
    }
  };

  // Clear the poll timer if the consumer unmounts mid-connect.
  useEffect(() => stopPolling, []);

  const beginPolling = (startedFrom: number) => {
    stopPolling();
    let ticks = 0;
    pollRef.current = setInterval(async () => {
      ticks += 1;
      const { data } = await accountsQuery.refetch();
      const now = data?.filter((a) => a.status === 'connected').length ?? 0;
      if (now > startedFrom) {
        stopPolling();
        setIsConnecting(false);
        void queryClient.invalidateQueries({ queryKey=[redacted] });
      } else if (ticks >= 40) {
        // ~2 min — give up polling but leave the button ready to re-check. The seat only
        // flips to connected once Unipile's account-status webhook lands, so surface that
        // instead of silently dropping the spinner and leaving the user unsure what happened.
        stopPolling();
        setIsConnecting(false);
        toast.error(
          "LinkedIn is taking longer than expected to finish connecting. If you completed sign-in it should appear shortly — otherwise try Connect again.",
        );
      }
    }, 3000);
  };

  const handleConnect = async () => {
    try {
      setIsConnecting(true);
      const { authUrl } = await startHostedAuth({});
      window.open(authUrl, '_blank', 'noopener,noreferrer');
      beginPolling(connectedCount);
    } catch (error) {
      setIsConnecting(false);
      const msg = error instanceof Error ? error.message : String(error);
      toast.error(
        msg.toLowerCase().includes('not configured')
          ? 'LinkedIn is not available for your workspace yet.'
          : `Failed to start LinkedIn connection: ${msg}`,
      );
    }
  };

  return {
    accounts,
    refetchAccounts: accountsQuery.refetch,
    connectedCount,
    accountsLoaded: accountsQuery.isSuccess,
    isConnecting,
    handleConnect,
  };
}