open-output-in-channel.ts4.6 KBView on GitHub
'use client';

/**
 * "Open this draft where it is going" — the click behind an output cell's channel pill.
 *
 * It opens the chat the way the unibox opens it: `/mail/inbox?slack=<containerKey>`, or
 * `?linkedin=` / `?whatsapp=` with the Unipile chat id. All three are the deep-link shape
 * `LayoutUrlSync` projects and restores (`useChannelArtifactParam`), and `mail.tsx` adopts the
 * matching feed filter on the way in. So you land in the same full `ChannelThreadView` you
 * would have reached by clicking that row — history, mention menu, Send and all — rather than
 * in a second, lesser copy of it.
 *
 * That is also why there is no conversation lookup here. The unibox addresses a chat by its OWN
 * key; only the per-deal composer needs a `conversationId`, and requiring one would make the
 * pill dead on every chat that is not linked to a deal. That per-deal path is the sibling
 * definition, `conversations/.../composer/open-slack-draft-in-channel.ts` — use it when you
 * have a conversation, and this one when you only have a chat.
 *
 * ── The draft ──
 *
 * `ChannelThreadView` clears its composer on every row change, so the message is handed over
 * through `seedChannelDraft` and adopted there on mount — keyed by the same container key the
 * URL carries, on every channel. Raw text, `<@U…>` tokens included on Slack: see
 * `pending-channel-draft.ts`.
 *
 * A LinkedIn or WhatsApp composer ALSO persists what it holds (`inbox.saveChannelDraft` writes
 * `linkedin_chats.draft`), so a handed-over draft that the user walks away from mid-edit
 * survives on those two where Slack's would not. The seed is still what carries it across the
 * navigation: the persisted draft is written by the composer, not read into it.
 *
 * ── A sent cell opens the chat, not the draft ──
 *
 * `sentAt` means this message is already out. Seeding it back into the composer would put a
 * fresh copy in the box, one Enter away from being sent twice.
 */

import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { TABLE_OUTPUT_KIND } from '@zero/server/table';

import { seedChannelDraft } from '@/modules/inbox/store/pending-channel-draft';
import { isOutputSent, type CellOutput } from './cell-output';

/**
 * Where a chat opens. The `inbox` folder of the mail chrome — the query param is what names the
 * chat, and `mail.tsx` switches the feed to that channel when it sees one.
 */
const CHANNEL_ROUTE = '/mail/inbox';

/** The URL param each channel is addressed by. Keyed by output kind so a new kind is a type error. */
const CHANNEL_PARAM = {
  [TABLE_OUTPUT_KIND.SLACK]: 'slack',
  [TABLE_OUTPUT_KIND.LINKEDIN]: 'linkedin',
  [TABLE_OUTPUT_KIND.WHATSAPP]: 'whatsapp',
} as const;

/** An output cell that names a chat this app can open, resolved to its address. */
export interface OpenableOutput {
  param: (typeof CHANNEL_PARAM)[keyof typeof CHANNEL_PARAM];
  /** The provider's container id — a Slack channel id, or a Unipile chat id. */
  containerKey=[redacted];
  message: string;
  sent: boolean;
}

/**
 * The chat this cell would open, or null.
 *
 * Null for an email or a file (nowhere to go), and — importantly — for a LinkedIn or WhatsApp
 * draft addressed only by `attendeeProviderId` / `phoneE164`. Those can be SENT, because the
 * provider will open the conversation on the way, but there is no chat to open yet, so the
 * pill stays a plain one rather than navigating to a chat id that does not exist.
 */
export function openableOutput(output: CellOutput): OpenableOutput | null {
  if (output.kind === TABLE_OUTPUT_KIND.SLACK) {
    if (!output.channelId) return null;
    return {
      param: CHANNEL_PARAM[TABLE_OUTPUT_KIND.SLACK],
      containerKey=[redacted],
      message: output.message ?? '',
      sent: isOutputSent(output),
    };
  }
  if (output.kind === TABLE_OUTPUT_KIND.LINKEDIN || output.kind === TABLE_OUTPUT_KIND.WHATSAPP) {
    if (!output.chatId) return null;
    return {
      param: CHANNEL_PARAM[output.kind],
      containerKey=[redacted],
      message: output.message ?? '',
      sent: isOutputSent(output),
    };
  }
  return null;
}

export interface OpenOutputInChannel {
  /** Hands the draft to the chat's composer and lands there. */
  open: (target: OpenableOutput) => void;
}

export function useOpenOutputInChannel(): OpenOutputInChannel {
  const navigate = useNavigate();

  const open = useCallback(
    (target: OpenableOutput) => {
      if (!target.sent) seedChannelDraft(target.containerKey, target.message);
      void navigate(
        `${CHANNEL_ROUTE}?${target.param}=${encodeURIComponent(target.containerKey)}`,
      );
    },
    [navigate],
  );

  return { open };
}