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