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()),
});