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