use-open-channel-item.ts8.3 KBView on GitHub
import { useCallback, useEffect, type SetStateAction } from 'react';
import { create } from 'zustand';

import { useCedarStore } from '@/modules/store';
import { selectLoadedInboxItems } from '@/modules/inbox/store/inboxSlice';
import type { CedarStore } from '@/modules/store/CedarStoreTypes';
import type { InboxItem } from '@/modules/inbox/types';
import {
  CHANNEL_CONTEXT_KINDS,
  type ContextKind,
} from '@/modules/cedar-os/src/store/messages/MessageTypes';

/**
 * The display artifact a feed row opens — kind + container key — or null for email.
 *
 * All three chat channels get one. This used to be Slack-only, on the reasoning that Slack was
 * the one with a URL; but the artifact is not just an address, it is what `artifactToContext`
 * reads to decide the context column and, through it, the empty chat's surface. A LinkedIn chat
 * that put nothing in the slot therefore opened with the *mail* surface beside it, so the
 * counterpart profile card — declared on the `channelChat` surface — could never appear. Setting
 * the artifact for every channel is what makes the panel match what is on screen.
 *
 * The key is the PROVIDER's container id, matching `inbox.channelItem`'s `containerKey`: the
 * Slack channel id (`C…`/`D…`) or the Unipile chat id.
 */
export function channelArtifact(item: InboxItem): { kind: ContextKind; id: string } | null {
  const ref = item.ref;
  if (ref.kind === 'email') return null;
  const id = ref.kind === 'slack' ? ref.slackChannelId : ref.chatId;
  return id ? { kind: CHANNEL_CONTEXT_KINDS[ref.kind], id } : null;
}

/** The Slack channel id a feed row opens, or null. Kept for the `?slack=` restore path. */
export function slackContainerKey(item: InboxItem): string | null {
  return item.ref.kind === 'slack' ? item.ref.slackChannelId : null;
}

/** The feed item for a Slack container key, or undefined when it isn't loaded. */
export function findSlackItem(
  items: readonly InboxItem[],
  containerKey=[redacted],
): InboxItem | undefined {
  return items.find((item) => slackContainerKey(item) === containerKey);
}

/** The open Slack channel per the display artifact, or null when something else is open. */
export function selectSlackArtifactId(state: CedarStore): string | null {
  const artifact = state.getDisplayArtifact();
  return artifact?.kind === 'slack_thread' ? artifact.id : null;
}

/** Every artifact kind a feed row can open — the three chat channels. */
const CHANNEL_ARTIFACT_KINDS: ReadonlySet<string> = new Set(Object.values(CHANNEL_CONTEXT_KINDS));

/**
 * The open channel chat as a `kind:id` STRING, or null.
 *
 * A primitive by contract, for the reason spelled out on `selectEmptyChatSurface`: consumers
 * subscribe with a bare `useCedarStore(selector)` and no equality function, and
 * `getDisplayArtifact()` allocates a fresh object every call — so returning the artifact itself
 * would re-render on every store change.
 */
export function selectChannelArtifactKey(state: CedarStore): string | null {
  const artifact = state.getDisplayArtifact();
  if (!artifact || !CHANNEL_ARTIFACT_KINDS.has(artifact.kind)) return null;
  return `${artifact.kind}:${artifact.id}`;
}

/** The same `kind:id` key for a feed row, so the two sides can be compared as strings. */
export function channelArtifactKey(item: InboxItem): string | null {
  const artifact = channelArtifact(item);
  return artifact ? `${artifact.kind}:${artifact.id}` : null;
}

export interface OpenChannelItem {
  /** The chat rendered full-screen over the feed, or null for none. */
  openChannelItem: InboxItem | null;
  /** Open a feed row's chat (a row click). */
  openChannel: (item: InboxItem) => void;
  /** Close it (the view's own close button / Escape). */
  closeChannel: () => void;
}

/**
 * Which chat is open, as MODULE state rather than the view's own `useState`.
 *
 * There is one open chat in the app, and the things that open it are not all inside the
 * component that renders it. `MailListHotkeys` is mounted once at the root (see
 * HotkeyProviderWrapper) — it can never receive `openChannel` as a prop — and `r` on a
 * LinkedIn row has to open that row's chat from there. A ref registry would do it; a store
 * says the same thing without the indirection, and keeps the Slack mirror below in one place.
 */
interface OpenChannelChatStore {
  item: InboxItem | null;
  setItem: (next: SetStateAction<InboxItem | null>) => void;
}

const useOpenChannelChatStore = create<OpenChannelChatStore>((set) => ({
  item: null,
  setItem: (next) =>
    set((state) => ({ item: typeof next === 'function' ? next(state.item) : next })),
}));

/**
 * Open a feed row's chat from anywhere — the row click, or a hotkey fired from the root.
 *
 * Every chat channel rides the display artifact, which does two jobs: it is the address the URL
 * projects (`?slack=` / `?linkedin=` / `?whatsapp=`), and it is what the context resolver reads
 * to put the right surface in the chat column beside the chat. `ChannelThreadView` focuses its
 * composer on mount and on every row change, so "open the chat" IS "put the cursor in the reply
 * box" — nothing else has to ask for focus.
 */
export function openChannelChat(item: InboxItem): void {
  useOpenChannelChatStore.getState().setItem(item);
  const artifact = channelArtifact(item);
  if (artifact) useCedarStore.getState().setSelectedArtifact(artifact);
}

/** Test seam: drop the open chat without touching the artifact. */
export function resetOpenChannelChat(): void {
  useOpenChannelChatStore.getState().setItem(null);
}

/**
 * Which channel chat the unibox has open.
 *
 * The open chat lives in the module store above for LinkedIn/WhatsApp; for SLACK it is
 * additionally mirrored onto the display
 * artifact (`{kind:'slack_thread', id:<channelId>}`), which `LayoutUrlSync` projects to
 * `?slack=<containerKey>`. That mirror is what makes an open Slack channel survive a reload,
 * a shared link and the back button — as plain component state it died with the component, so
 * a Slack channel was the one openable surface in the app with no address.
 *
 * The artifact is the source of truth in the restore direction: the effect below re-opens
 * whatever the URL put there once the feed carries it, so a cold deep-link (`/mail?slack=C123`,
 * where the store is empty and the first feed page has not landed yet) opens as soon as it can.
 */
export function useOpenChannelItem(): OpenChannelItem {
  const openChannelItem = useOpenChannelChatStore((state) => state.item);
  const setOpenChannelItem = useOpenChannelChatStore((state) => state.setItem);

  const setSelectedArtifact = useCedarStore((state) => state.setSelectedArtifact);
  const getDisplayArtifact = useCedarStore((state) => state.getDisplayArtifact);
  const channelArtifactKeyOpen = useCedarStore(selectChannelArtifactKey);
  const loadedItems = useCedarStore(selectLoadedInboxItems);

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

  const closeChannel = useCallback(() => {
    setOpenChannelItem(null);
    // Still kind-guarded, now across all three channels: closing a chat must not clear a
    // conversation or email thread that something else put in the single display slot.
    const kind = getDisplayArtifact()?.kind;
    if (kind && CHANNEL_ARTIFACT_KINDS.has(kind)) setSelectedArtifact(null);
  }, [setOpenChannelItem, getDisplayArtifact, setSelectedArtifact]);

  useEffect(() => {
    // Nothing, or a non-channel artifact, holds the slot — back button, or a conversation /
    // email thread took it. Drop whichever chat we had open, since the artifact is the record
    // of what is open for all three channels now.
    if (!channelArtifactKeyOpen) {
      setOpenChannelItem((current) => (current && channelArtifactKey(current) ? null : current));
      return;
    }

    setOpenChannelItem((current) => {
      if (current && channelArtifactKey(current) === channelArtifactKeyOpen) return current;
      // Not in the feed yet — a deep-link that landed before the first page, or a chat that
      // paginated out. Keep what is on screen and retry when the feed next changes, rather
      // than blanking a chat the user is reading.
      return (
        loadedItems.find((item) => channelArtifactKey(item) === channelArtifactKeyOpen) ?? current
      );
    });
  }, [channelArtifactKeyOpen, loadedItems, setOpenChannelItem]);

  return { openChannelItem, openChannel, closeChannel };
}