diff --git a/desktop/src/features/workflows/ui/MessageIdPicker.tsx b/desktop/src/features/workflows/ui/MessageIdPicker.tsx new file mode 100644 index 000000000..3ddae77b5 --- /dev/null +++ b/desktop/src/features/workflows/ui/MessageIdPicker.tsx @@ -0,0 +1,328 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Check, LoaderCircle, Search } from "lucide-react"; +import * as React from "react"; + +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { useSearchMessagesQuery } from "@/features/search/hooks"; +import { getChannelWindowEvents } from "@/shared/api/channelWindow"; +import { getEventById } from "@/shared/api/tauri"; +import type { ChannelPageCursor, RelayEvent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + CHANNEL_MESSAGE_EVENT_KINDS, + KIND_STREAM_MESSAGE_DIFF, +} from "@/shared/constants/kinds"; +import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; + +const PAGE_SIZE = 25; +const FULL_EVENT_ID = /^[0-9a-f]{64}$/i; +const PICKABLE_KINDS = new Set([ + ...CHANNEL_MESSAGE_EVENT_KINDS, + KIND_STREAM_MESSAGE_DIFF, +]); + +type MessageCandidate = { + id: string; + pubkey: string; + content: string; + createdAt: number; +}; + +function eventChannelId(event: RelayEvent): string | null { + return event.tags.find((tag) => tag[0] === "h")?.[1] ?? null; +} + +function candidateFromEvent(event: RelayEvent): MessageCandidate { + return { + id: event.id, + pubkey: event.pubkey, + content: event.content, + createdAt: event.created_at, + }; +} + +function truncateContent(content: string): string { + const normalized = content.trim().replaceAll(/\s+/g, " "); + if (!normalized) return "No message body"; + return normalized.length > 120 + ? `${normalized.slice(0, 117)}...` + : normalized; +} + +function formatTimestamp(unixSeconds: number): string { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(unixSeconds * 1_000)); +} + +function dedupeCandidates(candidates: MessageCandidate[]): MessageCandidate[] { + const seen = new Set(); + return candidates.filter((candidate) => { + const normalized = candidate.id.toLowerCase(); + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); +} + +export function MessageIdPicker({ + channelId, + disabled, + id, + onChange, + value, +}: { + channelId?: string | null; + disabled?: boolean; + id: string; + onChange: (messageId: string) => void; + value: string; +}) { + const [query, setQuery] = React.useState(""); + const normalizedQuery = query.trim().toLowerCase(); + const pastedEventId = FULL_EVENT_ID.test(normalizedQuery) + ? normalizedQuery + : null; + + const historyQuery = useInfiniteQuery({ + enabled: Boolean(channelId), + initialPageParam: null as ChannelPageCursor | null, + queryKey: ["workflow-message-id-picker", channelId], + queryFn: async ({ pageParam }) => { + if (!channelId) throw new Error("Choose a channel first."); + const events = await getChannelWindowEvents( + channelId, + pageParam, + PAGE_SIZE, + ); + return parseChannelWindowResponse(events, channelId, pageParam); + }, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + staleTime: 30_000, + }); + + const searchQuery = useSearchMessagesQuery(query, { + channelId: channelId ?? undefined, + enabled: Boolean(channelId) && normalizedQuery.length > 0 && !pastedEventId, + limit: 30, + minimumQueryLength: 1, + }); + + const pastedEventQuery = useQuery({ + enabled: Boolean(channelId && pastedEventId), + queryKey: ["workflow-message-id", channelId, pastedEventId], + queryFn: () => getEventById(pastedEventId ?? ""), + retry: false, + staleTime: 60_000, + }); + + const recentCandidates = React.useMemo( + () => + dedupeCandidates( + (historyQuery.data?.pages ?? []).flatMap((page) => + page.rows + .map(({ event }) => event) + .filter((event) => PICKABLE_KINDS.has(event.kind)) + .map(candidateFromEvent), + ), + ), + [historyQuery.data?.pages], + ); + + const searchCandidates = React.useMemo( + () => + (searchQuery.data?.hits ?? []) + .filter((hit) => PICKABLE_KINDS.has(hit.kind)) + .map((hit) => ({ + id: hit.eventId, + pubkey: hit.pubkey, + content: hit.content, + createdAt: hit.createdAt, + })), + [searchQuery.data?.hits], + ); + + const pastedCandidate = React.useMemo(() => { + const event = pastedEventQuery.data; + if ( + !event || + !channelId || + eventChannelId(event) !== channelId || + !PICKABLE_KINDS.has(event.kind) + ) { + return null; + } + return candidateFromEvent(event); + }, [channelId, pastedEventQuery.data]); + + const displayedCandidates = React.useMemo(() => { + if (!normalizedQuery) return recentCandidates; + const localMatches = recentCandidates.filter( + (candidate) => + candidate.id.toLowerCase().includes(normalizedQuery) || + candidate.content.toLowerCase().includes(normalizedQuery), + ); + return dedupeCandidates([ + ...(pastedCandidate ? [pastedCandidate] : []), + ...localMatches, + ...searchCandidates, + ]); + }, [normalizedQuery, pastedCandidate, recentCandidates, searchCandidates]); + + const profilePubkeys = React.useMemo( + () => [...new Set(displayedCandidates.map(({ pubkey }) => pubkey))], + [displayedCandidates], + ); + const profilesQuery = useUsersBatchQuery(profilePubkeys); + const loading = + historyQuery.isLoading || + searchQuery.isFetching || + pastedEventQuery.isFetching; + const pastedEventInAnotherChannel = + Boolean(pastedEventQuery.data && channelId && pastedEventId) && + !pastedCandidate; + + return ( +
+
+
+ + {pastedEventInAnotherChannel ? ( +

+ That message is not available in this channel. +

+ ) : null} + +
{ + const element = event.currentTarget; + const nearBottom = + element.scrollHeight - element.scrollTop - element.clientHeight < + 80; + if ( + nearBottom && + !normalizedQuery && + historyQuery.hasNextPage && + !historyQuery.isFetchingNextPage + ) { + void historyQuery.fetchNextPage(); + } + }} + > + {displayedCandidates.length > 0 ? ( +
+ {displayedCandidates.map((message) => { + const profile = + profilesQuery.data?.profiles[message.pubkey.toLowerCase()]; + const author = resolveUserLabel({ + pubkey: message.pubkey, + profiles: profilesQuery.data?.profiles, + }); + const selected = value.toLowerCase() === message.id.toLowerCase(); + return ( + + ); + })} +
+ ) : loading ? ( +

+ Loading messages... +

+ ) : ( +

+ {normalizedQuery ? "No messages found." : "No messages yet."} +

+ )} + + {!normalizedQuery && historyQuery.hasNextPage ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx index dbf0f9db6..14d0c2fc4 100644 --- a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx @@ -5,6 +5,7 @@ import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { AuthorGridPicker } from "./AuthorGridPicker"; +import { MessageIdPicker } from "./MessageIdPicker"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; import { FieldLabel, FormSelect } from "./workflowFormPrimitives"; import { @@ -86,6 +87,7 @@ function valuePlaceholder(field: string): string { } export function WorkflowConditionBuilder({ + channelId, channels, disabled, idPrefix, @@ -94,6 +96,7 @@ export function WorkflowConditionBuilder({ triggerType, value, }: { + channelId?: string | null; channels: Channel[]; disabled?: boolean; idPrefix: string; @@ -337,6 +340,16 @@ export function WorkflowConditionBuilder({ } value={editor.value} /> + ) : editor.field === "trigger_message_id" ? ( + + emitEditor({ ...editor, value: messageId }) + } + value={editor.value} + /> ) : ( void; }) { @@ -65,6 +67,7 @@ function TriggerConfigFields({ return (
) : selectedStep ? ( diff --git a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx index ac2ecc5dc..586ac60fb 100644 --- a/desktop/src/features/workflows/ui/WorkflowStepCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowStepCard.tsx @@ -386,6 +386,7 @@ export function WorkflowStepCard({ only this step and continues the run.

{ + await navigateToWorkflows(page); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("combobox", { name: "Channel" }).click(); + await dialog + .getByTestId("channel-combobox-list") + .getByRole("button", { name: /general/i }) + .click(); + const inspector = dialog.getByTestId("workflow-node-inspector"); + await inspector.getByLabel("Trigger event").click(); + await page.getByRole("menuitem", { name: "Reaction Added" }).click(); + await inspector.getByRole("button", { name: "Message ID" }).click(); + + const search = inspector.getByLabel("Search messages or paste a message ID"); + const messageList = inspector.getByTestId("message-id-picker-list"); + await expect(search).toBeVisible(); + await expect(messageList).toHaveCSS("overflow-y", "auto"); + await expect( + messageList.getByRole("button", { name: /Hey team — checking in\./ }), + ).toBeVisible(); + + await search.fill("custom emoji"); + const targetMessage = messageList.getByRole("button", { + name: /React to me with a custom emoji/, + }); + await expect(targetMessage).toBeVisible(); + await targetMessage.click(); + + await dialog.getByRole("tab", { name: "YAML" }).click(); + await expect(dialog.getByLabel("Workflow YAML")).toHaveValue( + /filter: trigger_message_id ==\s+"[0-9a-f]{64}"/, + ); +}); + test("switches an empty workflow between form and YAML modes", async ({ page, }) => {