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