inboxSlice.ts8.1 KBView on GitHub
import type { CedarStore } from '@/modules/store/CedarStoreTypes';
import type { StateCreator } from 'zustand';
import { mergeChannelFeeds, type ChannelFeedSlice, type MergedFeed } from '@zero/server/inbox/merge';
import type { InboxChannel, InboxItem } from '@/modules/inbox/types';

/**
 * Store slice for the unified omni-channel inbox — the channel analogue of `threadSlice`.
 *
 * The feed is composed on the CLIENT (inbox-triage.md Phase 8): one infinite query per
 * channel, each cached and refreshable on its own, and this slice holds their loaded windows
 * side by side rather than one server-ordered page. Order is DERIVED — `selectMergedFeed`
 * runs the shared merge (`@zero/server/inbox/merge`), which sorts the union and cuts it at
 * the watermark so a `fetchNextPage` on one channel can only ever extend the list downward.
 *
 * Selection reuses the shared `bulkSelected` (mailSlice) — its ids are unified ids (email
 * thread id OR channel id), which is what makes cross-channel select/range/bulk-done work.
 * `getUnifiedInboxList` therefore returns the RENDERED order, not everything loaded: keyboard
 * nav, range-select and the bulk actions all index into the list the rep can see.
 * See apps/mail/docs/unified-inbox-slice-design.md.
 */
export type ChannelFeeds = Partial<Record<InboxChannel, ChannelFeedSlice>>;

/** The narrow read the selectors below need, so they compose with the whole store. */
export interface InboxFeedSource {
  channelFeeds: ChannelFeeds;
}

export interface InboxState extends InboxFeedSource {
  /** Each participating channel's loaded window. The merge, not the server, orders them. */
  channelFeeds: ChannelFeeds;
  /**
   * Enough to open a Slack channel that is NOT in the feed.
   *
   * The feed is only loaded on the inbox route, so opening a channel from anywhere else — a
   * Slack task on the kanban board, a deep link — finds nothing to render and silently shows a
   * blank column. The opener already knows the channel (a Slack task carries its id, workspace
   * and name), so it leaves them here and the view synthesises the row it needs.
   *
   * A hint, not a cache: the real feed item always wins when it is present.
   */
  slackChannelHint: {
    channelId: string;
    workspaceId: string;
    channelName: string | null;
    conversationId: string | null;
  } | null;
}

/**
 * The id a row is SELECTED and rendered by. Email uses the raw Gmail `threadId`
 * (so the shared `<Thread>` component — which selects by threadId — participates
 * in the same `bulkSelected` as channel rows); channels use their `InboxItem.id`.
 * This is the unified id in `bulkSelected`. The server id (`email:<threadId>`) still
 * lives on `item.id`, which is what every per-channel query cache is keyed by.
 */
export function inboxSelectionId(item: InboxItem): string {
  return item.channel === 'email' && item.ref.kind === 'email' ? item.ref.threadId : item.id;
}

/**
 * Single-entry memo keyed on the `channelFeeds` object identity.
 *
 * The merge SORTS, so a Zustand subscriber that recomputed it per render would re-sort the
 * whole feed on every keystroke elsewhere in the app — and would hand `useEffect` a fresh
 * object each time. `channelFeeds` is replaced wholesale on every commit, so its identity is
 * an exact key for "has the feed changed".
 */
function memoOnFeeds<T>(compute: (feeds: ChannelFeeds) => T): (state: InboxFeedSource) => T {
  let cached: { feeds: ChannelFeeds; value: T } | null = null;
  return (state) => {
    if (cached && cached.feeds === state.channelFeeds) return cached.value;
    const value = compute(state.channelFeeds);
    cached = { feeds: state.channelFeeds, value };
    return value;
  };
}

/** The merged, watermarked feed — what the list renders and what selection walks. */
export const selectMergedFeed = memoOnFeeds<MergedFeed>((feeds) => mergeChannelFeeds(feeds));

/**
 * The rendered order as `{ id }[]`, so the existing range/select index logic reuses it.
 *
 * Derived THROUGH `selectMergedFeed` rather than by merging again. Both selectors run on every
 * feed change and the merge sorts the whole union, so calling it twice doubled the cost of a
 * commit for a result that is by definition identical — the memo below it turns the second
 * call into a lookup.
 */
export const selectUnifiedInboxList = memoOnFeeds((feeds) =>
  selectMergedFeed({ channelFeeds: feeds }).items.map((item) => ({ id: inboxSelectionId(item) })),
);

/**
 * Every item loaded across every channel — the LOOKUP surface, deliberately wider than the
 * rendered one. A deep-linked Slack channel sitting below the watermark is still openable;
 * holding it back is about where a row may be DRAWN, not about whether it is known.
 */
export const selectLoadedInboxItems = memoOnFeeds((feeds) =>
  Object.values(feeds).flatMap((slice) => slice?.items ?? []),
);

/** The same loaded set keyed by selection id, for the by-id lookups selection makes. */
export const selectInboxItemsById = memoOnFeeds((feeds) => {
  const byId: Record<string, InboxItem> = {};
  for (const slice of Object.values(feeds)) {
    for (const item of slice?.items ?? []) byId[inboxSelectionId(item)] = item;
  }
  return byId;
});

/**
 * The loaded item for a selection id, WITHOUT touching the memo.
 *
 * `resolveSelectionConversationId` runs inside an immer recipe, where `channelFeeds` is a
 * draft proxy that is revoked when the recipe returns — caching one would hand a later reader
 * a proxy that throws on access. A linear scan over the loaded window costs nothing at feed
 * sizes and cannot poison anything.
 */
export function findLoadedInboxItem(
  feeds: ChannelFeeds,
  selectionId: string,
): InboxItem | undefined {
  for (const slice of Object.values(feeds)) {
    const hit = slice?.items.find((item) => inboxSelectionId(item) === selectionId);
    if (hit) return hit;
  }
  return undefined;
}

export interface InboxSlice extends InboxState {
  /**
   * Replace every channel's window in ONE commit.
   *
   * Whole-map rather than per-channel on purpose. The channels settle at different times and
   * the participating set changes with the channel badge and the active tab, so a per-channel
   * write would leave a departed channel's rows in the list and would reconcile the keyboard
   * cursor once per settling query — jitter the rep sees as the focus ring hopping while the
   * feed fills in.
   */
  setChannelFeeds: (feeds: ChannelFeeds) => void;
  getInboxItem: (id: string) => InboxItem | undefined;
  /** The RENDERED order as `{ id }[]` — everything above the watermark, nothing below it. */
  getUnifiedInboxList: () => { id: string }[];
  /** Record how to render a Slack channel the feed has not loaded. */
  setSlackChannelHint: (hint: InboxState['slackChannelHint']) => void;
}

const initialInboxState: InboxState = {
  channelFeeds: {},
  slackChannelHint: null,
};

export const createInboxSlice: StateCreator<
  CedarStore,
  [['zustand/immer', never], ['zustand/devtools', never]],
  [],
  InboxSlice
> = (set, get) => ({
  ...initialInboxState,

  setSlackChannelHint: (hint) =>
    set(
      (state) => {
        state.slackChannelHint = hint;
      },
      undefined,
      'inbox/setSlackChannelHint',
    ),

  setChannelFeeds: (feeds) =>
    set(
      (state) => {
        state.channelFeeds = feeds;

        // Keep the keyboard cursor on the selected row as the feed re-orders, exactly as
        // `setCurrentThreadList` does for the email list. Nothing selected → nothing focused;
        // no auto-select, so landing on the unibox leaves the list untouched. Computed off
        // the plain `feeds` rather than the draft so it shares the render's memo entry.
        if (state.activeListSource !== 'unified') return;
        const order = selectUnifiedInboxList({ channelFeeds: feeds });
        const selectedIndex = state.selectedThreadId
          ? order.findIndex((row) => row.id === state.selectedThreadId)
          : -1;
        state.focusedIndex = selectedIndex === -1 ? null : selectedIndex;
      },
      false,
      'inbox/setChannelFeeds',
    ),

  getInboxItem: (id) => selectInboxItemsById(get())[id],

  getUnifiedInboxList: () => selectUnifiedInboxList(get()),
});