pending-channel-draft.ts5.7 KBView on GitHub
'use client';

/**
 * A message handed to a channel's composer by something that is not the channel.
 *
 * `ChannelThreadView` owns its reply as plain local state and clears it on every row change, so
 * a surface that wants to say "open #cedar-rform WITH this in the box" has nowhere to put the
 * text — it navigates, the view mounts, and the effect that resets the draft wins. This is the
 * handoff, and it is deliberately the same shape `useComposerDraft`'s `seedComposerDoc` /
 * `takeComposerDoc` pair has for the conversation composer: seed by key, consume ONCE.
 *
 * Consumed once, because the draft belongs to the click that sent you here and not to the
 * channel. Re-opening the same channel later must show an empty box, not resurrect a message
 * you already looked at and walked away from.
 *
 * ── One slot, and it expires ──
 *
 * A single slot rather than a map keyed by channel, because a handoff is a click and there is
 * only ever one of those in flight. A map let a seed that was never collected sit there for the
 * whole session — and a seed IS routinely never collected: `useOpenChannelItem` only mounts the
 * thread view once the feed carries a row for that container, so a channel with no synced
 * messages, or one that has paginated out, navigates to an inbox that opens nothing. The text
 * then surfaced later in a composer the user had not asked for it in, one Enter from being
 * posted. The TTL is what makes "consumed once" true even when nothing consumes it.
 *
 * Keyed by the CONTAINER key — a Slack channel id (`C…`/`D…`) or a Unipile chat id, the same
 * key `?slack=` / `?linkedin=` / `?whatsapp=` carries and the same one `channelArtifact` reads
 * off a feed row — so the text and the chat that opens cannot disagree.
 *
 * On Slack the value is RAW mrkdwn, `<@U…>` tokens included: that is exactly what the composer
 * holds while you type (`insertSlackMention` writes the token, `handleSend` posts the string
 * unchanged), so anything decoded on the way in would be posted as text that notifies nobody.
 * The other two channels have no such grammar, and their composers hold plain text.
 *
 * ── It is still a handoff, not the stored draft ──
 *
 * LinkedIn and WhatsApp composers PERSIST what they hold (`inbox.saveChannelDraft` →
 * `linkedin_chats.draft`), and this is not that. This slot is the message that belongs to the
 * click that navigated here; the persisted draft is what the composer writes once it has it.
 */

import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { create } from 'zustand';

/**
 * How long a seeded draft stays claimable.
 *
 * Long enough to cover navigate → route change → first feed page → mount on a cold cache, and
 * short enough that a handoff whose channel never appeared is gone before the user could open
 * that channel by hand and be surprised by it.
 */
const SEED_TTL_MS = 60_000;

interface PendingChannelDraft {
  containerKey=[redacted];
  text: string;
  seededAt: number;
}

interface PendingChannelDraftStore {
  pending: PendingChannelDraft | null;
  seed: (containerKey=[redacted], text: string) => void;
  take: (containerKey=[redacted] => string | null;
}

const usePendingChannelDraftStore = create<PendingChannelDraftStore>((set, get) => ({
  pending: null,
  // Overwrites rather than merges: a second click supersedes the first, and the one it
  // supersedes was headed somewhere the user has already left.
  seed: (containerKey, text) => set({ pending: { containerKey, text, seededAt: Date.now() } }),
  take: (containerKey) => {
    const pending = get().pending;
    if (!pending) return null;
    if (Date.now() - pending.seededAt > SEED_TTL_MS) {
      set({ pending: null });
      return null;
    }
    if (pending.containerKey !== containerKey) return null;
    set({ pending: null });
    return pending.text;
  },
}));

/** Queue a message to land in this channel's composer the next time it opens. */
export function seedChannelDraft(containerKey=[redacted], text: string): void {
  usePendingChannelDraftStore.getState().seed(containerKey, text);
}

/** Consume (read + clear) the queued message for a channel, if there is one and it is fresh. */
export function takeChannelDraft(containerKey=[redacted] string | null {
  return usePendingChannelDraftStore.getState().take(containerKey);
}

/**
 * A channel view's composer text, adopting a handoff on the way in.
 *
 * Owns the clear-on-row-change every other piece of per-chat state in `ChannelThreadView` has
 * (`historyLimit`, `pending`): an unsent reply to A must not still be on screen when you open
 * B, where `persistDraft` would save A's words as B's draft.
 *
 * The `claimedFor` ref is not belt-and-braces. StrictMode invokes a mount effect TWICE on the
 * same instance, and `takeChannelDraft` is destructive — so without it the second pass finds
 * nothing and clears the draft the first pass just adopted, and the feature never works in dev.
 * The same trap is documented on `cell-selection.ts`'s edit seed, and it is why
 * `UniversalComposer` consumes its own seed from `onEditorCreated` rather than from an effect.
 *
 * @param rowKey identifies the opened row — this view stays mounted while you move between them
 * @param containerKey the Slack container, or null for a channel with no handoff address
 */
export function useChannelDraft(
  rowKey=[redacted],
  containerKey=[redacted] | null,
): [string, Dispatch<SetStateAction<string>>] {
  const [draft, setDraft] = useState('');
  const claimedFor = useRef<string | null>(null);
  const claim = `${rowKey} ${containerKey ?? ''}`;

  useEffect(() => {
    if (claimedFor.current === claim) return;
    claimedFor.current = claim;
    setDraft((containerKey && takeChannelDraft(containerKey)) || '');
  }, [claim, containerKey]);

  return [draft, setDraft];
}