use-channel-hotkey-actions.ts5.3 KBView on GitHub
/**
 * The CHAT half of every mail-list keyboard action.
 *
 * `MailListHotkeys` resolves one target set and then acts on it twice: Gmail ids go down the
 * optimistic email path, and LinkedIn / WhatsApp / Slack rows come here. Before this existed
 * the unibox had no chat half at all — the hover ref the key handlers consult only ever holds
 * an email id, so `r` on a LinkedIn row reported "No emails to select", and anything reached
 * through the bulk selection instead handed a `li:…` id straight to Gmail.
 *
 * ── What each key means for a chat ──
 *
 * A chat is not a mail thread, so three of the keys have to be TRANSLATED rather than ported.
 * The translations, and why:
 *
 * - **Spam / trash / archive all collapse into Mark done.** A chat has no spam folder and no
 *   deletion — the messages live in Slack or LinkedIn, not here. Every "get it out of my
 *   inbox" key therefore means the one thing the inbox can do about it, and the server
 *   re-surfaces the row when the counterpart replies.
 * - **Mark important stars.** `cedar_inbox_item_state` has exactly one flag for a chat, and
 *   this is it. `i` sets it (matching email's set-true semantics) where `x` toggles.
 * - **Reply, reply-all and Enter are one action: open the chat.** There is a single composer
 *   at the bottom of a chat, addressed to the one counterpart, and `ChannelThreadView` focuses
 *   it on open — so "reply" IS "open it", and reply-all has nobody extra to add.
 *
 * Forward is the one key with no honest translation: it needs a destination the feed cannot
 * supply. It says so rather than doing something adjacent.
 */

import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';

import { useInboxItemActions } from '@/modules/inbox/hooks/use-inbox-item-actions';
import { openChannelChat } from '@/modules/inbox/hooks/use-open-channel-item';
import { channelLabel } from '@/modules/inbox/lib/hotkey-targets';
import type { InboxItem } from '@/modules/inbox/types';

export interface ChannelHotkeyActions {
  /** e / ! / w / # — out of the inbox. No exit animation: the caller plays one over every target. */
  markDone: (items: InboxItem[]) => void;
  /** ⇧E — the inverse of Mark done. */
  restore: (items: InboxItem[]) => void;
  /** ⇧I — read, at the source and as an explicit override. */
  markRead: (items: InboxItem[]) => void;
  /** u — flip read/unread, on the same "any unread → read them all" rule email uses. */
  toggleRead: (items: InboxItem[]) => void;
  /** i — the one flag a chat row has. */
  star: (items: InboxItem[]) => void;
  /** x — toggle it, on the same "any starred → unstar them all" rule email uses. */
  toggleStar: (items: InboxItem[]) => void;
  /** h — the remind dialog's pick, which for a chat is a snooze. */
  snooze: (items: InboxItem[], until: Date) => void;
  /** r / ⇧R / Enter — open the chat, which lands the cursor in its composer. */
  openChat: (item: InboxItem) => void;
  /** f — say what cannot be done, rather than doing nothing. */
  refuse: (items: InboxItem[], action: string) => void;
}

export function useChannelHotkeyActions(): ChannelHotkeyActions {
  const {
    markManyDone,
    restore: restoreItem,
    markChannelRead,
    setUnread,
    setStarred,
    snooze: snoozeItem,
  } = useInboxItemActions();

  const markDone = useCallback(
    (items: InboxItem[]) => {
      if (items.length > 0) markManyDone(items);
    },
    [markManyDone],
  );

  const restore = useCallback((items: InboxItem[]) => items.forEach(restoreItem), [restoreItem]);

  const markRead = useCallback(
    (items: InboxItem[]) => items.forEach(markChannelRead),
    [markChannelRead],
  );

  const toggleRead = useCallback(
    (items: InboxItem[]) => {
      // One decision for the whole set, taken from the set — the same rule `toggleReadUnread`
      // applies to threads, so a mixed selection does not half-flip.
      const anyUnread = items.some((item) => item.unread);
      for (const item of items) {
        if (anyUnread) markChannelRead(item);
        else setUnread(item, true);
      }
    },
    [markChannelRead, setUnread],
  );

  const star = useCallback(
    (items: InboxItem[]) => items.forEach((item) => setStarred(item, true)),
    [setStarred],
  );

  const toggleStar = useCallback(
    (items: InboxItem[]) => {
      const anyStarred = items.some((item) => item.starred);
      for (const item of items) setStarred(item, !anyStarred);
    },
    [setStarred],
  );

  const snooze = useCallback(
    (items: InboxItem[], until: Date) => items.forEach((item) => void snoozeItem(item, until)),
    [snoozeItem],
  );

  const openChat = useCallback((item: InboxItem) => openChannelChat(item), []);

  const refuse = useCallback((items: InboxItem[], action: string) => {
    const first = items[0];
    if (!first) return;
    const where = items.every((item) => item.channel === first.channel)
      ? `${channelLabel(first)} chats`
      : 'chats';
    toast.info(`${action} isn't available for ${where}.`);
  }, []);

  // One stable object: `MailListHotkeys` puts it in the dependency list of every handler, and
  // those handlers are what `useShortcuts` re-registers when they change.
  return useMemo(
    () => ({ markDone, restore, markRead, toggleRead, star, toggleStar, snooze, openChat, refuse }),
    [markDone, restore, markRead, toggleRead, star, toggleStar, snooze, openChat, refuse],
  );
}