diff --git a/README.md b/README.md index ee9c145..dac986d 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,10 @@ export function MyBuilderPage() { | `loadChannels` | `() => Promise<{ id: string; name: string }[]>` | yes | Returns channels available to send to. The package never makes Slack API calls itself. | | `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | yes | Whether the current user has a Slack user-token and can post as themselves. If `canSendAsUser` is false, `oauthUrl` is shown as a "Sign in with Slack" link. | | `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | yes | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser }`. | +| `editing` | `{ onLoadMessage, onUpdate, loadRecentMessages? }` | no | Opt-in edit mode. When present, the toolbar exposes "Edit message": the user pastes a Slack message link, `onLoadMessage({ link })` returns a host-computed [editability verdict](#editing-an-existing-message-opt-in), and a successful load flips the primary action to "Update message" wired to `onUpdate`. Pass `loadRecentMessages` to add a "recent messages from this app" picker beside the paste input. Omit `editing` to keep send-only behavior. | +| `loadButtonLabel` | `string` | no | Label + accessible name for the toolbar button that opens the load-message dialog (the edit-mode entry point). Defaults to `'Load message'`. Only shown when `editing` is set and no message is loaded. | +| `updateButtonLabel` | `string` | no | Label for the toolbar's primary button while a message is loaded for editing. It's a split button: clicking it updates the message in place; the menu beside it also offers "Send as a new message" (post the current blocks as new). Defaults to `'Review & update'`. | +| `confirmUpdateLabel` | `string` | no | Label for the update dialog's final confirm button. Defaults to `'Update message'` (shows `'Updating…'` while in flight). | | `previewHooks` | `PreviewHooks` | no | Hooks forwarded to `slack-blocks-to-jsx`'s `` for resolving user / channel / emoji directives. | | `customEmojis` | `CustomEmoji[]` | no | Workspace custom emoji (`{ name, url, alias }`) the preview resolves. Entries with a `url` render `:name:` as the workspace image; alias entries (`url: null`) fall back to their target emoji. Render-only — never serialized into the emitted Block Kit JSON. A caller-supplied `previewHooks.emoji` takes precedence. | | `palette` | `PaletteSection[]` | no | The left-hand palette of draggable variants. Defaults to `defaultPalette`. Spread it to filter, reorder, or add your own pre-configured variants — see [Customizing the palette](#customizing-the-palette). | @@ -119,10 +123,70 @@ export function MyBuilderPage() { | `showThemeControl` | `boolean` | no | Defaults to `true`. When `false`, the toolbar's light/dark toggle is hidden and the theme stays at `defaultPreviewTheme`. Ignored when `previewTheme` is set (a controlled theme always hides the toggle). | | `defaultPreviewTheme` | `'light' \| 'dark'` | no | Initial (uncontrolled) preview theme. Pass the host app's current theme so the preview opens matched to the consuming app's appearance. Ignored when `previewTheme` is provided. | | `previewTheme` | `'light' \| 'dark'` | no | Controlled preview theme. When set, the preview renders in this theme, follows it reactively, and the toolbar's light/dark toggle is hidden so the host app fully owns the theme. Leave unset to keep the preview uncontrolled (seeded from `defaultPreviewTheme`, toggle shown). | -| `sendButtonLabel` | `string` | no | Label and accessible name for the toolbar's Send button (which opens the send dialog). Defaults to `'Send'`. Use it to signal that a configuration step follows, e.g. `'Send to channel…'`. | +| `sendButtonLabel` | `string` | no | Label and accessible name for the toolbar's Send button (which opens the send dialog). Defaults to `'Review & send'` (the dialog is the review step). Override it for product-specific copy, e.g. `'Send to channel…'`. | | `confirmSendLabel` | `string` | no | Label for the send dialog's final confirm button. Defaults to `'Send'` (shows `'Sending…'` while in flight). | | `theme` | `BrandTheme \| BrandPreset` | no | Branding tokens applied to the builder chrome (toolbar, palette, popovers, dialogs). Accepts a `Partial` map and optional `light`/`dark` overrides. See [Styling](#styling) below. | +## Editing an existing message (opt-in) + +By default the builder is send-only. Pass `editing` to let users load an +already-posted message, edit its blocks, and dispatch a `chat.update`. The +package stays integration-agnostic: it makes no Slack calls and computes +nothing about who can edit; the host does both. + +```tsx + { + const msg = await fetchMessageFromPermalink(link); // your code + if (!msg) return { ok: false, reason: "Couldn't find that message." }; + if (!msg.blocks?.length) return { ok: false, reason: 'This message has no editable blocks.' }; + // `username` + `iconUrl` are optional; when present they show in the + // preview header instead of the generic workspace name/avatar. + const author = { username: msg.authorName, iconUrl: msg.authorAvatarUrl }; + if (msg.appId === MY_APP_ID) + return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'bot', ...author }; + if (msg.userId === currentUserId) + return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'user', ...author }; + return { ok: false, reason: 'Only messages your app or you posted can be edited.', blocks: msg.blocks }; + }, + // Sibling to onSend; carries the source channel + ts. `asUser` follows + // the verdict's `editableVia`. + onUpdate: async ({ channelId, ts, blocks, asUser }) => { + await chatUpdate({ channel: channelId, ts, blocks, asUser }); // your code + return { ok: true }; + }, + // Optional: adds a "recent messages from this app" picker beside the paste + // input. These are editable-by-construction (the app authored them), so + // picking one loads it straight into edit mode (no verdict needed). + loadRecentMessages: async () => { + const msgs = await fetchRecentAppMessages(); // your code + return msgs.map((m) => ({ + channelId: m.channel, + channelName: m.channelName, + ts: m.ts, + blocks: m.blocks, + editableVia: 'bot', // defaults to 'bot' if omitted + label: m.preview // one-line preview shown in the picker row + })); + } + }} +/> +``` + +- On `ok`, the builder hydrates with `blocks`, shows an edit-mode badge, locks + the destination to the source channel, and fixes the post-as identity to + `editableVia` (the `'user'` path reuses `loadSendAsUserStatus` for the "Sign + in with Slack" gate). +- On `{ ok: false, reason }`, the load dialog renders the reason inline and + offers **Open as a new message instead**. Pass `blocks` on the failure + result to hydrate the draft for that fallback. + ## Customizing the palette The default palette ships with curated presets for every supported block type. To narrow what's available, or add your own pre-configured variants (e.g. a "Help footer" section), pass a `palette` array. Define it at module scope (or wrap in `useMemo`) so it stays referentially stable across renders. diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 39787a7..ea7cf8f 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -2,19 +2,27 @@ import { BlockKitchen, type BrandPreset, type ChannelOption, + type LoadMessageInput, + type LoadResult, + type RecentMessage, type SendAsUserStatus, type SendPayload, type SendResult, type SupportedBlock, type Template, - TemplatePicker + TemplatePicker, + type UpdatePayload, + type UpdateResult } from '@tightknitai/block-kitchen'; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, + useCallback, useEffect, + useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { demoTemplates } from './templates'; const PRESET_OPTIONS: { value: BrandPreset; label: string }[] = [ @@ -34,6 +42,113 @@ const MOCK_CHANNELS: ChannelOption[] = [ { id: 'C0005', name: 'product' } ]; +// --- Edit-mode demo: in-memory "already-posted" message store --------------- +// +// Everything below mocks the host side of the package's opt-in edit mode so +// every editability branch is demonstrable with no backend. A real host would +// parse the pasted permalink, call `conversations.replies`, and compute the +// same verdict; here we look the message up in this store instead. + +type MessageAuthor = 'bot' | 'you' | 'someoneElse'; + +interface StoredMessage { + ts: string; + channelId: string; + channelName: string; + author: MessageAuthor; + // `non-block` / `edit-window-closed` reproduce the "sharp edges": a message + // that doesn't round-trip through the editor, or one past Slack's edit window. + kind: 'normal' | 'non-block' | 'edit-window-closed'; + blocks: SupportedBlock[]; +} + +const WORKSPACE_NAME = 'Acme Inc.'; + +// Display identity per author, surfaced as the preview header's username + icon +// when a message is loaded for editing. +const AUTHOR_IDENTITY: Record = { + bot: { username: 'Acme Bot', iconUrl: 'https://api.dicebear.com/9.x/identicon/png?seed=acme-bot&backgroundColor=4a154b' }, + you: { username: 'Riley Park', iconUrl: 'https://api.dicebear.com/9.x/identicon/png?seed=riley&backgroundColor=2eb67d' }, + someoneElse: { username: 'Jordan Lee', iconUrl: 'https://api.dicebear.com/9.x/identicon/png?seed=jordan&backgroundColor=e01e5a' } +}; + +// Slack's "Copy link" permalink shape: …/archives//p. +function permalinkFor(msg: Pick): string { + return `https://acme.slack.com/archives/${msg.channelId}/p${msg.ts.replace('.', '')}`; +} + +// Best-effort one-line preview for the recent-messages picker: first block +// that carries text wins. +function previewOf(blocks: SupportedBlock[]): string { + for (const b of blocks) { + if ((b.type === 'header' || b.type === 'section') && 'text' in b && b.text && 'text' in b.text) { + return b.text.text; + } + } + return `${blocks.length} block${blocks.length === 1 ? '' : 's'}`; +} + +function sampleBlocks(text: string): SupportedBlock[] { + return [ + { type: 'header', text: { type: 'plain_text', text } }, + { type: 'section', text: { type: 'mrkdwn', text: `Loaded from the store at *${text}*. Edit me and update.` } } + ]; +} + +// One fixture per outcome the verdict logic can produce. +const SEED_MESSAGES: StoredMessage[] = [ + { + ts: '1718000042.000100', + channelId: 'C0003', + channelName: 'engineering', + author: 'bot', + kind: 'normal', + blocks: sampleBlocks('Bot message') + }, + { + ts: '1718000099.000200', + channelId: 'C0001', + channelName: 'general', + author: 'you', + kind: 'normal', + blocks: sampleBlocks('Your message') + }, + { + ts: '1718000150.000300', + channelId: 'C0002', + channelName: 'random', + author: 'someoneElse', + kind: 'normal', + blocks: sampleBlocks("Someone else's message") + }, + { + ts: '1718000200.000400', + channelId: 'C0004', + channelName: 'design', + author: 'bot', + kind: 'non-block', + blocks: [] + }, + { + ts: '1718000250.000500', + channelId: 'C0005', + channelName: 'product', + author: 'you', + kind: 'edit-window-closed', + blocks: sampleBlocks('Old message') + } +]; + +// Match a pasted permalink to a stored message. Tolerant of extra query/thread +// params: compare on the `p` segment, falling back to a digit match. +function findMessageByLink(store: StoredMessage[], link: string): StoredMessage | undefined { + const digits = link.replace(/\D/g, ''); + return store.find((m) => { + const tsDigits = m.ts.replace('.', ''); + return digits.includes(tsDigits) || link.trim() === permalinkFor(m); + }); +} + const INITIAL_BLOCKS: SupportedBlock[] = [ { type: 'header', @@ -54,23 +169,6 @@ async function loadChannels(): Promise { return MOCK_CHANNELS; } -async function loadSendAsUserStatus(): Promise { - await new Promise((r) => setTimeout(r, 150)); - return { canSendAsUser: true }; -} - -async function onSend(payload: SendPayload): Promise { - await new Promise((r) => setTimeout(r, 400)); - console.log('[demo] onSend called with', payload); - const channel = MOCK_CHANNELS.find((c) => c.id === payload.channelId); - window.alert( - `Mock send to #${channel?.name ?? payload.channelId}\n` + - `${payload.blocks.length} block${payload.blocks.length === 1 ? '' : 's'} — ` + - `sendAsUser=${payload.sendAsUser}\n\nSee console for full payload.` - ); - return { ok: true }; -} - const ASIDE_MIN = 280; const ASIDE_MAX = 640; const ASIDE_DEFAULT = 380; @@ -112,6 +210,130 @@ export function App() { setBuilderKey((n) => n + 1); }; + // --- Edit-mode demo knobs + in-memory store ------------------------------ + const [editingEnabled, setEditingEnabled] = useState(true); + const [canSendAsUser, setCanSendAsUser] = useState(true); + const [includeOauthUrl, setIncludeOauthUrl] = useState(true); + const [store, setStore] = useState(SEED_MESSAGES); + + // Read the latest store from inside the stable `onLoadMessage` callback + // without making it a dependency (the package captures it directly). + const storeRef = useRef(store); + useEffect(() => { + storeRef.current = store; + }); + + const loadSendAsUserStatus = useCallback(async (): Promise => { + await new Promise((r) => setTimeout(r, 150)); + if (canSendAsUser) { + return { canSendAsUser: true }; + } + return { + canSendAsUser: false, + oauthUrl: includeOauthUrl ? 'https://slack.com/oauth/v2/authorize?mock=1' : undefined + }; + }, [canSendAsUser, includeOauthUrl]); + + const onSend = useCallback(async (payload: SendPayload): Promise => { + await new Promise((r) => setTimeout(r, 300)); + const channel = MOCK_CHANNELS.find((c) => c.id === payload.channelId); + const name = channel?.name ?? payload.channelId; + setStore((prev) => { + const ts = `${Math.floor(Date.now() / 1000)}.${String(prev.length).padStart(6, '0')}`; + const posted: StoredMessage = { + ts, + channelId: payload.channelId, + channelName: name, + author: payload.sendAsUser ? 'you' : 'bot', + kind: 'normal', + blocks: payload.blocks + }; + return [...prev, posted]; + }); + const count = `${payload.blocks.length} block${payload.blocks.length === 1 ? '' : 's'}`; + window.alert(`Sent a new message to #${name} (${count}, as ${payload.sendAsUser ? 'you' : 'the bot'}).`); + return { ok: true }; + }, []); + + // Mirrors the verdict a real host computes from `conversations.replies`. + const onLoadMessage = useCallback(async ({ link }: LoadMessageInput): Promise => { + await new Promise((r) => setTimeout(r, 250)); + const msg = findMessageByLink(storeRef.current, link); + if (!msg) { + return { ok: false, reason: 'No message matched that link. Copy a link from the store on the right.' }; + } + if (msg.kind === 'non-block') { + return { + ok: false, + reason: "This message has no editable blocks (it may be attachment-only), so it can't be opened in the editor." + }; + } + if (msg.kind === 'edit-window-closed') { + return { ok: false, reason: "This message is past Slack's edit window and can no longer be edited." }; + } + if (msg.author === 'someoneElse') { + return { + ok: false, + reason: "This message was posted by someone else, so it can't be edited. You can repost its content as a new message.", + blocks: msg.blocks + }; + } + const identity = AUTHOR_IDENTITY[msg.author]; + const base = { + channelId: msg.channelId, + channelName: msg.channelName, + ts: msg.ts, + blocks: msg.blocks, + workspaceName: WORKSPACE_NAME, + username: identity.username, + iconUrl: identity.iconUrl + } as const; + return msg.author === 'bot' + ? { ok: true, ...base, editableVia: 'bot' } + : { ok: true, ...base, editableVia: 'user' }; + }, []); + + const onUpdate = useCallback( + async ({ channelId, ts, blocks: updated, asUser }: UpdatePayload): Promise => { + await new Promise((r) => setTimeout(r, 300)); + const channel = MOCK_CHANNELS.find((c) => c.id === channelId); + const name = channel?.name ?? channelId; + setStore((prev) => prev.map((m) => (m.ts === ts ? { ...m, blocks: updated } : m))); + const count = `${updated.length} block${updated.length === 1 ? '' : 's'}`; + window.alert(`Updated the message in #${name} (${count}, as ${asUser ? 'you' : 'the bot'}).`); + return { ok: true }; + }, + [] + ); + + // "Recent messages from this app" — round-trippable fixtures the app + // authored, whether posted as the bot or as the current user (plus anything + // sent during the session). Each carries the identity it was posted as via + // `editableVia`, which the picker surfaces and the update uses to pick the + // token. Messages by someone else (or that don't round-trip) are excluded. + // Conservative host behavior: drop the user's own messages when there's no + // user token, rather than offer an edit that can't complete without re-auth. + const loadRecentMessages = useCallback(async (): Promise => { + await new Promise((r) => setTimeout(r, 200)); + return storeRef.current + .filter((m) => { + if (m.kind !== 'normal' || m.blocks.length === 0) return false; + if (m.author === 'bot') return true; + return m.author === 'you' && canSendAsUser; + }) + .map((m): RecentMessage => ({ + channelId: m.channelId, + channelName: m.channelName, + ts: m.ts, + blocks: m.blocks, + editableVia: m.author === 'you' ? 'user' : 'bot', + label: previewOf(m.blocks), + workspaceName: WORKSPACE_NAME, + username: AUTHOR_IDENTITY[m.author].username, + iconUrl: AUTHOR_IDENTITY[m.author].iconUrl + })); + }, [canSendAsUser]); + const [asideWidth, setAsideWidth] = useState(ASIDE_DEFAULT); // Collapse state. `narrow` follows the viewport; user clicks override @@ -194,9 +416,9 @@ export function App() { textOverflow: 'ellipsis' }} > - block-kitchen — live demo + block-kitchen — live demo -
+
Drag blocks from the palette, edit them in place, and send to a (mocked) Slack channel.{' '}
- + +
@@ -286,12 +494,13 @@ export function App() {
); } + +const AUTHOR_LABEL: Record = { + bot: 'this app (bot)', + you: 'you', + someoneElse: 'someone else' +}; + +const KIND_NOTE: Record = { + normal: '', + 'non-block': ' · non-block', + 'edit-window-closed': ' · edit window closed' +}; + +/** + * Header button that opens a modal owning all the mocked-host edit-mode + * controls — the mode choice (Write-only vs Read & Write), the user-token + * knobs, and the message store — so none of it lives on the page. The store + * makes the load → update round-trip observable: copy a message's link, load + * it in the builder, update, and watch the blocks change. + */ +function EditingMenu({ + editingEnabled, + onEditingEnabledChange, + canSendAsUser, + onCanSendAsUserChange, + includeOauthUrl, + onIncludeOauthUrlChange, + store +}: { + editingEnabled: boolean; + onEditingEnabledChange: (v: boolean) => void; + canSendAsUser: boolean; + onCanSendAsUserChange: (v: boolean) => void; + includeOauthUrl: boolean; + onIncludeOauthUrlChange: (v: boolean) => void; + store: StoredMessage[]; +}) { + const [open, setOpen] = useState(false); + const [copiedTs, setCopiedTs] = useState(null); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + + const copyLink = (msg: StoredMessage) => { + navigator.clipboard?.writeText(permalinkFor(msg)).catch(() => {}); + setCopiedTs(msg.ts); + window.setTimeout(() => setCopiedTs((cur) => (cur === msg.ts ? null : cur)), 1200); + }; + + // Segmented switcher: a muted track with the active segment raised on a + // solid background, so it reads as a button-style mode toggle. + const tab = (label: string, active: boolean, onSelect: () => void) => ( + + ); + + const checkbox = (label: string, checked: boolean, onChange: (v: boolean) => void, disabled?: boolean) => ( + + ); + + return ( + <> + + {open && + createPortal( +
setOpen(false)} + style={{ + position: 'fixed', + inset: 0, + zIndex: 100, + background: 'rgba(0,0,0,0.45)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 16 + }} + > +
e.stopPropagation()} + style={{ + width: 560, + maxWidth: '100%', + maxHeight: '85vh', + overflow: 'auto', + background: 'hsl(var(--background))', + color: 'hsl(var(--foreground))', + border: '1px solid hsl(var(--border))', + borderRadius: 12, + boxShadow: '0 20px 60px rgba(0,0,0,0.3)', + padding: 20, + fontSize: 13 + }} + > +
+
+
Edit-mode demo
+
+ Mocks the host side of the package's editing prop. +
+
+ +
+ +
+ {tab('Write-only', !editingEnabled, () => onEditingEnabledChange(false))} + {tab('Read & Write', editingEnabled, () => onEditingEnabledChange(true))} +
+ +
+ {editingEnabled ? ( + <> +
+ Copy a link below, then use “Load message” in the toolbar, or pick a recent message. +
+ +
User-token settings
+
+ {checkbox('User can edit their own messages (canSendAsUser)', canSendAsUser, onCanSendAsUserChange)} + {checkbox( + 'Offer Slack sign-in link (oauthUrl)', + includeOauthUrl, + onIncludeOauthUrlChange, + canSendAsUser + )} +
+ +
Message store
+
+ {store.map((m) => ( +
+ +
+
+ {AUTHOR_IDENTITY[m.author].username}{' '} + #{m.channelName} +
+
+ {m.ts} · {AUTHOR_LABEL[m.author]} + {KIND_NOTE[m.kind]} · {m.blocks.length} block{m.blocks.length === 1 ? '' : 's'} +
+
+ +
+ ))} +
+ + ) : ( +
+ The editing prop is omitted, so the builder is a plain composer. “Load message” is + hidden and the primary action stays “Send”. +
+ )} +
+
+
, + document.body + )} + + ); +} + +/** + * Small button-style dropdown for the header. Unlike a native ` onChange({ ...element, alt_text: e.target.value })} /> diff --git a/src/components/load-message-dialog.tsx b/src/components/load-message-dialog.tsx new file mode 100644 index 0000000..2dc029a --- /dev/null +++ b/src/components/load-message-dialog.tsx @@ -0,0 +1,281 @@ +import { AlertTriangle, Info } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from '../lib/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../lib/ui/dialog'; +import { Input } from '../lib/ui/input'; +import { Label } from '../lib/ui/label'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../lib/ui/tooltip'; +import type { LoadResult, RecentMessage, SupportedBlock } from '../types'; + +/** Map a {@link RecentMessage} onto the `ok` verdict so it reuses the load path. */ +function recentToResult(msg: RecentMessage): Extract { + return { + ok: true, + channelId: msg.channelId, + channelName: msg.channelName, + ts: msg.ts, + blocks: msg.blocks, + editableVia: msg.editableVia ?? 'bot', + workspaceName: msg.workspaceName, + username: msg.username, + iconUrl: msg.iconUrl + }; +} + +type LoadStatus = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'error'; error: string } + | { kind: 'not-editable'; reason: string; blocks?: SupportedBlock[] }; + +/** + * Edit-mode entry point. Collects a Slack message permalink and hands it to + * the host's `onLoadMessage`. On a successful load the parent flips into + * edit mode (`onLoaded`); on a not-editable verdict the dialog renders the + * host's `reason` inline and offers "Open as a new message instead". + * + * The package never parses the permalink — the host extracts `channel + ts`. + * @param props - dialog props + * @param props.open - whether the dialog is open + * @param props.onOpenChange - notified when the user closes the dialog + * @param props.onLoadMessage - host loader returning an editability verdict + * @param props.loadRecentMessages - optional loader for the "recent messages" picker + * @param props.onLoaded - called with the `ok` result so the parent enters edit mode + * @param props.onOpenAsNew - called with optional blocks for the "open as new" fallback + * @returns the rendered load-message dialog + */ +export function LoadMessageDialog({ + open, + onOpenChange, + onLoadMessage, + loadRecentMessages, + onLoaded, + onOpenAsNew +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onLoadMessage: (input: { link: string }) => Promise; + loadRecentMessages?: () => Promise; + onLoaded: (result: Extract) => void; + onOpenAsNew: (blocks?: SupportedBlock[]) => void; +}) { + const [link, setLink] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + // Recent-messages picker (only loaded when `loadRecentMessages` is given). + // `null` means "loading / not loaded yet". + const [recent, setRecent] = useState(null); + const [recentError, setRecentError] = useState(null); + const inputRef = useRef(null); + + // Hold the latest loaders in refs so they can change identity between renders + // (consumers often pass a fresh arrow) without us needing them as deps. + const onLoadMessageRef = useRef(onLoadMessage); + const loadRecentMessagesRef = useRef(loadRecentMessages); + useEffect(() => { + onLoadMessageRef.current = onLoadMessage; + loadRecentMessagesRef.current = loadRecentMessages; + }); + + const hasRecent = !!loadRecentMessages; + + // Reset to a clean slate each time the dialog opens, and (re)load the recent + // list so a fresh open reflects any messages posted since. + useEffect(() => { + if (!open) { + return; + } + setLink(''); + setStatus({ kind: 'idle' }); + if (!loadRecentMessagesRef.current) { + setRecent([]); + setRecentError(null); + return; + } + setRecent(null); + setRecentError(null); + let cancelled = false; + loadRecentMessagesRef + .current() + .then((list) => { + if (!cancelled) { + setRecent(list); + } + }) + .catch((e) => { + if (!cancelled) { + setRecentError(e instanceof Error ? e.message : 'Failed to load recent messages.'); + } + }); + return () => { + cancelled = true; + }; + }, [open]); + + const handleLoad = async () => { + const trimmed = link.trim(); + if (!trimmed) { + setStatus({ kind: 'error', error: 'Paste a Slack message link first.' }); + return; + } + setStatus({ kind: 'loading' }); + try { + const result = await onLoadMessageRef.current({ link: trimmed }); + if (result.ok) { + onLoaded(result); + return; + } + setStatus({ kind: 'not-editable', reason: result.reason, blocks: result.blocks }); + } catch (e) { + setStatus({ kind: 'error', error: e instanceof Error ? e.message : 'Failed to load message.' }); + } + }; + + const notEditable = status.kind === 'not-editable' ? status : null; + + return ( + + { + e.preventDefault(); + inputRef.current?.focus(); + }} + > + + Edit an existing message +
+ + {hasRecent + ? 'Paste a Slack message link, or pick a recent message your app posted.' + : 'Paste a Slack message link (Slack\'s "Copy link") to load its blocks for editing.'} + + + + + + + In Slack, hover over the message and click the ⋮ More actions button (or right-click + the message), then choose Copy link. + + +
+
+ +
+
+ + { + setLink(e.target.value); + if (status.kind !== 'idle' && status.kind !== 'loading') { + setStatus({ kind: 'idle' }); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && status.kind !== 'loading') { + e.preventDefault(); + handleLoad(); + } + }} + placeholder="https://your-workspace.slack.com/archives/C…/p…" + autoComplete="off" + spellCheck={false} + /> +
+ + {status.kind === 'error' && ( +

+ {status.error} +

+ )} + + {notEditable && ( +
+
+ + {notEditable.reason} +
+ +
+ )} + + {hasRecent && ( +
+
+ + or pick a recent message + +
+ {recent === null && !recentError && ( +

Loading recent messages…

+ )} + {recentError &&

{recentError}

} + {recent && recent.length === 0 && !recentError && ( +

No recent messages from this app.

+ )} + {recent && recent.length > 0 && ( +
+ {recent.map((m) => { + // Which identity the message was posted as — drives both + // this badge and (on load) the token the update uses. + const asUser = (m.editableVia ?? 'bot') === 'user'; + return ( + + ); + })} +
+ )} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/surface.tsx b/src/components/surface.tsx index add6622..d771247 100644 --- a/src/components/surface.tsx +++ b/src/components/surface.tsx @@ -3,6 +3,7 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { LayoutGrid, Plus, X } from 'lucide-react'; import type { ReactNode } from 'react'; import { cn } from '../lib/cn'; +import { isSafeImageSrc } from '../lib/url-safety'; import type { BuilderBlock, PreviewHooks, PreviewSurface, PreviewTheme, SupportedBlock } from '../types'; import { BlockRow } from './block-row'; import { ContainerRow } from './container-row'; @@ -35,6 +36,8 @@ export const SURFACE_DROPPABLE_ID = 'builder-surface'; export function Surface({ blocks, workspaceName, + authorName, + authorIcon, previewHooks, previewTheme, previewSurface = 'message', @@ -50,6 +53,10 @@ export function Surface({ }: { blocks: BuilderBlock[]; workspaceName?: string; + /** Author name shown in the message-frame header (overrides `workspaceName`). */ + authorName?: string; + /** Author avatar URL shown in the message-frame header. */ + authorIcon?: string; previewHooks?: PreviewHooks; previewTheme?: PreviewTheme; previewSurface?: PreviewSurface; @@ -154,7 +161,7 @@ export function Surface({ {blocksList} ) : ( - + {blocksList} )} @@ -164,7 +171,7 @@ export function Surface({ } /** - * Slack message chrome: avatar + app name + APP badge + timestamp + * Slack message chrome: avatar + author name + timestamp * across the top, blocks below. Mimics the library's `` wrapper * without wiring each block through its own library wrapper (so per-block * editing affordances still work). @@ -176,14 +183,22 @@ export function Surface({ */ function MessageFrame({ workspaceName, + authorName, + authorIcon, isDark, children }: { workspaceName?: string; + authorName?: string; + authorIcon?: string; isDark: boolean; children: ReactNode; }) { - const initial = (workspaceName ?? 'A').slice(0, 1).toUpperCase(); + const displayName = authorName ?? workspaceName ?? 'Your app'; + const initial = displayName.slice(0, 1).toUpperCase(); + // Only render the author image when it's a safe http(s) URL — it flows into + // an just like block image URLs do. + const safeIcon = authorIcon && isSafeImageSrc(authorIcon) ? authorIcon : null; return (
- - {initial} - - - {workspaceName ?? 'Your app'} - - - APP - + {safeIcon ? ( + + ) : ( + + {initial} + + )} + {displayName} 10:37 AM
{children} diff --git a/src/components/toolbar.stories.tsx b/src/components/toolbar.stories.tsx index b2dcf6a..4e4e57d 100644 --- a/src/components/toolbar.stories.tsx +++ b/src/components/toolbar.stories.tsx @@ -85,3 +85,34 @@ export const ClickSendInvokesHandler: Story = { await expect(args.onOpenSend).toHaveBeenCalledOnce(); } }; + +// Edit mode configured but no message loaded yet: the "Load message" entry +// appears and the primary action is a plain "Send". +export const EditingEnabled: Story = { + args: { editingEnabled: true, onOpenLoad: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: 'Load message' })); + await expect(args.onOpenLoad).toHaveBeenCalledOnce(); + await expect(await canvas.findByRole('button', { name: 'Review & send' })).toBeInTheDocument(); + } +}; + +// A message is loaded for editing: the badge shows and the primary split +// button reads "Review & update" with a "More message options" menu. +export const EditingActive: Story = { + args: { + editingEnabled: true, + editBadge: { channelLabel: '#engineering', ts: '1718000042.000100' }, + onOpenUpdate: fn(), + onExitEdit: fn() + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: 'Review & update' })); + await expect(args.onOpenUpdate).toHaveBeenCalledOnce(); + await expect(await canvas.findByRole('button', { name: 'More message options' })).toBeInTheDocument(); + await userEvent.click(await canvas.findByRole('button', { name: 'Switch to a new message' })); + await expect(args.onExitEdit).toHaveBeenCalledOnce(); + } +}; diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 0a2d0cb..3f84a5d 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -9,13 +9,14 @@ import { Home, MessageSquare, Moon, - MoreHorizontal, + Pencil, Plus, Send, Sun, - Trash2 + Trash2, + X } from 'lucide-react'; -import type { ComponentType, KeyboardEvent, ReactNode } from 'react'; +import type { ComponentType, KeyboardEvent } from 'react'; import { useRef, useState } from 'react'; import { cn } from '../lib/cn'; import { Button } from '../lib/ui/button'; @@ -44,6 +45,22 @@ const SURFACE_OPTIONS: { const DEFAULT_DOCS_HREF = 'https://docs.slack.dev/reference/block-kit/blocks'; const DEFAULT_DOCS_LABEL = 'Docs'; +/** + * Render a Slack message timestamp (`.`) as a readable + * date for the edit-mode banner. Falls back to the raw value if it doesn't + * parse as a number. + */ +function formatMessageTs(ts: string): string { + const seconds = Number.parseFloat(ts); + if (!Number.isFinite(seconds)) return ts; + const date = new Date(seconds * 1000); + if (Number.isNaN(date.getTime())) return ts; + return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +const SEND_MENU_ITEM = + 'flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50'; + /** * Top toolbar with the preview theme picker, View JSON escape hatch, and * the Send action. @@ -86,7 +103,14 @@ export function Toolbar({ showThemeControl = true, docsLink, errorCount, - sendButtonLabel = 'Send' + sendButtonLabel = 'Review & send', + editingEnabled = false, + editBadge, + onOpenLoad, + onOpenUpdate, + onExitEdit, + loadButtonLabel = 'Load message', + updateButtonLabel = 'Review & update' }: { onClear: () => void; onOpenJson: () => void; @@ -104,6 +128,20 @@ export function Toolbar({ docsLink?: false | { href?: string; label?: string }; errorCount: number; sendButtonLabel?: string; + /** Whether edit mode is configured (shows the "Edit existing message" entry). */ + editingEnabled?: boolean; + /** When set, a message is loaded for editing: renders the edit-mode badge. */ + editBadge?: { channelLabel: string; ts: string } | null; + /** Opens the load-message dialog (edit-mode entry point). */ + onOpenLoad?: () => void; + /** Opens the update dialog (split button's main action + "Update message"). */ + onOpenUpdate?: () => void; + /** Switches back to a new message, clearing the loaded edit target. */ + onExitEdit?: () => void; + /** Label for the load-message entry button. Defaults to `'Load message'`. */ + loadButtonLabel?: string; + /** Label for the primary button while editing. Defaults to `'Review & update'`. */ + updateButtonLabel?: string; }) { const activeTheme = THEME_OPTIONS.find((t) => t.value === previewTheme) ?? THEME_OPTIONS[0]; const activeSurface = SURFACE_OPTIONS.find((s) => s.value === previewSurface) ?? SURFACE_OPTIONS[0]; @@ -116,185 +154,211 @@ export function Toolbar({ // `role="menuitemradio"` semantics imply activation dismisses the menu. const [surfaceMenuOpen, setSurfaceMenuOpen] = useState(false); const [themeMenuOpen, setThemeMenuOpen] = useState(false); - // Mobile-only overflow menu containing secondary actions (Clear, View - // JSON, Docs). At `sm+` those render inline; below `sm` the trigger - // button collapses them into a single `⋯` popover. - const [moreMenuOpen, setMoreMenuOpen] = useState(false); + const [sendMenuOpen, setSendMenuOpen] = useState(false); return ( -
-
- {onOpenPalette ? ( + <> +
+ +
+ {errorCount > 0 ? ( + + ) : null} - ) : null} - {showSurfaceControl ? ( - - - - - - - ariaLabel="Preview surface" - options={surfaceOptions} - value={previewSurface} - onChange={(next) => { - onPreviewSurfaceChange(next); - setSurfaceMenuOpen(false); - }} - /> - - - ) : null} - {showThemeControl ? ( - - - - - - - ariaLabel="Preview theme" - options={THEME_OPTIONS} - value={previewTheme} - onChange={(next) => { - onPreviewThemeChange(next); - setThemeMenuOpen(false); - }} - /> - - - ) : null} - {docsHref ? ( - - - {docsLabel} - - - ) : null} -
-
- {errorCount > 0 ? ( - - ) : null} - - - - - - - -
- } - onSelect={() => { - setMoreMenuOpen(false); - onClear(); - }} - disabled={!canClear} - tone="destructive" + {editBadge ? ( + // A message is loaded: split button. Main action updates it in + // place; the menu also offers posting the blocks as a new message. +
+ + + + + + +
+ + +
+
+
- - - + ) : ( + + )} +
-
+ {editingEnabled && editBadge ? ( +
+ + + References an existing message in {editBadge.channelLabel} + + {formatMessageTs(editBadge.ts)} + + + +
+ ) : null} + ); } @@ -370,72 +434,3 @@ function Menu({
); } - -const ACTION_ITEM_BASE = - 'flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50'; - -/** - * One row in the mobile overflow menu. Action items don't carry - * selection state, so they're plain `role="menuitem"` rather than - * `menuitemradio` — clicking dispatches and the parent closes the menu. - */ -function ActionMenuItem({ - icon, - children, - onSelect, - disabled, - tone -}: { - icon: ReactNode; - children: ReactNode; - onSelect: () => void; - disabled?: boolean; - tone?: 'destructive'; -}) { - return ( - - ); -} - -/** - * External-link variant of {@link ActionMenuItem}. Used for Docs in the - * mobile overflow menu so the link still opens in a new tab while - * matching the surrounding items visually. - */ -function ActionMenuLink({ - href, - icon, - trailingIcon, - children, - onSelect -}: { - href: string; - icon: ReactNode; - trailingIcon?: ReactNode; - children: ReactNode; - onSelect?: () => void; -}) { - return ( - - {icon} - {children} - {trailingIcon} - - ); -} diff --git a/src/components/update-dialog.tsx b/src/components/update-dialog.tsx new file mode 100644 index 0000000..3374970 --- /dev/null +++ b/src/components/update-dialog.tsx @@ -0,0 +1,204 @@ +import { AlertTriangle, ExternalLink } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toSlackBlocks } from '../lib/to-slack-blocks'; +import { Button } from '../lib/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../lib/ui/dialog'; +import { Label } from '../lib/ui/label'; +import { isSafeHref } from '../lib/url-safety'; +import type { EditableVia, SendAsUserStatus, SupportedBlock, UpdatePayload, UpdateResult } from '../types'; + +/** The loaded message being edited. Destination + identity are fixed by the host's verdict. */ +export interface EditTarget { + channelId: string; + channelName?: string; + ts: string; + editableVia: EditableVia; + workspaceName?: string; + username?: string; + iconUrl?: string; +} + +type UpdateStatus = { kind: 'idle' } | { kind: 'updating' } | { kind: 'error'; error: string }; + +/** + * Modal dialog confirming an update to an already-posted message. Unlike the + * send dialog, the destination channel is locked to the source and the + * post-as identity is fixed by the host's `editableVia` verdict — there is no + * channel or identity picker. When the verdict is `'user'` and the user has no + * token yet, the dialog reuses the "Sign in with Slack" flow before allowing + * the update. + * @param props - dialog props + * @param props.open - whether the dialog is open + * @param props.onOpenChange - notified when the user closes the dialog + * @param props.target - the loaded message (channel + ts + editability verdict) + * @param props.blocks - the edited draft blocks to write back + * @param props.loadSendAsUserStatus - returns user-token status + OAuth URL (user-token path only) + * @param props.onUpdate - terminal action; should return `{ ok }` or `{ ok: false, error }` + * @param props.confirmUpdateLabel - label for the confirm button. Defaults to `'Update message'`. + * @param props.errorCount - total validation errors against the current draft + * @param props.onShowIssues - called when the user opens the global issues panel + * @returns the rendered update dialog + */ +export function UpdateDialog({ + open, + onOpenChange, + target, + blocks, + loadSendAsUserStatus, + onUpdate, + confirmUpdateLabel = 'Update message', + errorCount, + onShowIssues +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + target: EditTarget; + blocks: SupportedBlock[]; + loadSendAsUserStatus: () => Promise; + onUpdate: (payload: UpdatePayload) => Promise; + confirmUpdateLabel?: string; + errorCount: number; + onShowIssues?: () => void; +}) { + const asUser = target.editableVia === 'user'; + const [userStatus, setUserStatus] = useState(null); + const [status, setStatus] = useState({ kind: 'idle' }); + + const loadSendAsUserStatusRef = useRef(loadSendAsUserStatus); + useEffect(() => { + loadSendAsUserStatusRef.current = loadSendAsUserStatus; + }); + + const refreshSendAsUser = useCallback(() => { + loadSendAsUserStatusRef + .current() + .then(setUserStatus) + .catch(() => setUserStatus({ canSendAsUser: false })); + }, []); + + // Only the user-token path needs a token check; the bot path can always edit + // its own message. + useEffect(() => { + if (!open) { + return; + } + setStatus({ kind: 'idle' }); + if (asUser) { + setUserStatus(null); + refreshSendAsUser(); + } + }, [open, asUser, refreshSendAsUser]); + + // Pick up a completed OAuth round-trip when the window regains focus. + useEffect(() => { + if (!open || !asUser) { + return; + } + const handler = () => refreshSendAsUser(); + window.addEventListener('focus', handler); + return () => window.removeEventListener('focus', handler); + }, [open, asUser, refreshSendAsUser]); + + // Editing as the user requires a usable user token. The bot path never gates. + const needsSignIn = asUser && userStatus !== null && !userStatus.canSendAsUser; + + const handleSubmit = async () => { + setStatus({ kind: 'updating' }); + try { + const result = await onUpdate({ + channelId: target.channelId, + ts: target.ts, + blocks: toSlackBlocks(blocks), + asUser + }); + if (result.ok) { + setStatus({ kind: 'idle' }); + onOpenChange(false); + return; + } + setStatus({ kind: 'error', error: result.error ?? 'Update failed.' }); + } catch (e) { + setStatus({ kind: 'error', error: e instanceof Error ? e.message : 'Update failed.' }); + } + }; + + const channelLabel = target.channelName ? `#${target.channelName}` : target.channelId; + + return ( + + + + Update message + + This replaces the message that's already posted. It stays in the same channel and can't be moved. + + + +
+
+ +

+ {channelLabel} +

+
+ +
+ +

+ {asUser ? 'Your account' : 'App bot'} +

+ {needsSignIn && userStatus?.oauthUrl && isSafeHref(userStatus.oauthUrl) && ( +

+ + Sign in with Slack + {' '} + to update your own message. +

+ )} + {needsSignIn && !userStatus?.oauthUrl && ( +

Sign in with Slack to update your own message.

+ )} +
+ + {errorCount > 0 ? ( + + ) : null} + + {status.kind === 'error' && ( +

+ {status.error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/index.ts b/src/index.ts index 7ac8e4b..b421125 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,12 +32,16 @@ export type { ContextActionsElement, CustomEmoji, DataVisualizationBlock, + EditableVia, + EditingConfig, FeedbackButtonSubobject, FeedbackButtonsElement, HeaderLevel, IconButtonElement, IconButtonIcon, InputBlock, + LoadMessageInput, + LoadResult, MarkdownBlock, PieChart, PieChartSegment, @@ -45,6 +49,7 @@ export type { PreviewHooks, PreviewSurface, PreviewTheme, + RecentMessage, SendAsUserStatus, SendPayload, SendResult, @@ -57,6 +62,8 @@ export type { TaskCardBlock, TaskCardStatus, Template, + UpdatePayload, + UpdateResult, UrlSourceElement, VideoBlock } from './types'; diff --git a/src/types.ts b/src/types.ts index 4af6a3a..3f429e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -497,6 +497,130 @@ export interface SendResult { error?: string; } +/** + * Which token can edit a loaded message, as computed by the host. + * `chat.update` only edits a message authored by the calling token: + * - `'bot'` — the message was posted by this app; edit via the bot token. + * - `'user'` — the message was posted by the current user; edit via their + * user token (gated behind {@link BlockKitchenProps.loadSendAsUserStatus}). + */ +export type EditableVia = 'bot' | 'user'; + +/** + * Input passed to {@link EditingConfig.onLoadMessage}. The user pastes a + * Slack message permalink (Slack's "Copy link"); the host parses + * `channel + ts` out of it — the package never touches raw timestamps. + */ +export interface LoadMessageInput { + link: string; +} + +/** + * Result of loading an existing message for editing, computed by the host. + * + * On `ok`, the package enters edit mode: it hydrates the editor with + * `blocks`, locks the destination to `channelId`, and constrains the + * post-as identity to `editableVia`. + * + * On `!ok`, the package renders the host's `reason` inline and offers + * "Open as a new message instead". If the message round-trips through the + * block editor (e.g. someone else's block message), the host may include + * `blocks` so that fallback can hydrate the draft; for non-block or + * attachment-only messages, omit it. + */ +export type LoadResult = + | { + ok: true; + channelId: string; + /** Channel name for the edit-mode badge (display only). Falls back to `channelId`. */ + channelName?: string; + ts: string; + blocks: SupportedBlock[]; + editableVia: EditableVia; + workspaceName?: string; + /** + * Display name of the existing message's author. When provided, the + * preview header shows it instead of `workspaceName`. Optional. + */ + username?: string; + /** + * Avatar image URL of the existing message's author, shown in the + * preview header. Optional; ignored if it isn't a safe `http(s)` URL. + */ + iconUrl?: string; + } + | { ok: false; reason: string; blocks?: SupportedBlock[] }; + +/** + * Payload passed to {@link EditingConfig.onUpdate}. Sibling to + * {@link SendPayload} but carries the source `channel + ts`. `chat.update` + * requires re-sending the full blocks payload (no partial update). + */ +export interface UpdatePayload { + channelId: string; + ts: string; + blocks: SupportedBlock[]; + asUser: boolean; +} + +/** + * Result returned from {@link EditingConfig.onUpdate}. Mirrors + * {@link SendResult}; `error` is rendered as-is. + */ +export interface UpdateResult { + ok: boolean; + error?: string; +} + +/** + * One entry in the "recent messages from this app" picker, returned by + * {@link EditingConfig.loadRecentMessages}. These are editable-by-construction + * — the app authored them — so they load straight into edit mode without a + * separate verdict round-trip. `editableVia` defaults to `'bot'` when omitted. + */ +export interface RecentMessage { + channelId: string; + /** Channel name for display + the edit-mode badge. Falls back to `channelId`. */ + channelName?: string; + ts: string; + blocks: SupportedBlock[]; + editableVia?: EditableVia; + /** Short preview shown in the picker row (e.g. the first line of the message). */ + label?: string; + workspaceName?: string; + /** Display name of the message's author; shown in the preview header. Optional. */ + username?: string; + /** Avatar image URL shown in the preview header. Optional; ignored unless a safe `http(s)` URL. */ + iconUrl?: string; +} + +/** + * Opt-in edit-mode configuration. Presence of {@link BlockKitchenProps.editing} + * enables loading an existing message and dispatching an update; omit it to + * keep the send-only behavior. The package stays integration-agnostic — it + * makes no network calls and has no Slack-token knowledge; the host brokers + * I/O and computes the editability verdict. + */ +export interface EditingConfig { + /** + * Host parses the pasted permalink, fetches the message, and returns a + * host-computed editability verdict plus blocks. See {@link LoadResult}. + */ + onLoadMessage: (input: LoadMessageInput) => Promise; + /** + * Dispatches the update for the loaded message. Sibling to + * {@link BlockKitchenProps.onSend}; carries `channel + ts`. + */ + onUpdate: (payload: UpdatePayload) => Promise; + /** + * Optional. When provided, the load dialog adds a "recent messages from this + * app" picker alongside the paste-a-link input. Returns messages the app + * authored (editable-by-construction); picking one loads it straight into + * edit mode. Omit to offer the paste-link entry only. + */ + loadRecentMessages?: () => Promise; +} + /** * A workspace custom emoji as configured in Slack. Mirrors the shape Slack's * `emoji.list` API returns once normalized: @@ -625,6 +749,35 @@ export interface BlockKitchenProps { * `{ ok: true }` on success or `{ ok: false, error }` on failure. */ onSend: (payload: SendPayload) => Promise; + /** + * Opt-in edit mode. When provided, the toolbar exposes an "Edit existing + * message" entry: the user pastes a Slack message link, the host loads it + * and returns an editability verdict ({@link EditingConfig.onLoadMessage}), + * and a successful load flips the primary action from Send to "Update + * message" ({@link EditingConfig.onUpdate}). Omit to keep today's send-only + * behavior. The user-token path reuses {@link BlockKitchenProps.loadSendAsUserStatus}. + */ + editing?: EditingConfig; + /** + * Label + accessible name for the toolbar button that opens the + * load-message dialog (the edit-mode entry point). Defaults to + * `'Load message'`. Only shown when {@link BlockKitchenProps.editing} is set + * and no message is currently loaded. + */ + loadButtonLabel?: string; + /** + * Label for the toolbar's primary button when a message is loaded for + * editing. The button is a split control: clicking it updates the message + * in place; a menu beside it also offers "Send as a new message" (post the + * current blocks as a brand-new message). Defaults to `'Review & update'`. + */ + updateButtonLabel?: string; + /** + * Label for the update dialog's final confirm button, which dispatches the + * update via {@link EditingConfig.onUpdate}. Defaults to `'Update message'` + * (and shows `'Updating…'` while in flight). + */ + confirmUpdateLabel?: string; /** * The palette shown on the left-hand side. When omitted, the built-in * `defaultPalette` is used. Pass a custom array (typically built by @@ -733,9 +886,9 @@ export interface BlockKitchenProps { previewTheme?: PreviewTheme; /** * Label for the toolbar's Send button, which opens the send dialog. - * Defaults to `'Send'`. Use this to signal that a configuration step - * follows (e.g. `'Send to channel…'`) without hardcoding - * product-specific copy in the package. Also used as the button's + * Defaults to `'Review & send'` (the dialog is the review step). Use this to + * override the copy (e.g. `'Send to channel…'`) without hardcoding + * product-specific text in the package. Also used as the button's * accessible name. */ sendButtonLabel?: string;