Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecentMessage[]> => {
const loadRecentMessages = useCallback(async (channelId: string): Promise<RecentMessage[]> => {
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;
Expand Down
1 change: 1 addition & 0 deletions src/components/block-kitchen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
103 changes: 87 additions & 16 deletions src/components/load-message-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoadResult, { ok: true }> {
Expand Down Expand Up @@ -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
Expand All @@ -49,20 +50,26 @@ export function LoadMessageDialog({
onOpenChange,
onLoadMessage,
loadRecentMessages,
loadChannels,
onLoaded,
onOpenAsNew
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onLoadMessage: (input: { link: string }) => Promise<LoadResult>;
loadRecentMessages?: () => Promise<RecentMessage[]>;
loadRecentMessages?: (channelId: string) => Promise<RecentMessage[]>;
loadChannels: () => Promise<ChannelOption[]>;
onLoaded: (result: Extract<LoadResult, { ok: true }>) => void;
onOpenAsNew: (blocks?: SupportedBlock[]) => void;
}) {
const [link, setLink] = useState('');
const [status, setStatus] = useState<LoadStatus>({ 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<ChannelOption[] | null>(null);
const [channelsError, setChannelsError] = useState<string | null>(null);
const [channelId, setChannelId] = useState<string>('');
// Recent messages for the selected channel. `null` means "loading / not loaded yet".
const [recent, setRecent] = useState<RecentMessage[] | null>(null);
const [recentError, setRecentError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
Expand All @@ -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);
Expand All @@ -109,7 +147,7 @@ export function LoadMessageDialog({
return () => {
cancelled = true;
};
}, [open]);
}, [open, channelId]);

const handleLoad = async () => {
const trimmed = link.trim();
Expand Down Expand Up @@ -226,14 +264,47 @@ export function LoadMessageDialog({
or pick a recent message
<span className="h-px flex-1 bg-border" />
</div>
{recent === null && !recentError && (

{/* Pick a channel first — the recent lookup is scoped to it. */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="recent-channel-picker">Channel</Label>
{channels === null && !channelsError && (
<p className="text-xs text-muted-foreground">Loading channels…</p>
)}
{channelsError && <p className="text-xs text-destructive">{channelsError}</p>}
{channels && channels.length === 0 && !channelsError && (
<p className="text-xs text-muted-foreground">No public channels available.</p>
)}
{channels && channels.length > 0 && (
<select
id="recent-channel-picker"
value={channelId}
onChange={(e) => setChannelId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="" disabled>
Select a channel…
</option>
{channels.map((c) => (
<option key={c.id} value={c.id}>
#{c.name}
</option>
))}
</select>
)}
</div>

{!channelId && channels && channels.length > 0 && (
<p className="text-xs text-muted-foreground">Select a channel to see recent messages.</p>
)}
{channelId && recent === null && !recentError && (
<p className="text-xs text-muted-foreground">Loading recent messages…</p>
)}
{recentError && <p className="text-xs text-destructive">{recentError}</p>}
{recent && recent.length === 0 && !recentError && (
<p className="text-xs text-muted-foreground">No recent messages from this app.</p>
{channelId && recentError && <p className="text-xs text-destructive">{recentError}</p>}
{channelId && recent && recent.length === 0 && !recentError && (
<p className="text-xs text-muted-foreground">No recent messages from this app in this channel.</p>
)}
{recent && recent.length > 0 && (
{channelId && recent && recent.length > 0 && (
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{recent.map((m) => {
// Which identity the message was posted as — drives both
Expand Down
18 changes: 11 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -614,11 +615,14 @@ export interface EditingConfig {
onUpdate: (payload: UpdatePayload) => Promise<UpdateResult>;
/**
* 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<RecentMessage[]>;
loadRecentMessages?: (channelId: string) => Promise<RecentMessage[]>;
}

/**
Expand Down
80 changes: 80 additions & 0 deletions test/load-message-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, RecentMessage[]> = {
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<LoadResult> => ({ ok: false, reason: 'nope' });

function renderDialog(overrides: Partial<Parameters<typeof LoadMessageDialog>[0]> = {}): {
loadRecentMessages: (channelId: string) => Promise<RecentMessage[]>;
} {
const loadRecentMessages = overrides.loadRecentMessages ?? (async (id: string) => RECENT_BY_CHANNEL[id] ?? []);
render(
<TooltipProvider>
<LoadMessageDialog
open
onOpenChange={() => {}}
onLoadMessage={noopLoad}
loadChannels={async () => CHANNELS}
loadRecentMessages={loadRecentMessages}
onLoaded={() => {}}
onOpenAsNew={() => {}}
{...overrides}
/>
</TooltipProvider>
);
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');
});
});
Loading