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