use-inbox-item-actions.ts9.6 KBView on GitHub import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { triggerThreadExitAnimation } from '@/modules/threads/threadList/threadItem/components/thread';
import type { InboxItem } from '@/modules/inbox/types';
import { useTRPC } from '@/providers/query-provider';
type InfinitePage = { items: InboxItem[]; nextCursor?: string };
type InfiniteData = { pages: InfinitePage[]; pageParams: unknown[] };
/** The channel of a row that HAS per-item state — i.e. anything but email. */
type ChatChannel = Exclude<InboxItem['channel'], 'email'>;
/**
* A row's chat channel, or null when it is an email.
*
* `inbox.setItemState` accepts the three chat channels and nothing else, and every call below
* used to reach that signature with `item.channel as 'linkedin' | 'whatsapp' | 'slack'` — a cast
* that would have sent an email row's channel to the route unchanged. This is the same narrowing
* done for real, so an email row returns early instead.
*/
function chatChannelOf(item: InboxItem): ChatChannel | null {
return item.channel === 'email' ? null : item.channel;
}
/**
* Optimistic per-item state for the unibox list — done, snooze, star, read/unread, restore.
* Done/snooze immediately drop the row from every `inbox.listChannelItems` page (the server
* hides it too, and re-surfaces it when a reply arrives); star and read flip in place. Only
* channel items carry per-item state — email keeps using its own Gmail-backed path. See
* apps/mail/docs/omni-channel-inbox.md Phase 10.
*
* These are the CHANNEL half of the mail-list keyboard actions; `use-channel-hotkey-actions`
* lifts them to the target sets `MailListHotkeys` resolves.
*/
export function useInboxItemActions() {
const trpc = useTRPC();
const queryClient = useQueryClient();
// `mutate`, not the mutation object: react-query keeps `mutate` stable across renders while
// the object is fresh every time, so depending on the object would churn the identity of
// every callback below — and with it every keyboard handler built on them.
const { mutate: setItemState } = useMutation(trpc.inbox.setItemState.mutationOptions());
const { mutate: markRead } = useMutation(trpc.channels.markRead.mutationOptions());
// Channel rows live in the per-channel feed queries now — one key matches all three,
// since the channel is part of the input rather than a separate procedure. Memoized because
// it is built fresh on every call and sits in the dependency list of everything below.
const listKey=[redacted] => trpc.inbox.listChannelItems.infiniteQueryKey(), [trpc]);
const removeFromCache = useCallback(
(itemId: string) => {
queryClient.setQueriesData<InfiniteData>({ queryKey=[redacted] }, (data) => {
if (!data) return data;
return {
...data,
pages: data.pages.map((p) => ({ ...p, items: p.items.filter((it) => it.id !== itemId) })),
};
});
},
[queryClient, listKey],
);
const removeManyFromCache = useCallback(
(itemIds: string[]) => {
const drop = new Set(itemIds);
queryClient.setQueriesData<InfiniteData>({ queryKey=[redacted] }, (data) => {
if (!data) return data;
return {
...data,
pages: data.pages.map((p) => ({ ...p, items: p.items.filter((it) => !drop.has(it.id)) })),
};
});
},
[queryClient, listKey],
);
const patchInCache = useCallback(
(itemId: string, patch: Partial<InboxItem>) => {
queryClient.setQueriesData<InfiniteData>({ queryKey=[redacted] }, (data) => {
if (!data) return data;
return {
...data,
pages: data.pages.map((p) => ({
...p,
items: p.items.map((it) => (it.id === itemId ? { ...it, ...patch } : it)),
})),
};
});
},
[queryClient, listKey],
);
const markDone = useCallback(
async (item: InboxItem) => {
const channel = chatChannelOf(item);
if (!channel) return;
// Slide/collapse the row out first (same animation email rows use), then drop
// it from the feed cache — so the removal reads as a deliberate exit.
await triggerThreadExitAnimation([item.id], 'archive');
removeFromCache(item.id);
setItemState(
{ itemId: item.id, channel, done: true },
{ onError: () => toast.error('Failed to mark done') },
);
},
[removeFromCache, setItemState],
);
const snooze = useCallback(
async (item: InboxItem, until: Date) => {
const channel = chatChannelOf(item);
if (!channel) return;
await triggerThreadExitAnimation([item.id], 'archive');
removeFromCache(item.id);
setItemState(
{ itemId: item.id, channel, snoozedUntil: until.toISOString() },
{ onError: () => toast.error('Failed to snooze') },
);
},
[removeFromCache, setItemState],
);
const setStarred = useCallback(
(item: InboxItem, starred: boolean) => {
const channel = chatChannelOf(item);
if (!channel) return;
patchInCache(item.id, { starred });
setItemState(
{ itemId: item.id, channel, starred },
{ onError: () => patchInCache(item.id, { starred: item.starred }) },
);
},
[patchInCache, setItemState],
);
const toggleStar = useCallback(
(item: InboxItem) => setStarred(item, !item.starred),
[setStarred],
);
/**
* `u` on a chat row — the explicit read/unread override.
*
* A channel's own unread is DERIVED (an unread count, or the source's `is_read`), so there
* is nothing there to flip. `cedar_inbox_item_state.unread` is the override that wins over
* it on both the read and the filter — `effectiveUnreadSql` folds it into the source
* predicate — which is why marking a read Slack channel unread survives the next refetch
* instead of being recomputed away.
*/
const setUnread = useCallback(
(item: InboxItem, unread: boolean) => {
const channel = chatChannelOf(item);
if (!channel) return;
patchInCache(item.id, { unread });
setItemState(
{ itemId: item.id, channel, unread },
{
onSuccess: () => void queryClient.invalidateQueries({ queryKey=[redacted] }),
onError: () => patchInCache(item.id, { unread: item.unread }),
},
);
},
[patchInCache, setItemState, queryClient, listKey],
);
/**
* ⇧E on a chat row — the inverse of Mark done, so a row archived by mistake comes back.
*
* No cache surgery: a done row is not in the feed to patch (the source hides it), and the
* refetch is what brings it back. Idempotent on a row that was never done.
*/
const restore = useCallback(
(item: InboxItem) => {
const channel = chatChannelOf(item);
if (!channel) return;
setItemState(
{ itemId: item.id, channel, done: false },
{
onSuccess: () => void queryClient.invalidateQueries({ queryKey=[redacted] }),
onError: () => toast.error('Failed to restore'),
},
);
},
[setItemState, queryClient, listKey],
);
// Opening a row clears its dot: flip `unread` in the feed cache FIRST (the row
// de-bolds on the same frame the chat opens), then persist. The feed is only
// re-read once the server has the timestamp, so the dot can't flash back.
// Fires even when the row already looks read: a catch-up sync just before this
// can re-light the container server-side (a newly ingested inbound message), and
// the rep is looking right at it.
//
// All three channels, not just Slack (design: channel-sync-architecture.md Phase 3).
// LinkedIn and WhatsApp had no clear of any kind — which is the other half of why a
// LinkedIn chat could sit at 444 unread with 15 stored messages.
const markChannelRead = useCallback(
(item: InboxItem) => {
const ref = item.ref;
// Email has its own Gmail-backed read state and no container.
if (ref.kind === 'email') return;
const containerKey=[redacted] === 'slack' ? ref.slackChannelId : ref.chatId;
patchInCache(item.id, { unread: false });
markRead(
{ channel: ref.kind, containerKey },
{
onSuccess: () => void queryClient.invalidateQueries({ queryKey=[redacted] }),
onError: () => patchInCache(item.id, { unread: item.unread }),
},
);
// And clear any explicit `u` override. The source read alone is not enough: the override
// WINS over it (`effectiveUnreadSql`), so a row the rep had marked unread by hand would
// re-bold on the next refetch — including the refetch this very call schedules.
setItemState({ itemId: item.id, channel: ref.kind, unread: false });
},
[patchInCache, markRead, setItemState, queryClient, listKey],
);
// Batch mark-done for a cross-channel bulk selection — no per-item animation or
// toast (the bulk caller animates all rows together and toasts once). Only channel
// items carry per-item state; email ids in the selection are handled by the caller
// via the Gmail optimistic path.
const markManyDone = useCallback(
(items: InboxItem[]) => {
const chats = items.flatMap((item) => {
const channel = chatChannelOf(item);
return channel ? [{ itemId: item.id, channel }] : [];
});
if (chats.length === 0) return;
removeManyFromCache(chats.map((chat) => chat.itemId));
for (const chat of chats) setItemState({ ...chat, done: true });
},
[removeManyFromCache, setItemState],
);
return {
markDone,
snooze,
toggleStar,
setStarred,
setUnread,
restore,
markChannelRead,
markManyDone,
dropFromFeed: removeFromCache,
};
}