use-inbox-items.ts21.9 KBView on GitHub
import { useEffect, useMemo, useRef } from 'react';
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
import {
  emailThreadToItem,
  mergedFeedHasMore,
  type ChannelFeedSlice,
} from '@zero/server/inbox/merge';

import { useTRPC } from '@/providers/query-provider';
import { resetListThreadsToFirstPage } from '@/modules/threads/threadList/hooks/use-threads-operations';
import { useActiveConnection } from '@/hooks/use-connections';
import { useCedarStore } from '@/modules/store';
import { selectMergedFeed, type ChannelFeeds } from '@/modules/inbox/store/inboxSlice';
import {
  filterItemPagesToUnread,
  filterThreadPagesToUnread,
  sameRequestApartFromUnread,
  trpcInputFromQueryKey,
  unreadOnlyQueryHash,
  withUnreadOnly,
} from '@/modules/threads/lib/unread-filter';
import type { InboxChannel, InboxChannelFilter, InboxItem } from '@/modules/inbox/types';

/**
 * Fired when the user hits Refresh on the unified inbox. The feed queries are
 * invalidated by the caller; the channel sync-on-load hooks also listen for this
 * to re-pull Unipile (LinkedIn/WhatsApp) so a refresh surfaces new messages.
 */
export const INBOX_REFRESH_EVENT = 'inbox:refresh';


/** The three channels `inbox.listChannelItems` serves. Email comes from `mail.listThreads`. */
type ChatChannel = 'linkedin' | 'whatsapp' | 'slack';

interface FeedQuery {
  /**
   * This channel's window, memoized on the query's own data.
   *
   * Identity is load-bearing, not cosmetic: it is what the composed `channelFeeds` is memoized
   * on, which is what the store commit is keyed on, which is what stops the commit effect from
   * firing on every render and re-rendering itself forever.
   */
  slice: ChannelFeedSlice;
  fetchNextPage: () => Promise<unknown>;
  refetch: () => Promise<unknown>;
  isFetchingNextPage: boolean;
  isPlaceholderData: boolean;
  isError: boolean;
}

interface ChannelScope {
  inboxId?: string;
  compiledQuery?: string;
  queryHash?: string;
  unreadOnly: boolean;
  limit: number;
  enabled: boolean;
  /** Chat only: hide containers that reach no conversation. True for the unified feed. */
  linkedOnly: boolean;
}

/**
 * One chat channel's window, over `inbox.listChannelItems`.
 *
 * Called once per chat channel from the hook below — a fixed number of calls, so the
 * per-channel `enabled` is what decides which ones actually go out rather than a conditional
 * hook. The route resolves the inbox's CRM rule from `inboxId` itself; the rule is never sent.
 */
function useChatChannelFeed(channel: ChatChannel, scope: ChannelScope): FeedQuery {
  const trpc = useTRPC();

  const unfilteredInput = useMemo(
    () => ({
      channel,
      inboxId: scope.inboxId,
      compiledQuery: scope.compiledQuery,
      queryHash: scope.queryHash,
      limit: scope.limit,
      // The unified feed hides containers that reach no deal — a rep's personal WhatsApp line
      // does not belong in a shared inbox. The DEDICATED tab is the one place every container
      // is meant to show, so it asks for them. The route reads it as
      // `conversationLinkedOnly`, which is the same rule the unified feed has always applied.
      linkedOnly: scope.linkedOnly,
    }),
    [channel, scope.inboxId, scope.compiledQuery, scope.queryHash, scope.limit, scope.linkedOnly],
  );
  const input = useMemo(
    () => ({ ...unfilteredInput, unreadOnly: scope.unreadOnly }),
    [unfilteredInput, scope.unreadOnly],
  );

  const query = useInfiniteQuery(
    trpc.inbox.listChannelItems.infiniteQueryOptions(input, {
      enabled: scope.enabled,
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
      // ⇧U is a lens over the rows already on screen, so render their unread subset
      // immediately rather than spinning through a round trip for rows we are holding.
      // Deliberately NOT a blanket keep-previous-data: every other input change — another
      // inbox's rule, a different tab — asks a DIFFERENT question.
      placeholderData: (previousData, previousQuery) => {
        if (!previousData) return undefined;
        const previousInput = trpcInputFromQueryKey(previousQuery?.queryKey);
        if (!sameRequestApartFromUnread(previousInput, input)) return undefined;
        return scope.unreadOnly ? filterItemPagesToUnread(previousData) : previousData;
      },
    }),
  );

  const slice = useMemo<ChannelFeedSlice>(
    () => ({
      items: query.data?.pages.flatMap((page) => page.items) ?? [],
      // A channel that FAILED counts as settled, so one dead channel cannot hold the whole
      // merge unresolved. It contributes no items and bounds nothing, which is exactly how
      // the merge already treats a settled-but-empty channel — see `partial failure` in the
      // hook below for how the failure itself reaches the user.
      settled: query.data !== undefined || query.isError,
      exhausted: !query.hasNextPage,
    }),
    [query.data, query.isError, query.hasNextPage],
  );

  return {
    slice,
    fetchNextPage: query.fetchNextPage,
    refetch: query.refetch,
    isFetchingNextPage: query.isFetchingNextPage,
    isPlaceholderData: query.isPlaceholderData,
    isError: query.isError,
  };
}

/** One `mail.listThreads` row, taken from the shared normalizer so the two cannot drift. */
type EmailThread = Parameters<typeof emailThreadToItem>[0];

/**
 * Flatten `mail.listThreads` pages into feed rows, dropping any thread id already emitted.
 *
 * Page boundaries can transiently overlap when a stale page-N cursor refetches against a
 * freshly-shifted page 1. Two rows with the same id would both survive the merge's sort — it
 * dedupes nothing, by design, because within one channel ids are supposed to be unique — and
 * the rep would see the same thread twice.
 */
function dedupeEmailPages(
  pages: ({ threads?: (EmailThread | null | undefined)[] } | undefined)[] | undefined,
  connectionId: string,
): InboxItem[] {
  if (!pages) return [];
  const seen = new Set<string>();
  const out: InboxItem[] = [];
  for (const page of pages) {
    for (const thread of page?.threads ?? []) {
      if (!thread || seen.has(thread.id)) continue;
      seen.add(thread.id);
      out.push(emailThreadToItem(thread, connectionId));
    }
  }
  return out;
}

/**
 * The email window, over `mail.listThreads` — the SAME read the email list makes.
 *
 * There is no email branch in `listChannelItems` on purpose: one read for email means the
 * unibox and `/mail` can never disagree about which threads exist, and the rows normalize
 * here through the shared `emailThreadToItem`.
 */
function useEmailFeed(scope: ChannelScope & { folder?: string }): FeedQuery {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { data: activeConnection } = useActiveConnection();
  const connectionId = activeConnection?.id ?? '';

  // The folder→query mapping the email list uses, so both lists answer `/mail/github` the
  // same way. A resolved tab sends `compiledQuery` instead, which wins server-side.
  const folderQuery = useMemo(
    () =>
      scope.folder && scope.folder !== 'inbox'
        ? `label:"${scope.folder.replace(/"/g, '\\"')}"`
        : undefined,
    [scope.folder],
  );

  const unfilteredInput = useMemo(
    () => ({
      q: folderQuery ?? '',
      maxResults: scope.limit,
      compiledQuery: scope.compiledQuery,
      queryHash: scope.queryHash,
      inboxId: scope.inboxId,
      // Whose cache the server's `headChanged` is addressed to. The latch is consumed on
      // read, so an unnamed surface here would take the email list's copy of the signal and
      // leave that list replaying page-2+ cursors past a head that moved.
      surface: 'unified-feed' as const,
    }),
    [folderQuery, scope.limit, scope.compiledQuery, scope.queryHash, scope.inboxId],
  );

  // ⇧U reaches email as a Gmail clause rather than a flag — Gmail owns email read state.
  // Added only where there is already a query to narrow: a bare `is:unread` would widen the
  // folder to all mail instead of filtering it, which is `useThreads`'s rule too.
  const input = useMemo(
    () => ({
      ...unfilteredInput,
      q: folderQuery && scope.unreadOnly ? withUnreadOnly(folderQuery, true) : (folderQuery ?? ''),
      compiledQuery: withUnreadOnly(scope.compiledQuery, scope.unreadOnly),
      queryHash: unreadOnlyQueryHash(scope.queryHash, scope.unreadOnly),
    }),
    [unfilteredInput, folderQuery, scope.compiledQuery, scope.queryHash, scope.unreadOnly],
  );

  const query = useInfiniteQuery(
    trpc.mail.listThreads.infiniteQueryOptions(input, {
      enabled: scope.enabled,
      initialCursor: '',
      getNextPageParam: (lastPage) => lastPage?.nextPageToken ?? null,
      // The unread lens, as above — but email expresses it in the query itself, so the
      // previous request is the same question when it matches EITHER form of this one.
      placeholderData: (previousData, previousQuery) => {
        if (!previousData) return undefined;
        const previousInput = trpcInputFromQueryKey(previousQuery?.queryKey);
        const sameQuestion =
          sameRequestApartFromUnread(previousInput, input) ||
          sameRequestApartFromUnread(previousInput, unfilteredInput);
        if (!sameQuestion) return undefined;
        return scope.unreadOnly ? filterThreadPagesToUnread(previousData) : previousData;
      },
    }),
  );

  // Page-1 freshness is reconciled INLINE by `mail.listThreads`, which sets `headChanged` when
  // the reconcile shifted the head. The unified feed is a SECOND consumer of that signal (it
  // asks as `surface: 'unified-feed'`, so the server's latch hands it its own copy rather than
  // the email list's) and has to act on it for the same reason the email list does: page-2+
  // cursors were minted against the old boundary, so replaying them straddles the shifted head
  // and the flattened result carries duplicate or stale rows. Trim every cached page back to
  // the first, then let the invalidate refetch from cursor='' — `resetListThreadsToFirstPage`
  // is the shared operation the email list funnels through too.
  //
  // Fire only on the false→true transition: the inline reconcile is idempotent, so the refetch
  // comes back `headChanged: false` and this converges instead of looping.
  //
  // The latch is keyed to the QUERY, not just to the flag. This hook instance outlives the
  // question it is asking — switching folder, inbox or the unread lens swaps the cache entry
  // underneath it while the ref persists. A bare boolean would then carry the previous query's
  // `true` into the next one, read "no transition", and skip a reset that query genuinely
  // needed; its later pages would keep cursors minted against a boundary that has since moved,
  // and id-dedup cannot clean that up because stale rows need not collide by id.
  const headChanged = Boolean(query.data?.pages?.[0]?.headChanged);
  const headKey=[redacted];
  const prevHeadRef = useRef<{ key=[redacted]; changed: boolean }>({ key: '', changed: false });
  useEffect(() => {
    const previous = prevHeadRef.current;
    const isDifferentQuery = previous.key !== headKey;
    if (headChanged && (isDifferentQuery || !previous.changed)) {
      void resetListThreadsToFirstPage(queryClient, trpc);
    }
    prevHeadRef.current = { key=[redacted], changed: headChanged };
  }, [headChanged, headKey, queryClient, trpc]);

  const slice = useMemo<ChannelFeedSlice>(
    () => ({
      // Deduped by thread id while flattening, the same safety net `useThreads` keeps over the
      // same pages. The reset above removes the CAUSE of an overlap, but it lands a render
      // later — the effect runs after this memo has already produced the straddled list once,
      // and a row appearing twice for a frame is exactly the artifact the watermark is meant
      // to make impossible.
      items: dedupeEmailPages(query.data?.pages, connectionId),
      // A channel that FAILED counts as settled — see the chat feed above.
      settled: query.data !== undefined || query.isError,
      exhausted: !query.hasNextPage,
    }),
    [query.data, query.isError, query.hasNextPage, connectionId],
  );

  return {
    slice,
    fetchNextPage: query.fetchNextPage,
    refetch: query.refetch,
    isFetchingNextPage: query.isFetchingNextPage,
    isPlaceholderData: query.isPlaceholderData,
    isError: query.isError,
  };
}

/**
 * The unified feed, composed on the client: one infinite query per PARTICIPATING channel,
 * merged in the store. See apps/mail/docs/inbox-triage.md Phase 8.
 *
 * The public shape is unchanged from the single-query version it replaces — `items` is the
 * merged list, `hasNextPage` and `fetchNextPage` speak for the merge rather than for one
 * server cursor — so every caller is untouched.
 */
export function useInboxItems(opts: {
  channel: InboxChannelFilter;
  folder?: string;
  limit?: number;
  /**
   * The active custom inbox. `folder` alone cannot express it: a custom inbox's slug is not a
   * real Gmail folder, so without these the email source matched nothing while the chat sources
   * matched everything. `inboxId` is how each route looks up its CRM rule — the rule itself is
   * never sent.
   */
  compiledQuery?: string;
  queryHash?: string;
  inboxId?: string;
  /**
   * ⇧U — narrow this inbox to unread. The chat routes take it as a flag; email folds it into
   * the Gmail query, because that is where email read state lives. One list, one filter.
   */
  unreadOnly?: boolean;
  /**
   * False while the tab this feed is scoped to is still resolving. The channel and the
   * inbox are ORTHOGONAL filters that compose — "all channels" ∩ "Important" — so a feed
   * request that goes out before the inbox half is known is not a partial answer, it is a
   * different question. Until settings/inboxes load, `inboxLayout` reads as the default
   * `inbox`, no stub owns the `important` slug, `compiledQuery` comes back undefined and
   * the server falls back to a folder mapping that cannot express the tab. Hold the queries
   * instead of asking them wrong.
   */
  enabled?: boolean;
}) {
  const trpc = useTRPC();
  const enabled = opts.enabled ?? true;
  const limit = opts.limit ?? 50;
  const unreadOnly = opts.unreadOnly ?? false;

  // WHICH channels participate — never the rule that decides it. `conversationFilter` stays
  // server-side and stays unsent; each route resolves its own from `inboxId`.
  const scopeQuery = useQuery(
    trpc.inbox.getFeedScope.queryOptions({ inboxId: opts.inboxId }, { enabled }),
  );

  const participating = useMemo<InboxChannel[]>(() => {
    const scope = scopeQuery.data;
    if (!enabled || !scope) return [];
    // `scope.channels` is ALREADY the resolved participation set — the server ran the shared
    // `participatingChannels` rule (services/inbox/feed-scope.ts), the same one
    // `inbox.listChannelItems` runs too. Re-deriving it here from the
    // split's opt-in would be a fourth copy of a rule whose whole failure mode is the copies
    // disagreeing, and the browser cannot see the inputs anyway: a CRM rule never leaves the
    // server. Narrow by the channel badge and ask nothing else.
    return opts.channel === 'all'
      ? scope.channels
      : scope.channels.filter((c) => c === opts.channel);
  }, [enabled, scopeQuery.data, opts.channel]);

  // Everything each channel's query needs except its own `enabled`, which is the one thing
  // that differs between them.
  const scope: Omit<ChannelScope, 'enabled'> & { folder?: string } = {
    folder: opts.folder,
    inboxId: opts.inboxId,
    compiledQuery: opts.compiledQuery,
    queryHash: opts.queryHash,
    unreadOnly,
    limit,
    // The unified feed narrows to linked containers; a dedicated channel tab shows them all.
    linkedOnly: opts.channel === 'all',
  };
  const email = useEmailFeed({ ...scope, enabled: participating.includes('email') });
  const linkedin = useChatChannelFeed('linkedin', {
    ...scope,
    enabled: participating.includes('linkedin'),
  });
  const whatsapp = useChatChannelFeed('whatsapp', {
    ...scope,
    enabled: participating.includes('whatsapp'),
  });
  const slack = useChatChannelFeed('slack', {
    ...scope,
    enabled: participating.includes('slack'),
  });

  const queries: Record<InboxChannel, FeedQuery> = { email, linkedin, whatsapp, slack };

  // Memoized on the SLICES, never on the query objects: a query object is new every render,
  // and depending on one would remake `feeds` every render, refire the commit effect, and
  // re-render off its own store write.
  const feeds = useMemo<ChannelFeeds>(() => {
    const slices: Record<InboxChannel, ChannelFeedSlice> = {
      email: email.slice,
      linkedin: linkedin.slice,
      whatsapp: whatsapp.slice,
      slack: slack.slice,
    };
    const next: ChannelFeeds = {};
    for (const channel of participating) next[channel] = slices[channel];
    return next;
  }, [participating, email.slice, linkedin.slice, whatsapp.slice, slack.slice]);

  // Priming the store's memo with the very object the store is about to hold: the selector is
  // keyed on `channelFeeds` identity, so the list's read of it costs nothing after this.
  const merged = selectMergedFeed({ channelFeeds: feeds });

  // Mirror the composed feed into the store slice (the render + selection source) in ONE
  // commit, and seed threadSlice for email rows so the unibox can draw them through the full
  // `<Thread>` component rather than a thin row.
  const setChannelFeeds = useCedarStore((s) => s.setChannelFeeds);
  const batchPopulateThreadMetadata = useCedarStore((s) => s.batchPopulateThreadMetadata);
  useEffect(() => {
    setChannelFeeds(feeds);
    const threadMetaById: Record<string, NonNullable<InboxItem['threadMeta']>> = {};
    for (const item of feeds.email?.items ?? []) {
      if (item.ref.kind === 'email' && item.threadMeta) {
        threadMetaById[item.ref.threadId] = item.threadMeta;
      }
    }
    if (Object.keys(threadMetaById).length > 0) batchPopulateThreadMetadata(threadMetaById);
  }, [feeds, setChannelFeeds, batchPopulateThreadMetadata]);

  /**
   * PARTIAL FAILURE. One channel going down is not the inbox going down.
   *
   * Each channel is its own query, so Slack can fail while email and LinkedIn are perfectly
   * healthy. Reporting that as `isError` would blank a working list and lose the rep every row
   * we did successfully load, which is a worse answer than the one we have. So the feed errors
   * only when it genuinely cannot answer: the SCOPE query failed (we do not even know which
   * channels participate), or every participating channel failed.
   *
   * The channels that did fail are named instead, and the list surfaces them inline above the
   * rows it can still show. Their slices report `settled` so the merge resolves around them —
   * a failed channel contributes nothing and bounds nothing.
   */
  // Memoized by VALUE rather than by the query objects, which are new every render: the joined
  // key changes only when the set of failing channels actually changes, so a caller can hold
  // this in a dependency array without re-firing on every render.
  const failedKey=[redacted] => queries[channel].isError).join(',');
  const failedChannels = useMemo(
    () => (failedKey ? (failedKey.split(',') as InboxChannel[]) : []),
    [failedKey],
  );
  const allChannelsFailed =
    participating.length > 0 && failedChannels.length === participating.length;

  // Only the GATING channels — the ones whose tail sets the watermark. Paging any other
  // channel loads rows that stay held, which is a round trip the rep never sees. When nothing
  // gates (every loaded channel is exhausted or came back empty), page whatever is left.
  const pageTargets =
    merged.gating.length > 0
      ? merged.gating
      : participating.filter((channel) => feeds[channel] && !feeds[channel]!.exhausted);

  /**
   * Every channel the next page would come from is down.
   *
   * A channel that failed on a LATER fetch keeps its loaded rows — react-query retains the last
   * good data — so it stays in the merge and can still be the one setting the watermark. Paging
   * it is then the only thing that would release the rows held beneath it, and paging it fails.
   * Advertising `hasNextPage` there gives the rep a button that silently does nothing on every
   * press. Say there is no more instead, and let the notice above the list explain why; Retry is
   * the control that can actually change the answer.
   */
  const pagingBlocked =
    pageTargets.length > 0 && pageTargets.every((channel) => failedChannels.includes(channel));

  // Neither of these is memoized: they close over query objects that are new every render, so
  // a `useCallback` would promise a stability it cannot deliver. Nothing takes them as an
  // effect dependency — they are an onClick and a sync hook's callback.
  const fetchNextPage = async () => {
    await Promise.all(pageTargets.map((channel) => queries[channel].fetchNextPage()));
  };

  const refetch = async () => {
    await Promise.all([
      scopeQuery.refetch(),
      ...participating.map((channel) => queries[channel].refetch()),
    ]);
  };

  const active = participating.map((channel) => queries[channel]);

  return {
    items: merged.items,
    // A feed that has not settled every channel renders nothing (the merge cannot prove any
    // prefix stable yet), so it is still loading — not empty.
    isLoading: scopeQuery.isLoading || (participating.length > 0 && !merged.ready),
    /**
     * True while the rows are the locally-filtered stand-in rather than the server's answer.
     * The caller needs it for the empty case: an inbox whose LOADED page held no unread is
     * not an inbox with no unread, and flashing "No unread here" before the real page lands
     * would be a wrong answer shown confidently.
     */
    isPlaceholderData: active.some((query) => query.isPlaceholderData),
    isError: scopeQuery.isError || allChannelsFailed,
    /** Non-fatal: these channels are missing from the rows below. Empty when all is well. */
    failedChannels,
    hasNextPage: mergedFeedHasMore(feeds, merged) && !pagingBlocked,
    isFetchingNextPage: active.some((query) => query.isFetchingNextPage),
    fetchNextPage,
    refetch,
  };
}