channel-availability.ts2.6 KBView on GitHub
import type { InboxChannel, InboxChannelFilter } from '@/modules/inbox/types';

/**
 * Which channel badges the ACTIVE TAB can actually answer.
 *
 * The channel badge and the inbox tab are orthogonal filters over one feed, and they compose
 * everywhere except at one intersection, where the product is empty. A tab whose rule is a Gmail
 * query — GitHub, Marketing, Agent Drafts, and the Important / Other stubs — resolves through
 * `participatingChannels` to email alone: a Slack source cannot evaluate `label:"…"`, so it is
 * gated out rather than allowed to contribute its whole unfiltered stream. Correct for `all`.
 * But the client then narrowed that same set by an explicit badge, and
 * `['email'].filter(c => c === 'slack')` is `[]` — no channel participated, the merge answered
 * `ready: true` with no rows, and the list stated "No slack messages." over a mirror holding 38.
 *
 * An empty intersection is not a result worth rendering, so it is not offered: the badge is
 * disabled while the tab cannot answer it. See apps/mail/docs/bug-inbox-channel-filter-empty.md.
 */

/**
 * Can this tab answer this badge?
 *
 * `participating` is the RESOLVED set from `inbox.getFeedScope` — already through the shared
 * `participatingChannels` rule, so this asks the server's answer rather than re-deriving it.
 * `undefined` means the scope has not resolved yet, which is "not known", not "not allowed":
 * disabling on a pending scope would flicker the options out and back on every tab change.
 *
 * `all` is always answerable — on a mail-only tab it means email, which is what that tab is.
 * `email` needs no special case: `participatingChannels` returns it unconditionally.
 */
export function isChannelFilterAvailable(
  filter: InboxChannelFilter,
  participating: readonly InboxChannel[] | undefined,
): boolean {
  if (!participating) return true;
  if (filter === 'all') return true;
  return participating.includes(filter);
}

/**
 * The badge to actually apply, given what this tab can answer.
 *
 * Disabling the menu item closes the front door; this closes the others. A chat badge reaches a
 * mail-only tab without the menu too — `?channel=slack` in a shared link, or the remembered pick
 * restored from localStorage, followed by a click onto a Gmail-query tab. Both land in the same
 * dead end, so an unanswerable badge resolves to `all`, which every tab can answer.
 */
export function resolveChannelFilter(
  filter: InboxChannelFilter,
  participating: readonly InboxChannel[] | undefined,
): InboxChannelFilter {
  return isChannelFilterAvailable(filter, participating) ? filter : 'all';
}