use-slack-sync-on-load.ts6.5 KBView on GitHub
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';

import { useTRPC } from '@/providers/query-provider';
import { INBOX_REFRESH_EVENT } from '@/modules/inbox/hooks/use-inbox-items';
import type { InboxItem } from '@/modules/inbox/types';

/** Don't re-pull Slack more than once per this window (rate-limited API). */
const RESYNC_THROTTLE_MS = 60 * 1000;

/**
 * Never catch up more than this many channels in one pass. The visible page is
 * already bounded (`inbox.listChannelItems` defaults to 50), but a rep on "All" with a
 * Slack-heavy feed would otherwise fire 50 sequential Slack API calls on every
 * focus. Rows are feed-ordered, so this keeps the most recently active channels.
 */
const MAX_CONTAINERS_PER_PASS = 20;

/** Messages fetched per channel — matches the on-open catch-up's cheap default. */
const CATCH_UP_LIMIT = 30;

/**
 * Best-effort catch-up for Slack — the channel analogue of email's on-load sync,
 * and the last of the three channels to get one (design:
 * channel-sync-architecture.md Phase 6). Slack having no client-side catch-up is
 * exactly why `catchUpSlackChannel`-on-open had to exist: the feed could only
 * self-heal for a channel the rep had already opened, so a channel whose webhook
 * events were dropped (auth lapse, buffer quarantine) stayed silently behind
 * until someone clicked it.
 *
 * Unlike LinkedIn and WhatsApp there is NO account-wide Slack sync procedure to
 * call — `integrations.slack.syncConversationChannels` needs a conversation id
 * and `channels.reconcile` only re-derives data already stored, never fetching
 * from the provider. Rather than invent a heavy account-wide pull, this walks the
 * Slack containers currently visible in the inbox list and fires the existing
 * per-channel `channels.catchUp` for each. That runs no agent and no model, and
 * every fetcher is idempotent, so a repeat pass over an up-to-date channel is a
 * cheap no-op rather than duplicate ingest.
 *
 * Runs on mount AND when the tab regains focus (throttled), plus forced on
 * Refresh, so a feed left open doesn't go stale. Fire-and-forget: a failure on
 * one channel never fails the pass and never blocks the inbox.
 */
export function useSlackSyncOnLoad(opts: {
  enabled: boolean;
  /** The currently-visible feed rows; only the Slack ones are used. */
  items: InboxItem[];
  onSynced: () => void;
}) {
  const trpc = useTRPC();
  const catchUp = useMutation(trpc.channels.catchUp.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;

  // De-duplicated Slack channel ids from the visible page. A channel can appear
  // once per row, and the same `C…` can back more than one row, so dedupe before
  // spending an API call on it.
  const containerKeys = useMemo(() => {
    const keys: string[] = [];
    const seen = new Set<string>();
    for (const it of opts.items) {
      if (it.channel !== 'slack' || it.ref.kind !== 'slack') continue;
      const key=[redacted];
      if (!key || seen.has(key)) continue;
      seen.add(key);
      keys.push(key);
      if (keys.length >= MAX_CONTAINERS_PER_PASS) break;
    }
    return keys;
  }, [opts.items]);

  // A stable identity for the visible set, so the mount effect below re-arms when
  // the feed finally resolves (the first render has no rows at all) without
  // re-running on every array-identity change from the store.
  const containerSignature = containerKeys.join(',');

  const runSync = useCallback(
    (force = false) => {
      if (!opts.enabled || inFlight.current) return;
      if (!force && Date.now() - lastSyncedAt.current < RESYNC_THROTTLE_MS) return;
      // Nothing visible yet (feed still loading, or no Slack rows on this filter).
      // Deliberately does NOT stamp `lastSyncedAt`, so the initial pass still runs
      // once rows arrive rather than being throttled out by an empty first render.
      if (containerKeys.length === 0) return;

      inFlight.current = true;
      lastSyncedAt.current = Date.now();
      void (async () => {
        let ingested = 0;
        try {
          // Sequential, not parallel: Slack rate-limits per method per workspace,
          // and a burst of 20 concurrent history calls is the fastest way to get
          // the whole pass 429'd.
          for (const containerKey of containerKeys) {
            try {
              const result = await catchUp.mutateAsync({
                channel: 'slack',
                containerKey,
                limit: CATCH_UP_LIMIT,
              });
              ingested += result.ingested;
            } catch (err) {
              // One channel failing (unlinked, revoked token) must not abort the
              // rest of the pass, but keep it diagnosable.
              console.warn('[inbox] Slack catch-up failed', containerKey, err);
            }
          }
          // Only refetch when something actually landed. Unlike the Unipile hooks,
          // this pass is driven BY the visible feed, so in the common (already
          // up-to-date) case an unconditional refetch would churn the list — and
          // the list is this hook's own input — for no new messages.
          if (ingested > 0) onSyncedRef.current();
        } finally {
          inFlight.current = false;
        }
      })();
    },
    // catchUp mutation object is stable; containerSignature drives eligibility.
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [opts.enabled, containerSignature],
  );

  // Initial sync once the feed 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]);
}