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