From 6ed0ad7e65d282cce1ea2bef68685e0fa09c0812 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 21:59:04 -0700 Subject: [PATCH] feat(editing)!: scope recent-messages picker to a chosen channel The load dialog's "recent messages" picker now requires the user to pick a channel first (reusing the existing `loadChannels`), then calls `loadRecentMessages(channelId)` scoped to that one channel instead of scanning the whole workspace. Changing the channel re-fetches; loading/empty/error states render per channel. The paste-a-link path is unchanged. BREAKING CHANGE: `EditingConfig.loadRecentMessages` now takes a `channelId` argument: `(channelId: string) => Promise`. Co-Authored-By: Claude Opus 4.8 --- README.md | 12 +-- demo/src/App.tsx | 8 +- src/components/block-kitchen.tsx | 1 + src/components/load-message-dialog.tsx | 103 +++++++++++++++++++++---- src/types.ts | 18 +++-- test/load-message-dialog.test.tsx | 80 +++++++++++++++++++ 6 files changed, 191 insertions(+), 31 deletions(-) create mode 100644 test/load-message-dialog.test.tsx diff --git a/README.md b/README.md index dac986d..34fb701 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ 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. | +| `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; the user picks a channel first (reusing `loadChannels`) and the lookup is scoped to it. 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). | @@ -162,10 +162,12 @@ nothing about who can edit; the host does both. 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 + // input. The user first picks a channel (reusing `loadChannels`), then this + // is called with that `channelId` so the lookup scans only one channel. + // These are editable-by-construction (the app authored them), so picking one + // loads it straight into edit mode (no verdict needed). + loadRecentMessages: async (channelId) => { + const msgs = await fetchRecentAppMessages(channelId); // your code return msgs.map((m) => ({ channelId: m.channel, channelName: m.channelName, diff --git a/demo/src/App.tsx b/demo/src/App.tsx index ea7cf8f..1ce8bee 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -307,16 +307,18 @@ export function App() { ); // "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 + // authored, scoped to the channel the user picks in the load dialog, 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 => { + const loadRecentMessages = useCallback(async (channelId: string): Promise => { await new Promise((r) => setTimeout(r, 200)); return storeRef.current .filter((m) => { + if (m.channelId !== channelId) return false; if (m.kind !== 'normal' || m.blocks.length === 0) return false; if (m.author === 'bot') return true; return m.author === 'you' && canSendAsUser; diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index df49fe7..9111097 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -430,6 +430,7 @@ export function BlockKitchen(props: BlockKitchenProps) { onOpenChange={setLoadOpen} onLoadMessage={editing.onLoadMessage} loadRecentMessages={editing.loadRecentMessages} + loadChannels={loadChannels} onLoaded={(result) => { replaceAll(result.blocks); setEditTarget({ diff --git a/src/components/load-message-dialog.tsx b/src/components/load-message-dialog.tsx index 2dc029a..c2ed542 100644 --- a/src/components/load-message-dialog.tsx +++ b/src/components/load-message-dialog.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D 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'; +import type { ChannelOption, LoadResult, RecentMessage, SupportedBlock } from '../types'; /** Map a {@link RecentMessage} onto the `ok` verdict so it reuses the load path. */ function recentToResult(msg: RecentMessage): Extract { @@ -39,7 +39,8 @@ type LoadStatus = * @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.loadRecentMessages - optional loader for the "recent messages" picker, scoped to a channel + * @param props.loadChannels - returns channels to scope the recent-messages picker by * @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 @@ -49,20 +50,26 @@ export function LoadMessageDialog({ onOpenChange, onLoadMessage, loadRecentMessages, + loadChannels, onLoaded, onOpenAsNew }: { open: boolean; onOpenChange: (open: boolean) => void; onLoadMessage: (input: { link: string }) => Promise; - loadRecentMessages?: () => Promise; + loadRecentMessages?: (channelId: string) => Promise; + loadChannels: () => 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". + // Channel selector for the recent-messages picker (only when `loadRecentMessages` + // is given). The user must pick a channel before any recent lookup runs. + const [channels, setChannels] = useState(null); + const [channelsError, setChannelsError] = useState(null); + const [channelId, setChannelId] = useState(''); + // Recent messages for the selected channel. `null` means "loading / not loaded yet". const [recent, setRecent] = useState(null); const [recentError, setRecentError] = useState(null); const inputRef = useRef(null); @@ -71,31 +78,62 @@ export function LoadMessageDialog({ // (consumers often pass a fresh arrow) without us needing them as deps. const onLoadMessageRef = useRef(onLoadMessage); const loadRecentMessagesRef = useRef(loadRecentMessages); + const loadChannelsRef = useRef(loadChannels); useEffect(() => { onLoadMessageRef.current = onLoadMessage; loadRecentMessagesRef.current = loadRecentMessages; + loadChannelsRef.current = loadChannels; }); 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. + // Reset to a clean slate each time the dialog opens, and load the channel + // list so the user can scope the recent-messages picker. useEffect(() => { if (!open) { return; } setLink(''); setStatus({ kind: 'idle' }); + setChannelId(''); + setRecent(null); + setRecentError(null); if (!loadRecentMessagesRef.current) { - setRecent([]); - setRecentError(null); + setChannels([]); + setChannelsError(null); + return; + } + setChannels(null); + setChannelsError(null); + let cancelled = false; + loadChannelsRef + .current() + .then((list) => { + if (!cancelled) { + setChannels(list); + } + }) + .catch((e) => { + if (!cancelled) { + setChannelsError(e instanceof Error ? e.message : 'Failed to load channels'); + } + }); + return () => { + cancelled = true; + }; + }, [open]); + + // (Re)load the recent list whenever the selected channel changes, scoping the + // lookup to that one channel. + useEffect(() => { + if (!open || !loadRecentMessagesRef.current || !channelId) { return; } setRecent(null); setRecentError(null); let cancelled = false; loadRecentMessagesRef - .current() + .current(channelId) .then((list) => { if (!cancelled) { setRecent(list); @@ -109,7 +147,7 @@ export function LoadMessageDialog({ return () => { cancelled = true; }; - }, [open]); + }, [open, channelId]); const handleLoad = async () => { const trimmed = link.trim(); @@ -226,14 +264,47 @@ export function LoadMessageDialog({ or pick a recent message - {recent === null && !recentError && ( + + {/* Pick a channel first — the recent lookup is scoped to it. */} +
+ + {channels === null && !channelsError && ( +

Loading channels…

+ )} + {channelsError &&

{channelsError}

} + {channels && channels.length === 0 && !channelsError && ( +

No public channels available.

+ )} + {channels && channels.length > 0 && ( + + )} +
+ + {!channelId && channels && channels.length > 0 && ( +

Select a channel to see recent messages.

+ )} + {channelId && recent === null && !recentError && (

Loading recent messages…

)} - {recentError &&

{recentError}

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

No recent messages from this app.

+ {channelId && recentError &&

{recentError}

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

No recent messages from this app in this channel.

)} - {recent && recent.length > 0 && ( + {channelId && recent && recent.length > 0 && (
{recent.map((m) => { // Which identity the message was posted as — drives both diff --git a/src/types.ts b/src/types.ts index 3f429e9..0e8fb47 100644 --- a/src/types.ts +++ b/src/types.ts @@ -574,9 +574,10 @@ export interface UpdateResult { /** * 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. + * {@link EditingConfig.loadRecentMessages} for the selected channel. 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; @@ -614,11 +615,14 @@ export interface EditingConfig { 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. + * app" picker alongside the paste-a-link input. The user first picks a channel + * (reusing {@link BlockKitchenProps.loadChannels}); only then is this called + * with the chosen `channelId`, scoping the lookup to that single channel. + * Returns messages the app authored in that channel (editable-by-construction); + * picking one loads it straight into edit mode. Omit to offer the paste-link + * entry only. */ - loadRecentMessages?: () => Promise; + loadRecentMessages?: (channelId: string) => Promise; } /** diff --git a/test/load-message-dialog.test.tsx b/test/load-message-dialog.test.tsx new file mode 100644 index 0000000..2f425f6 --- /dev/null +++ b/test/load-message-dialog.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { LoadMessageDialog } from '../src/components/load-message-dialog'; +import { TooltipProvider } from '../src/lib/ui/tooltip'; +import type { LoadResult, RecentMessage } from '../src/types'; + +const CHANNELS = [ + { id: 'C1', name: 'general' }, + { id: 'C2', name: 'random' } +]; + +const RECENT_BY_CHANNEL: Record = { + C1: [{ channelId: 'C1', channelName: 'general', ts: '111.1', blocks: [], label: 'hi general' }], + C2: [{ channelId: 'C2', channelName: 'random', ts: '222.2', blocks: [], label: 'hi random' }] +}; + +const noopLoad = async (): Promise => ({ ok: false, reason: 'nope' }); + +function renderDialog(overrides: Partial[0]> = {}): { + loadRecentMessages: (channelId: string) => Promise; +} { + const loadRecentMessages = overrides.loadRecentMessages ?? (async (id: string) => RECENT_BY_CHANNEL[id] ?? []); + render( + + {}} + onLoadMessage={noopLoad} + loadChannels={async () => CHANNELS} + loadRecentMessages={loadRecentMessages} + onLoaded={() => {}} + onOpenAsNew={() => {}} + {...overrides} + /> + + ); + return { loadRecentMessages }; +} + +describe('LoadMessageDialog recent-messages picker', () => { + it('requires a channel selection before listing recent messages', async () => { + const calls: string[] = []; + renderDialog({ + loadRecentMessages: async (id) => { + calls.push(id); + return RECENT_BY_CHANNEL[id] ?? []; + } + }); + + // Channel picker shows once channels resolve; nothing fetched yet. + await screen.findByText('Select a channel to see recent messages.'); + expect(calls).toEqual([]); + + // Picking a channel scopes the lookup to it. + fireEvent.change(screen.getByLabelText('Channel'), { target: { value: 'C1' } }); + await screen.findByText('hi general'); + expect(calls).toEqual(['C1']); + expect(screen.queryByText('hi random')).toBeNull(); + + // Changing the channel re-fetches for the new channel only. + fireEvent.change(screen.getByLabelText('Channel'), { target: { value: 'C2' } }); + await screen.findByText('hi random'); + expect(calls).toEqual(['C1', 'C2']); + }); + + it('renders an empty state when the channel has no editable messages', async () => { + renderDialog({ loadRecentMessages: async () => [] }); + fireEvent.change(await screen.findByLabelText('Channel'), { target: { value: 'C1' } }); + await screen.findByText('No recent messages from this app in this channel.'); + }); + + it('renders an error state when the loader throws', async () => { + renderDialog({ + loadRecentMessages: async () => { + throw new Error('boom'); + } + }); + fireEvent.change(await screen.findByLabelText('Channel'), { target: { value: 'C1' } }); + await screen.findByText('boom'); + }); +});