diff --git a/desktop/src/features/workflows/ui/AuthorGridPicker.tsx b/desktop/src/features/workflows/ui/AuthorGridPicker.tsx index a12105e14..012fef2ab 100644 --- a/desktop/src/features/workflows/ui/AuthorGridPicker.tsx +++ b/desktop/src/features/workflows/ui/AuthorGridPicker.tsx @@ -145,7 +145,8 @@ export function AuthorGridPicker({ ); function selectAuthor(pubkey: string) { - onChange(pubkey.toLowerCase()); + const normalizedPubkey = pubkey.toLowerCase(); + onChange(normalizedPubkey === normalizedValue ? "" : normalizedPubkey); } function loadNextPage() { diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index 603391a6d..3b9f26f0b 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -46,6 +46,7 @@ import { getWorkflowTriggerSummary, getWorkflowTriggerType, } from "./workflowDefinition"; +import { TriggerDescriptionText } from "./WorkflowNodeDescriptions"; import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation"; type WorkflowCardProps = { @@ -182,6 +183,28 @@ function ReactionLabelText({ return parts.length > 0 ? parts : text; } +function TriggerCardText({ + authorLoading, + messageLoading, + reactions, + text, +}: { + authorLoading?: boolean; + messageLoading?: boolean; + reactions: ReadonlyArray<{ emoji: string; url?: string }>; + text: string; +}) { + return authorLoading || messageLoading ? ( + + ) : ( + + ); +} + export function WorkflowCard({ workflow, channelName, @@ -348,14 +371,21 @@ export function WorkflowCard({ {triggerSummary ? (

-

) : null}

- +

{description ? (

diff --git a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx index 690cc7d1a..33c30ee95 100644 --- a/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowConditionBuilder.tsx @@ -1,8 +1,11 @@ +import { ChevronRight } from "lucide-react"; import * as React from "react"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { useIdentityQuery } from "@/shared/api/hooks"; -import type { Channel } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; +import type { Channel, UserProfileSummary } from "@/shared/api/types"; +import { truncatePubkey } from "@/shared/lib/pubkey"; import { Input } from "@/shared/ui/input"; import { AuthorGridPicker } from "./AuthorGridPicker"; import { MessageIdPicker } from "./MessageIdPicker"; @@ -13,7 +16,6 @@ import { conditionFieldsForTrigger, conditionOperatorsForField, conditionOperatorNeedsValue, - CUSTOM_CONDITION_FIELD, defaultConditionOperatorForField, normalizeWebhookField, parseConditionExpressions, @@ -53,6 +55,10 @@ type ConditionEditorState = { editors: ParsedConditionExpression[]; }; +type ActiveConditionEditor = + | { kind: "field"; draft: ParsedConditionExpression } + | { kind: "custom"; draft: string }; + function normalizedEditor( editor: ParsedConditionExpression, ): ParsedConditionExpression { @@ -105,13 +111,87 @@ function valuePlaceholder(field: string): string { } } -function ConditionEditorControls({ +function compactValue(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= 18) return trimmed; + return `${trimmed.slice(0, 10)}…${trimmed.slice(-5)}`; +} + +function editorSummary(editor: ParsedConditionExpression): string { + const operator = operatorLabel( + editor.field, + editor.operator, + conditionOperatorsForField(editor.field).length === 2, + ); + if (!conditionOperatorNeedsValue(editor.operator)) return operator; + const value = compactValue(editor.value); + if (!value) return "Off"; + return editor.field === "trigger_text" + ? `${operator} “${value}”` + : `${operator} ${value}`; +} + +function authorSummaryLabel( + pubkey: string, + profile?: UserProfileSummary | null, +): string { + return ( + profile?.displayName?.trim() || + profile?.name?.trim() || + profile?.nip05Handle?.trim() || + truncatePubkey(pubkey) + ); +} + +function AuthorConditionSummary({ + editor, + profile, +}: { + editor: ParsedConditionExpression; + profile?: UserProfileSummary | null; +}) { + const label = authorSummaryLabel(editor.value, profile); + const isExcluded = editor.operator === "not_equals"; + + return ( + + + + {isExcluded ? ( + + ); +} + +function ConditionEditorFields({ channelId, disabled, editor, idPrefix, knownAuthorPubkeys, - label, onChange, }: { channelId?: string | null; @@ -119,7 +199,6 @@ function ConditionEditorControls({ editor: ParsedConditionExpression; idPrefix: string; knownAuthorPubkeys: string[]; - label: string; onChange: (editor: ParsedConditionExpression) => void; }) { const needsValue = conditionOperatorNeedsValue(editor.operator); @@ -132,13 +211,10 @@ function ConditionEditorControls({ const controlIdPrefix = `${idPrefix}-${editor.field}`; return ( -

- - {label} -
Match ) : null} -
+ ); } @@ -250,7 +326,6 @@ export function WorkflowConditionBuilder({ channels, disabled, idPrefix, - matchAllLabel = "All events", onChange, triggerType, value, @@ -301,12 +376,27 @@ export function WorkflowConditionBuilder({ const [editorState, setEditorState] = React.useState(() => initialEditorState(value, triggerType), ); + const [activeEditor, setActiveEditor] = + React.useState(null); const previousTriggerType = React.useRef(triggerType); + const selectedAuthorPubkey = editorState.custom + ? "" + : (editorState.editors + .find((editor) => editor.field === "trigger_author") + ?.value.trim() + .toLowerCase() ?? ""); + const selectedAuthorProfileQuery = useUsersBatchQuery( + selectedAuthorPubkey ? [selectedAuthorPubkey] : [], + ); + const selectedAuthorProfile = selectedAuthorPubkey + ? selectedAuthorProfileQuery.data?.profiles[selectedAuthorPubkey] + : undefined; React.useEffect(() => { if (previousTriggerType.current === triggerType) return; previousTriggerType.current = triggerType; setEditorState(initialEditorState(value, triggerType)); + setActiveEditor(null); }, [triggerType, value]); const emitEditors = (editors: ParsedConditionExpression[]) => { @@ -314,136 +404,185 @@ export function WorkflowConditionBuilder({ onChange(buildConditionExpressions(editors)); }; - const updateEditor = (next: ParsedConditionExpression) => { - emitEditors( - editorState.editors.map((editor) => - editor.field === next.field ? next : editor, - ), - ); - }; - const evalexprFields = fields .map((field) => field.value === "webhook_field" ? "trigger_" : field.value, ) .join(", "); - const fieldOptions = [ - { label: matchAllLabel, value: "" }, - ...fields, - { label: "Custom", value: CUSTOM_CONDITION_FIELD }, - ]; - const selectedFields = new Set( - editorState.editors.map((editor) => editor.field), - ); + const openFieldEditor = (field: (typeof fields)[number]) => { + if ( + activeEditor?.kind === "field" && + activeEditor.draft.field === field.value + ) { + setActiveEditor(null); + return; + } + const existing = editorState.editors.find( + (editor) => editor.field === field.value, + ); + setActiveEditor({ + kind: "field", + draft: existing ?? { + field: field.value, + operator: defaultConditionOperatorForField(field.value), + value: "", + webhookField: "", + }, + }); + }; + + const updateFieldEditor = (draft: ParsedConditionExpression) => { + const isComplete = + (draft.field !== "webhook_field" || + normalizeWebhookField(draft.webhookField) !== null) && + (!conditionOperatorNeedsValue(draft.operator) || + draft.value.trim().length > 0); + setActiveEditor({ kind: "field", draft }); + if (!isComplete) { + emitEditors( + editorState.editors.filter((editor) => editor.field !== draft.field), + ); + return; + } + emitEditors( + editorState.custom + ? [draft] + : [ + ...editorState.editors.filter( + (editor) => editor.field !== draft.field, + ), + draft, + ], + ); + }; + + const updateCustomEditor = (draft: string) => { + const trimmed = draft.trim(); + setActiveEditor({ + kind: "custom", + draft, + }); + if (trimmed) { + setEditorState({ custom: true, editors: [] }); + onChange(trimmed); + } else { + setEditorState({ custom: false, editors: [] }); + onChange(""); + } + }; return ( -
-
- Condition -
- {fieldOptions.map((field) => { - const isMatchAll = field.value === ""; - const isCustom = field.value === CUSTOM_CONDITION_FIELD; - const isSelected = isMatchAll - ? !editorState.custom && editorState.editors.length === 0 - : isCustom - ? editorState.custom - : selectedFields.has(field.value); - return ( -
- -
+
+ {fields.map((field) => { + const editor = editorState.custom + ? undefined + : editorState.editors.find( + (candidate) => candidate.field === field.value, ); - })} -
-
+ const isExpanded = + activeEditor?.kind === "field" && + activeEditor.draft.field === field.value; + return ( +
+ - {editorState.custom ? ( -
- - Custom expression - - onChange(event.target.value)} - placeholder='e.g. str_contains(trigger_text, "deploy")' - value={value} + {isExpanded ? ( +
+ +
+ ) : null} +
+ ); + })} + +
+
- ) : ( - editorState.editors.map((editor) => ( - field.value === editor.field)?.label ?? - editor.field - } - onChange={updateEditor} - /> - )) - )} + + + {activeEditor?.kind === "custom" ? ( +
+
+ + Expression + + updateCustomEditor(event.target.value)} + placeholder='e.g. str_contains(trigger_text, "deploy")' + value={activeEditor.draft} + /> +

+ Use an evalexpr expression with {evalexprFields}. +

+
+
+ ) : null} +
); } diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index d4e001075..ad734a788 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -567,6 +567,7 @@ export function WorkflowFormBuilder({ diff --git a/desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx b/desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx index cf3b5a439..17e6ec419 100644 --- a/desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx +++ b/desktop/src/features/workflows/ui/WorkflowNodeDescriptions.tsx @@ -3,16 +3,21 @@ import { motion } from "motion/react"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { TRIGGER_MESSAGE_LOADING_LABEL } from "./workflowTriggerDescription"; +import { + TRIGGER_AUTHOR_LOADING_LABEL, + TRIGGER_MESSAGE_LOADING_LABEL, +} from "./workflowTriggerDescription"; export function TriggerNodeDescription({ authorAvatarUrl, authorLabel, + authorLoading, description, messageLoading, }: { authorAvatarUrl?: string | null; authorLabel?: string | null; + authorLoading?: boolean; description: string; messageLoading?: boolean; }) { @@ -20,6 +25,7 @@ export function TriggerNodeDescription({ if (!authorLabel || authorIndex < 0) { return ( @@ -45,6 +51,7 @@ export function TriggerNodeDescription({ {authorLabel}{" "} {suffix ? ( @@ -54,39 +61,65 @@ export function TriggerNodeDescription({ ); } -function TriggerDescriptionText({ +export function TriggerDescriptionText({ + authorLoading, messageLoading, text, }: { + authorLoading?: boolean; messageLoading?: boolean; text: string; }) { - const loadingIndex = messageLoading - ? text.indexOf(TRIGGER_MESSAGE_LOADING_LABEL) - : -1; - if (loadingIndex < 0) return text; + const references = [ + authorLoading + ? { + ariaLabel: "Loading author", + delay: 0, + testId: "workflow-trigger-author-loading", + token: TRIGGER_AUTHOR_LOADING_LABEL, + } + : null, + messageLoading + ? { + ariaLabel: "Loading message", + delay: 0.5, + testId: "workflow-trigger-message-loading", + token: TRIGGER_MESSAGE_LOADING_LABEL, + } + : null, + ].filter((reference) => reference !== null); + if (references.length === 0) return text; - const prefix = text.slice(0, loadingIndex); - const suffix = text.slice( - loadingIndex + TRIGGER_MESSAGE_LOADING_LABEL.length, - ); - return ( - <> - {prefix} + const parts: React.ReactNode[] = []; + let cursor = 0; + while (cursor < text.length) { + const next = references + .map((reference) => ({ + index: text.indexOf(reference.token, cursor), + reference, + })) + .filter(({ index }) => index >= 0) + .sort((left, right) => left.index - right.index)[0]; + if (!next) break; + if (next.index > cursor) parts.push(text.slice(cursor, next.index)); + parts.push( - {suffix} - - ); + , + ); + cursor = next.index + next.reference.token.length; + } + if (cursor < text.length) parts.push(text.slice(cursor)); + return parts.length > 0 ? parts : text; } export function StepReactionDescription({ diff --git a/desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts b/desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts index 34c4da8d3..315db69e0 100644 --- a/desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts +++ b/desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts @@ -13,6 +13,7 @@ const FULL_HEX_ID = /^[0-9a-f]{64}$/i; export type WorkflowTriggerPresentation = { authorAvatarUrl?: string | null; authorLabel?: string; + authorLoading?: boolean; description: string; emoji?: string; messageLoading?: boolean; @@ -48,13 +49,17 @@ export function useWorkflowTriggerPresentation({ const authorProfile = authorPubkey ? profilesQuery.data?.profiles[authorPubkey.toLowerCase()] : undefined; - const authorLabel = authorPubkey - ? resolveUserLabel({ - currentPubkey: identityQuery.data?.pubkey, - profiles: profilesQuery.data?.profiles, - pubkey: authorPubkey, - }) - : undefined; + const authorLoading = Boolean( + authorPubkey && !authorProfile && profilesQuery.isFetching, + ); + const authorLabel = + authorPubkey && !authorLoading + ? resolveUserLabel({ + currentPubkey: identityQuery.data?.pubkey, + profiles: profilesQuery.data?.profiles, + pubkey: authorPubkey, + }) + : undefined; const messageCondition = conditions?.find( (condition) => condition.field === "trigger_message_id", ); @@ -84,7 +89,9 @@ export function useWorkflowTriggerPresentation({ return { authorAvatarUrl: authorProfile?.avatarUrl, authorLabel, + authorLoading, description: workflowTriggerDescription(trigger, { + authorLoading, authorLabel, messageLabel: message?.content.trim() || undefined, messageLoading, diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs index 505d33b2c..48e5d8d4e 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs @@ -15,6 +15,17 @@ test("describes selected trigger conditions on the workflow canvas", () => { "Message posted by Carl", ); + assert.equal( + workflowTriggerDescription( + { + on: "message_posted", + filter: `trigger_author == "${"a".repeat(64)}"`, + }, + { authorLoading: true }, + ), + "Message posted by loading author", + ); + assert.equal( workflowTriggerDescription({ on: "message_posted", diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts index 19011e9a4..7c756a0c8 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts @@ -11,6 +11,16 @@ const EVENT_PHRASES = { } as const; export const TRIGGER_MESSAGE_LOADING_LABEL = "loading message"; +export const TRIGGER_AUTHOR_LOADING_LABEL = "loading author"; + +function authorReference( + condition: ParsedConditionExpression, + authorLabel?: string, + authorLoading?: boolean, +): string { + if (authorLoading) return TRIGGER_AUTHOR_LOADING_LABEL; + return authorLabel ?? truncatePubkey(condition.value); +} function quotedValue(value: string): string { const normalized = value.trim().replaceAll(/\s+/g, " "); @@ -60,6 +70,7 @@ function textConditionDescription( export function workflowTriggerDescription( trigger: TriggerConfig, options: { + authorLoading?: boolean; authorLabel?: string; messageLabel?: string; messageLoading?: boolean; @@ -104,8 +115,11 @@ export function workflowTriggerDescription( : eventPhrase; } if (authorCondition) { - const author = - options.authorLabel ?? truncatePubkey(authorCondition.value); + const author = authorReference( + authorCondition, + options.authorLabel, + options.authorLoading, + ); const attribution = authorCondition.operator === "not_equals" ? ` by anyone except ${author}` @@ -133,7 +147,11 @@ export function workflowTriggerDescription( } if (condition.field === "trigger_author") { - const author = options.authorLabel ?? truncatePubkey(condition.value); + const author = authorReference( + condition, + options.authorLabel, + options.authorLoading, + ); return condition.operator === "not_equals" ? `${eventPhrase} by anyone except ${author}` : `${eventPhrase} by ${author}`;