use-inboxes.ts29.4 KBView on GitHub import { useCallback, useMemo, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSession } from '@/modules/auth/utils/auth-client';
import { useTRPC } from '@/providers/query-provider';
// ── Types (mirror of apps/server/src/lib/schemas.ts inboxConfigSchema) ──────────
export type InboxClause =
| { type: 'aop'; aopId: string }
| { type: 'gmail'; field: 'from' | 'to' | 'subject'; op: 'contains' | 'equals'; value: string }
| { type: 'gmailLabel'; labelId: string }
| { type: 'ai'; prompt: string };
export type InboxRule =
| { kind: 'all' }
| { kind: 'all_of'; clauses: InboxClause[] }
| { kind: 'any_of'; clauses: InboxClause[] };
/**
* The CRM rule attached to an inbox: restrict to threads whose linked conversation
* matches. `filters` is the wire shape of the server's
* `conversationFilterParamsSchema` (AOP scope included) — the same object
* `crm.listConversations` takes — and `uiConfig` is the builder's per-column state,
* stored only so reopening the editor round-trips what the user picked.
* See apps/mail/docs/pipeline-inbox-crm-stage-filter.md.
*/
export interface InboxConversationFilter {
filters: Record<string, unknown>;
uiConfig?: Record<string, unknown>;
}
export interface InboxConfig {
// ─── Identity / display ─────────────────────────────────────────────────
id: string;
accountId?: string;
name: string;
position: number;
system?: boolean;
rule: InboxRule;
query?: string;
lookbackDays?: number | 'all';
// ─── Query / split state (merged in from the old inboxSplits array) ─────
enabled?: boolean;
hideWhenEmpty?: boolean;
alsoShowInImportant?: boolean;
excludeQuery?: string;
/** Optional CRM rule — see {@link InboxConversationFilter}. */
conversationFilter?: InboxConversationFilter;
compiledQuery?: string;
queryHash?: string;
createdAt?: string | Date;
updatedAt?: string | Date;
}
export type InboxLayout = 'inbox' | 'important_other' | 'stacked';
export type ImportantSignal = 'category_personal' | 'gmail_important';
const ALL_SYSTEM_INBOXES: InboxConfig[] = [
{ id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } },
{ id: 'important', name: 'Important', position: 1, system: true, rule: { kind: 'all' } },
{ id: 'other', name: 'Other', position: 2, system: true, rule: { kind: 'all' } },
];
const SYSTEM_INBOXES_BY_LAYOUT: Record<InboxLayout, InboxConfig[]> = {
inbox: [{ id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } }],
important_other: [
{ id: 'important', name: 'Important', position: 0, system: true, rule: { kind: 'all' } },
{ id: 'other', name: 'Other', position: 1, system: true, rule: { kind: 'all' } },
],
// Stacked layout shares the same system inbox set as `inbox` so toggling
// Display mode (Tabs ↔ Containers) only changes presentation, not which
// inboxes exist. StackedInboxView renders one collapsible section per
// inbox; the tab strip collapses to a single synthetic "Inbox" tab routed
// to /mail/inbox (an ALLOWED_FOLDERS standard slug, not an inbox slug).
stacked: [{ id: 'default', name: 'Inbox', position: 0, system: true, rule: { kind: 'all' } }],
};
export const SYSTEM_INBOX_IDS = new Set(ALL_SYSTEM_INBOXES.map((i) => i.id));
export const DEFAULT_INBOX: InboxConfig = ALL_SYSTEM_INBOXES[0]!;
// Slugs that the `/mail/:folder` route already owns (Gmail folders + dedicated
// sibling routes). A custom inbox whose name slugifies to one of these would
// shadow that route, so we forbid it at create/rename time.
export const RESERVED_INBOX_SLUGS = new Set([
'tasks',
'organised',
'draft',
'sent',
'spam',
'bin',
'archive',
'all',
'done',
'compose',
'conversation-inbox',
'smart-inbox',
]);
// System inbox slugs are also reserved against custom inboxes (a custom inbox
// can't be named "Important" or "Other" — those names belong to the system
// inboxes when the important/other layout is active).
const SYSTEM_INBOX_SLUGS = new Set(['inbox', 'important', 'other']);
export function slugifyInboxName(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export class InboxNameError extends Error {
constructor(message: string) {
super(message);
this.name = 'InboxNameError';
}
}
const MAX_SPLIT_INBOXES = 7;
export function newInboxId(): string {
return `inbox-${Math.random().toString(36).slice(2, 10)}`;
}
// ── Hook ────────────────────────────────────────────────────────────────────────
export function getInboxSlug(inbox: InboxConfig): string {
// System inboxes always slug to their canonical name regardless of any
// accidental name override — keeps the URL stable.
if (inbox.id === 'default') return 'inbox';
if (inbox.id === 'important') return 'important';
if (inbox.id === 'other') return 'other';
return slugifyInboxName(inbox.name);
}
export function findInboxBySlug(
inboxes: InboxConfig[],
slug: string,
): InboxConfig | undefined {
return inboxes.find((i) => getInboxSlug(i) === slug);
}
/**
* Canonical tab-strip / stacked-section ordering. When `hasCustomOrder` is true
* the user has explicitly reordered via the modal, so we trust the array order
* as-is. Otherwise we apply the default pinning: Important first, Other last,
* customs in saved position order in the middle.
*/
export function getRowInboxOrder(
inboxes: InboxConfig[],
hasCustomOrder = false,
): InboxConfig[] {
if (hasCustomOrder) return inboxes;
const important = inboxes.find((inbox) => inbox.id === 'important');
const other = inboxes.find((inbox) => inbox.id === 'other');
const middle = inboxes.filter((inbox) => inbox.id !== 'important' && inbox.id !== 'other');
if (important && other) return [important, ...middle, other];
if (important) return [important, ...middle];
if (other) return [...middle, other];
return middle;
}
function validateInboxName(
name: string,
inboxes: InboxConfig[],
selfId?: string,
): void {
const trimmed = name.trim();
if (!trimmed) throw new InboxNameError('Inbox name cannot be empty.');
const slug = slugifyInboxName(trimmed);
if (!slug) throw new InboxNameError('Inbox name must contain letters or numbers.');
if (RESERVED_INBOX_SLUGS.has(slug)) {
throw new InboxNameError(
`"${trimmed}" is reserved for a built-in folder. Pick a different name.`,
);
}
if (SYSTEM_INBOX_SLUGS.has(slug)) {
throw new InboxNameError(
`"${trimmed}" is reserved for a system inbox. Pick a different name.`,
);
}
const collision = inboxes.find(
(i) => i.id !== selfId && !SYSTEM_INBOX_IDS.has(i.id) && getInboxSlug(i) === slug,
);
if (collision) {
throw new InboxNameError(
`Another inbox already uses the name "${collision.name}". Pick a different name.`,
);
}
}
function normalize(
inboxes: InboxConfig[] | undefined,
inboxLayout: InboxLayout,
inboxOrder?: string[],
): InboxConfig[] {
const persisted = (inboxes ?? []).filter((i) => !SYSTEM_INBOX_IDS.has(i.id));
const customs = persisted.slice().sort((a, b) => a.position - b.position);
const systemInboxes = SYSTEM_INBOXES_BY_LAYOUT[inboxLayout];
const assembled = [...systemInboxes.map((s) => ({ ...s })), ...customs];
// If the user has saved an explicit order that covers every assembled inbox,
// apply it. New inboxes not yet in `inboxOrder` are appended in their
// existing order so they're still discoverable.
if (inboxOrder && inboxOrder.length > 0) {
const byId = new Map(assembled.map((i) => [i.id, i]));
const ordered: InboxConfig[] = [];
const seen = new Set<string>();
for (const id of inboxOrder) {
const inbox = byId.get(id);
if (inbox && !seen.has(id)) {
ordered.push(inbox);
seen.add(id);
}
}
for (const inbox of assembled) {
if (!seen.has(inbox.id)) ordered.push(inbox);
}
return ordered.map((i, idx) => ({ ...i, position: idx }));
}
return assembled.map((i, idx) => ({ ...i, position: idx }));
}
export function useInboxes() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: session } = useSession();
const settingsKey=[redacted];
// After the inboxes/inboxSplits unification, the single source of truth is
// `mail.listInboxes`. settings.get is still queried for the OTHER settings
// (inboxLayout, activeInboxId, importantSignal, inboxOrder) but no longer
// for the inbox array itself.
const inboxesKey=[redacted];
const settingsQuery = useQuery(
trpc.settings.get.queryOptions(void 0, {
enabled: !!session?.user.id,
staleTime: Infinity,
}),
);
const inboxesQuery = useQuery(
trpc.mail.listInboxes.queryOptions(void 0, {
enabled: !!session?.user.id,
staleTime: 60 * 1000,
}),
);
const rawInboxes = inboxesQuery.data as InboxConfig[] | undefined;
const inboxLayout =
((settingsQuery.data?.settings as { inboxLayout?: InboxLayout } | undefined)?.inboxLayout ??
'inbox') as InboxLayout;
const importantSignal =
((settingsQuery.data?.settings as { importantSignal?: ImportantSignal } | undefined)
?.importantSignal ?? 'category_personal') as ImportantSignal;
const inboxOrder = (settingsQuery.data?.settings as { inboxOrder?: string[] } | undefined)
?.inboxOrder;
const hasCustomInboxOrder = Array.isArray(inboxOrder) && inboxOrder.length > 0;
const inboxes = useMemo(
() => normalize(rawInboxes, inboxLayout, inboxOrder),
[rawInboxes, inboxLayout, inboxOrder],
);
const activeInboxId =
(settingsQuery.data?.settings as { activeInboxId?: string } | undefined)?.activeInboxId ??
inboxes[0]!.id;
const activeInbox = inboxes.find((i) => i.id === activeInboxId) ?? inboxes[0]!;
const { mutate: saveSettings } = useMutation(
trpc.settings.save.mutationOptions({
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey=[redacted] });
const previous = queryClient.getQueryData(settingsKey);
queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
old
? // `vars` is the partial save input; only the keys it actually carries are
// written over the fully-populated cached settings.
{ ...old, settings: { ...old.settings, ...vars } as typeof old.settings }
: old,
);
return { previous };
},
onError: (_e, _v, ctx) => {
if (ctx?.previous) queryClient.setQueryData(settingsKey, ctx.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey=[redacted] }),
}),
);
// Optimistic writes against the `mail.listInboxes` cache. The client mints the
// inbox id before calling the server, so an optimistically inserted row carries
// its REAL id — any follow-up write (rename, alsoShowInImportant toggle,
// delete) addresses the same record the server is creating, and the refetch on
// settle reconciles rather than replaces.
// `inboxesQuery.data` appears below only inside `typeof`, which is erased — listing it as a
// dependency would re-identify both callbacks on every refetch for no runtime reason.
const patchInboxCache = useCallback(
(mutate: (current: InboxConfig[]) => InboxConfig[]) => {
const previous = queryClient.getQueryData(inboxesKey) as InboxConfig[] | undefined;
// Cache rows come from `mail.listInboxes` (accountId required). Client InboxConfig keeps
// accountId optional for local system stubs that never land in this cache.
queryClient.setQueryData(
inboxesKey,
mutate(previous ?? []) as NonNullable<typeof inboxesQuery.data>,
);
return previous;
},
[queryClient, inboxesKey],
);
const restoreInboxCache = useCallback(
(previous: InboxConfig[] | undefined) => {
if (previous) {
queryClient.setQueryData(inboxesKey, previous as NonNullable<typeof inboxesQuery.data>);
}
},
[queryClient, inboxesKey],
);
/** In-flight `createInbox` calls, keyed by the client-minted inbox id. */
const pendingCreatesRef = useRef<Map<string, Promise<unknown>>>(new Map());
/**
* How many `createInbox` mutations are between `onMutate` and `onSettled`.
*
* Counted separately from `pendingCreatesRef`, which is keyed off `addInbox` and so
* is only populated once the caller has awaited its way back — too late to gate the
* settle of the very mutation that is settling.
*/
const inFlightCreatesRef = useRef(0);
/**
* Highest position handed to a create so far.
*
* The cache alone cannot answer this. `addInbox` picks the position synchronously,
* but the optimistic row is written inside `onMutate` — which awaits `cancelQueries`
* first — so two adds fired back to back BOTH read the cache before either has
* written to it, and both claim the same index. Carrying the last issued value
* forward closes that window without waiting on the cache.
*/
const lastIssuedPositionRef = useRef(-1);
/** Wait out an in-flight create for `id` so a follow-up write can't overtake it. */
const awaitPendingCreate = useCallback(async (id: string) => {
const pending = pendingCreatesRef.current.get(id);
if (pending) await pending.catch(() => undefined);
}, []);
/**
* Refetch the inbox list — but ONLY once every create has settled.
*
* Invalidating per-mutation loses rows. Click two templates in a row and create #1's
* refetch is already in flight when create #2 writes its optimistic row; the response
* lands afterwards carrying a server list that predates #2 and overwrites it. The row
* vanishes, the template card flips back to unselected, and the user clicks again —
* which is what "adding an inbox doesn't save" actually looks like from the outside.
*
* `cancelQueries` in `onMutate` does not cover this: it cancels what is in flight at
* that instant, and #1's refetch is started by its settle, which comes later.
*
* So the LAST create to settle does the single refetch, and every other write defers
* to it while any create is outstanding.
*/
const settleInboxes = useCallback(() => {
if (inFlightCreatesRef.current > 0) return;
queryClient.invalidateQueries({ queryKey=[redacted] });
}, [queryClient, inboxesKey]);
const { mutateAsync: createInboxMutation } = useMutation(
trpc.mail.createInbox.mutationOptions({
onMutate: async (vars) => {
inFlightCreatesRef.current += 1;
await queryClient.cancelQueries({ queryKey=[redacted] });
const previous = patchInboxCache((current) => [
...current,
{
...vars,
rule: vars.rule ?? { kind: 'all_of', clauses: [] },
// The server recomputes both on write; the include query is a good
// enough stand-in for the one render before the refetch lands.
compiledQuery: vars.query ?? '',
queryHash: '',
} as InboxConfig,
]);
return { previous };
},
// A failed create drops only ITS row. Restoring the `previous` snapshot wholesale
// would also undo any create that landed after this one's `onMutate` — the exact
// clobber `settleInboxes` exists to prevent, arrived at from the other side.
onError: (_error, vars) =>
patchInboxCache((current) => current.filter((inbox) => inbox.id !== vars.id)),
onSettled: () => {
inFlightCreatesRef.current = Math.max(0, inFlightCreatesRef.current - 1);
settleInboxes();
},
}),
);
const { mutateAsync: updateInboxMutation } = useMutation(
trpc.mail.updateInbox.mutationOptions({
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey=[redacted] });
const previous = patchInboxCache((current) =>
current.map((inbox) =>
inbox.id === vars.inboxId ? ({ ...inbox, ...vars.patch } as InboxConfig) : inbox,
),
);
return { previous };
},
onError: (_error, _vars, context) => restoreInboxCache(context?.previous),
onSettled: settleInboxes,
}),
);
const { mutateAsync: deleteInboxMutation } = useMutation(
trpc.mail.deleteInbox.mutationOptions({
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey=[redacted] });
const previous = patchInboxCache((current) =>
current.filter((inbox) => inbox.id !== vars.inboxId),
);
return { previous };
},
onError: (_error, _vars, context) => restoreInboxCache(context?.previous),
onSettled: settleInboxes,
}),
);
const { mutateAsync: reorderInboxesMutation } = useMutation(
trpc.mail.reorderInboxes.mutationOptions({
onSettled: () => {
settleInboxes();
queryClient.invalidateQueries({ queryKey=[redacted] });
},
}),
);
// Legacy migration effects (localStorage → settings.inboxes, settings.inboxes
// → inboxSplits, and the includeQuery backfill) all became moot after the
// server-side unification: the single `mail.listInboxes` source already
// carries every field these migrations were trying to reconcile. The SQL
// migration in apps/server/src/docs/unified-inbox-storage.md handled
// existing rows; new client installs never see the legacy shapes.
const previewSplitQuery = useCallback(
async (input: { includeQuery: string; excludeQuery?: string }) => {
return queryClient.fetchQuery(
trpc.mail.previewSplitQuery.queryOptions({
includeQuery: input.includeQuery,
excludeQuery: input.excludeQuery,
}),
);
},
[queryClient, trpc],
);
// activeInboxId is transient UI state — which tab is active. The URL is the
// canonical source of truth (browser restores it across reloads) and the
// server never reads this field. Keep it cache-only so tab switches don't
// round-trip to settings.save.
const setActiveInboxId = useCallback(
(id: string) => {
queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
old ? { ...old, settings: { ...old.settings, activeInboxId: id } } : old,
);
},
[queryClient, settingsKey],
);
// AI inboxes are filled in lazily by the per-email orchestrator agent — no
// upfront backfill (would burn LLM cost on history the agent will see anyway
// the next time each thread is touched). Only deterministic rules backfill.
const hasAiClause = (rule: InboxRule) =>
rule.kind !== 'all' && rule.clauses.some((c) => c.type === 'ai');
/**
* Synchronous pre-flight for a new inbox — capacity + name validation. Lets a
* caller reject bad input while the form is still open, then fire the create
* without awaiting it (the optimistic cache write already renders the inbox).
*/
const assertCanAddInbox = useCallback(
(name: string) => {
const currentCustomCount = inboxes.filter((inbox) => !SYSTEM_INBOX_IDS.has(inbox.id)).length;
if (currentCustomCount >= MAX_SPLIT_INBOXES) {
throw new Error(`You can create up to ${MAX_SPLIT_INBOXES} inboxes.`);
}
validateInboxName(name, inboxes);
},
[inboxes],
);
const addInbox = useCallback(
async (
config: Omit<InboxConfig, 'id' | 'position'> & {
query?: string;
alsoShowInImportant?: boolean;
hideWhenEmpty?: boolean;
/**
* Called with the client-minted id the moment it exists, before the write is
* awaited. The optimistic row carries this id, so a caller that wants to SELECT
* what it just created can do so on the same frame instead of after the
* round-trip. The returned promise still resolves to the same id.
*/
onOptimisticId?: (id: string) => void;
},
): Promise<string> => {
assertCanAddInbox(config.name);
const id = newInboxId();
config.onOptimisticId?.(id);
const query = config.query?.trim();
// Position off the optimistic cache rather than `rawInboxes`: the server list
// gives every create fired before the first refetch the SAME position, so a run of
// template clicks lands as a pile at one index and the tab strip reorders itself
// once the refetch arrives. `lastIssuedPositionRef` covers the adds that are too
// close together for even the optimistic cache to have caught up.
const cachedRows = (queryClient.getQueryData(inboxesKey) as InboxConfig[] | undefined) ?? [];
const cachedMax = cachedRows.reduce((max, inbox) => Math.max(max, inbox.position ?? 0), -1);
const position = Math.max(cachedMax, lastIssuedPositionRef.current) + 1;
lastIssuedPositionRef.current = position;
// The optimistic row lands in the cache the moment the mutation fires, so a
// fast follow-up edit (rename, "also show in inbox") can find the inbox by
// id before the server has actually created it. Park the in-flight create
// so those writes can wait on it instead of racing to a "not found".
const pending = createInboxMutation({
id,
name: config.name,
position,
rule: config.rule,
lookbackDays: config.lookbackDays,
enabled: true,
hideWhenEmpty: config.hideWhenEmpty ?? false,
// A CRM-filtered inbox defaults to mirroring into Inbox rather than
// carving out of it: its `query` is a bare scope anchor, so treating it
// as a partition would subtract all of `label:INBOX` from the default
// tab. See buildImportantQueryExclusions for the guard that makes this
// safe even if the user later un-ticks the toggle.
alsoShowInImportant: config.alsoShowInImportant ?? !!config.conversationFilter,
query,
excludeQuery: undefined,
conversationFilter: config.conversationFilter,
});
pendingCreatesRef.current.set(id, pending);
try {
await pending;
} finally {
pendingCreatesRef.current.delete(id);
}
if (!hasAiClause(config.rule)) {
queryClient.invalidateQueries({ queryKey=[redacted] });
queryClient.invalidateQueries({ queryKey=[redacted] });
}
return id;
},
[
assertCanAddInbox,
inboxesKey,
createInboxMutation,
queryClient,
trpc.mail.getSplitCounts,
trpc.mail.listThreads,
],
);
const updateInbox = useCallback(
async (
id: string,
// `conversationFilter: null` is meaningful (clear the CRM rule) and cannot
// be expressed by the optional field on InboxConfig, so that key is omitted
// before widening — an intersection would have re-narrowed it to non-null.
patch: Partial<Omit<InboxConfig, 'id' | 'conversationFilter'>> & {
conversationFilter?: InboxConversationFilter | null;
},
) => {
if (SYSTEM_INBOX_IDS.has(id)) return; // system inboxes are not editable
if (typeof patch.name === 'string') {
validateInboxName(patch.name, inboxes, id);
}
const ruleChanged = 'rule' in patch || 'lookbackDays' in patch;
const nextRule = patch.rule ?? inboxes.find((i) => i.id === id)?.rule;
const shouldReclassify = ruleChanged && nextRule && !hasAiClause(nextRule);
await awaitPendingCreate(id);
await updateInboxMutation({
inboxId: id,
patch: {
// Allowed fields only — the server schema rejects unknown ones.
...(patch.name !== undefined ? { name: patch.name } : {}),
...(patch.position !== undefined ? { position: patch.position } : {}),
...(patch.rule !== undefined ? { rule: patch.rule } : {}),
...(patch.lookbackDays !== undefined ? { lookbackDays: patch.lookbackDays } : {}),
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
...(patch.hideWhenEmpty !== undefined ? { hideWhenEmpty: patch.hideWhenEmpty } : {}),
...(patch.alsoShowInImportant !== undefined
? { alsoShowInImportant: patch.alsoShowInImportant }
: {}),
...(patch.query !== undefined ? { query: patch.query } : {}),
...(patch.excludeQuery !== undefined ? { excludeQuery: patch.excludeQuery } : {}),
// `null` clears the CRM rule server-side; absent leaves it untouched.
...(patch.conversationFilter !== undefined
? { conversationFilter: patch.conversationFilter }
: {}),
},
});
if (shouldReclassify) {
queryClient.invalidateQueries({ queryKey=[redacted] });
queryClient.invalidateQueries({ queryKey=[redacted] });
}
},
[
inboxes,
awaitPendingCreate,
updateInboxMutation,
queryClient,
trpc.mail.getSplitCounts,
trpc.mail.listThreads,
],
);
const renameInbox = useCallback(
(id: string, name: string) => updateInbox(id, { name }),
[updateInbox],
);
const removeInbox = useCallback(
async (id: string) => {
const target = inboxes.find((i) => i.id === id);
if (!target || target.system || target.id === 'default') return;
await awaitPendingCreate(id);
// Deliberately NOT caught. The delete's own `onError` puts the row back in the
// cache, but a caller holding its own selected-state (the template gallery's
// applied set) has no way to learn it should do the same if the rejection is
// swallowed here — the row returns while the card stays deselected.
await deleteInboxMutation({ inboxId: id });
// If the deleted inbox was active, switch to the first remaining one.
// activeInboxId is cache-only — no network round-trip needed.
if (activeInboxId === id) {
const nextActive = inboxes.find((i) => i.id !== id)?.id;
if (nextActive) {
setActiveInboxId(nextActive);
}
}
},
[inboxes, awaitPendingCreate, deleteInboxMutation, activeInboxId, setActiveInboxId],
);
const reorderInboxes = useCallback(
async (orderedIds: string[]) => {
// Optimistically reflect the new order in the cached settings so the UI
// updates immediately. The server persists both the inbox row positions
// and the `inboxOrder` field in a single write.
queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
old ? { ...old, settings: { ...old.settings, inboxOrder: orderedIds } } : old,
);
await reorderInboxesMutation({ orderedIds }).catch((err) => {
console.warn('[reorderInboxes] Failed to reorder inboxes:', err);
});
},
[queryClient, settingsKey, settingsQuery.data, reorderInboxesMutation],
);
const setInboxLayout = useCallback(
(nextLayout: InboxLayout) => {
const firstSystemInbox = SYSTEM_INBOXES_BY_LAYOUT[nextLayout][0];
// Persist inboxLayout (real setting), but only update activeInboxId in
// cache — it's transient UI state.
saveSettings({ inboxLayout: nextLayout } as never);
queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
old
? {
...old,
settings: {
...old.settings,
inboxLayout: nextLayout,
activeInboxId: firstSystemInbox?.id,
},
}
: old,
);
},
[saveSettings, queryClient, settingsKey, settingsQuery.data],
);
const setImportantSignal = useCallback(
(nextSignal: ImportantSignal) => {
saveSettings({ importantSignal: nextSignal } as never);
queryClient.setQueryData(settingsKey, (old: typeof settingsQuery.data) =>
old
? { ...old, settings: { ...old.settings, importantSignal: nextSignal } }
: old,
);
// Both 'important' and 'other' compiled queries depend on the signal,
// so invalidate to force a refetch with the new query.
queryClient.invalidateQueries({ queryKey=[redacted] });
queryClient.invalidateQueries({ queryKey=[redacted] });
},
[saveSettings, queryClient, settingsKey, settingsQuery.data, trpc.mail.listThreads, trpc.mail.getSplitCounts],
);
return {
inboxes,
activeInbox,
inboxLayout,
importantSignal,
activeInboxId,
setActiveInboxId,
setInboxLayout,
setImportantSignal,
addInbox,
assertCanAddInbox,
updateInbox,
renameInbox,
removeInbox,
reorderInboxes,
hasCustomInboxOrder,
isLoading: settingsQuery.isLoading || inboxesQuery.isLoading,
inboxesLoading: inboxesQuery.isLoading,
/**
* Whether the tab scope is actually KNOWN, as against merely not in flight.
*
* Both queries are gated on the session, and a DISABLED react-query reports
* `isLoading: false` — pending, but not fetching. So `isLoading` reads as "loaded"
* for the whole window before the session resolves, with no settings behind it: the
* exact window in which `inboxLayout` falls back to `inbox`, no stub owns the
* `important`/`other` slug, and a feed asked now is asked without its inbox half.
*
* `isPending` is the honest question. An ERROR settles it too — a failed settings
* fetch must let the feed through rather than spin forever.
*/
scopeReady: !settingsQuery.isPending && !inboxesQuery.isPending,
previewSplitQuery,
};
}