hotkey-targets.ts3.4 KBView on GitHub
/**
 * Splitting one keyboard target set into its email half and its chat half.
 *
 * Every mail-list hotkey resolves the SAME target set — the hovered row, else the bulk
 * selection, else the row `j`/`k` left selected — and in the unified inbox that set can hold
 * rows from four channels at once. What it may NOT do is hand a channel row to the Gmail
 * driver: a LinkedIn row's selection id is `li:<chatId>`, and `optimisticToggleStar` on it
 * issues a Gmail modify for a thread that does not exist. That is what `x`, `u`, `i`, `h` and
 * `#` all did on a chat row, silently.
 *
 * The rule is two lines, and the ORDER matters. An id carrying a channel prefix is a chat,
 * whether or not the feed can still resolve it: selection and hover state outlive a feed
 * replacement, so a lookup miss is an ordinary event, and "it did not resolve, so it must be
 * email" is how a `li:` id reaches a Gmail modify for a thread that does not exist. Only then:
 * an id the loaded feed knows as a non-email row is a chat. Everything else is a Gmail thread
 * id — safely, because a Gmail thread id is hex and can never hold a colon.
 *
 * An id that is a chat but does not resolve is dropped from BOTH lists. There is no row to
 * act on, and no driver that could act on it.
 *
 * Outside the unibox the feed is empty and no id carries a prefix, so every id falls through
 * to email and the mail list behaves exactly as it always has.
 */

import type { InboxItem } from '@/modules/inbox/types';

export interface HotkeyTargets {
  /** Every targeted selection id, in list order — what an exit animation plays over. */
  ids: string[];
  /** The Gmail thread ids among them. */
  emailIds: string[];
  /** The LinkedIn / WhatsApp / Slack rows among them. */
  channelItems: InboxItem[];
}

export const NO_TARGETS: HotkeyTargets = { ids: [], emailIds: [], channelItems: [] };

/**
 * The channel prefixes `services/inbox/feed-core.ts` mints (`li:<chatId>`, `wa:<chatId>`,
 * `slack:<ws>:<channel>`). An email row's selection id is a BARE Gmail thread id, which is
 * hex and therefore cannot collide with any of these.
 */
const CHANNEL_ID_PREFIXES = ['li:', 'wa:', 'slack:'];

export function isChannelSelectionId(id: string): boolean {
  return CHANNEL_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
}

/**
 * @param ids selection ids (email → bare Gmail threadId, chat → `InboxItem.id`)
 * @param lookup the loaded feed's by-selection-id resolver (`store.getInboxItem`)
 */
export function splitHotkeyTargets(
  ids: string[],
  lookup: (id: string) => InboxItem | undefined,
): HotkeyTargets {
  const emailIds: string[] = [];
  const channelItems: InboxItem[] = [];
  for (const id of ids) {
    const item = lookup(id);
    if (item) {
      if (item.channel === 'email') emailIds.push(id);
      else channelItems.push(item);
      continue;
    }
    // Unresolved. Its PREFIX still says what it is, and that is the half that matters:
    // a chat id must never fall through to the Gmail driver just because the feed moved on.
    if (!isChannelSelectionId(id)) emailIds.push(id);
  }
  return { ids, emailIds, channelItems };
}

/** Human name for a chat channel, for the one action that has to say it cannot. */
export function channelLabel(item: InboxItem): string {
  switch (item.channel) {
    case 'linkedin':
      return 'LinkedIn';
    case 'whatsapp':
      return 'WhatsApp';
    case 'slack':
      return 'Slack';
    default:
      return item.channel;
  }
}