diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 5de2d32b809..1e221b6bd18 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -396,23 +396,8 @@ pub async fn get_channel_messages_before( }) } -#[tauri::command] -pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result { - let events = query_relay( - &state, - &[serde_json::json!({ - "ids": [event_id], - "kinds": [0, 1, 3, 5, 7, 9, 30078, 40002, 40003, 40008, 40099, 40100, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED], - "limit": 1 - })], - ) - .await?; - - let ev = events - .first() - .ok_or_else(|| "event not found".to_string())?; - serde_json::to_string(ev).map_err(|e| format!("serialize event: {e}")) -} +mod event_batch; +pub use event_batch::{get_event, get_events}; // ── Writes ────────────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/commands/messages/event_batch.rs b/desktop/src-tauri/src/commands/messages/event_batch.rs new file mode 100644 index 00000000000..1bb51748683 --- /dev/null +++ b/desktop/src-tauri/src/commands/messages/event_batch.rs @@ -0,0 +1,128 @@ +use std::collections::HashSet; + +use tauri::State; + +use crate::{app_state::AppState, relay::query_relay}; + +// The relay clamps a single filter to this many events. Keep exact-ID reads in +// chunks so a large workflow list cannot silently lose late presentations. +const EVENT_QUERY_CHUNK_SIZE: usize = 1_000; + +const GET_EVENT_KINDS: [u32; 15] = [ + 0, + 1, + 3, + 5, + 7, + 9, + 30078, + 40002, + 40003, + 40008, + 40099, + 40100, + 45001, + 45003, + buzz_core_pkg::kind::KIND_HUDDLE_STARTED, +]; + +#[tauri::command] +pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result { + let events = query_relay( + &state, + &[serde_json::json!({ + "ids": [event_id], + "kinds": GET_EVENT_KINDS, + "limit": 1 + })], + ) + .await?; + + let event = events + .first() + .ok_or_else(|| "event not found".to_string())?; + serde_json::to_string(event).map_err(|error| format!("serialize event: {error}")) +} + +/// Resolve many exact event IDs in relay-sized chunks. Callers still validate +/// event kind, channel scope, and requested ID before using presentation data. +fn normalized_event_id_chunks(event_ids: Vec) -> Vec> { + let mut seen_ids = HashSet::new(); + let event_ids = event_ids + .into_iter() + .map(|event_id| event_id.trim().to_ascii_lowercase()) + .filter(|event_id| event_id.len() == 64 && event_id.chars().all(|c| c.is_ascii_hexdigit())) + .filter(|event_id| seen_ids.insert(event_id.clone())) + .collect::>(); + event_ids + .chunks(EVENT_QUERY_CHUNK_SIZE) + .map(<[String]>::to_vec) + .collect() +} + +#[tauri::command] +pub async fn get_events( + event_ids: Vec, + state: State<'_, AppState>, +) -> Result, String> { + let event_id_chunks = normalized_event_id_chunks(event_ids); + if event_id_chunks.is_empty() { + return Ok(Vec::new()); + } + + let mut events_by_id = std::collections::HashMap::new(); + for event_ids in event_id_chunks { + let events = query_relay( + &state, + &[serde_json::json!({ + "ids": event_ids, + "kinds": GET_EVENT_KINDS, + "limit": event_ids.len() + })], + ) + .await?; + for event in events { + events_by_id.entry(event.id).or_insert(event); + } + } + + events_by_id + .into_values() + .map(|event| { + serde_json::to_value(event).map_err(|error| format!("serialize event: {error}")) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_exact_relay_ceiling_in_one_chunk() { + let chunks = normalized_event_id_chunks( + (0..EVENT_QUERY_CHUNK_SIZE) + .map(|index| format!("{index:064x}")) + .collect(), + ); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].len(), EVENT_QUERY_CHUNK_SIZE); + } + + #[test] + fn normalizes_deduplicates_and_keeps_ids_beyond_relay_ceiling() { + let last_id = format!("{:064x}", EVENT_QUERY_CHUNK_SIZE); + let mut event_ids = (0..=EVENT_QUERY_CHUNK_SIZE) + .map(|index| format!("{index:064X}")) + .collect::>(); + event_ids.extend(["not-an-event-id".to_string(), format!(" {last_id} ")]); + + let chunks = normalized_event_id_chunks(event_ids); + + assert_eq!(chunks.iter().map(Vec::len).sum::(), 1_001); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), EVENT_QUERY_CHUNK_SIZE); + assert_eq!(chunks[1], [last_id]); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 428aa4d2a78..613040b8095 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -652,6 +652,7 @@ pub fn run() { add_reaction, remove_reaction, get_event, + get_events, show_native_notification, #[cfg(target_os = "macos")] macos_notifications::take_pending_activations, diff --git a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx new file mode 100644 index 00000000000..bcf0b209ffa --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx @@ -0,0 +1,361 @@ +import { Check, LoaderCircle, Search } from "lucide-react"; +import * as React from "react"; + +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { useRelayMembersQuery } from "@/features/community-members/hooks"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + enrichAuthorCandidates, + filterAuthorCandidatePage, + mergeAuthorCandidateSources, + nextWorkflowAuthorIndex, + parseDirectAuthorInput, + type WorkflowAuthorCandidate, +} from "./workflowAuthorCandidates"; + +const PAGE_SIZE = 50; + +export function WorkflowAuthorPicker({ + channelId, + disabled, + id, + onChange, + onEscape, + value, +}: { + channelId?: string | null; + disabled?: boolean; + id: string; + onChange: (pubkey: string) => void; + onEscape?: () => void; + value: string; +}) { + const pickerRef = React.useRef(null); + const optionRefs = React.useRef(new Map()); + const [query, setQuery] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(null); + const [columnCount, setColumnCount] = React.useState(2); + const trimmedQuery = query.trim(); + const deferredQuery = React.useDeferredValue(trimmedQuery); + const normalizedValue = parseDirectAuthorInput(value); + const channelMembersQuery = useChannelMembersQuery(channelId ?? null); + const relayMembersQuery = useRelayMembersQuery(true); + const directoryQuery = useInfiniteUserSearchQuery(deferredQuery, { + allowEmpty: true, + limit: PAGE_SIZE, + }); + const directoryResults = useFlattenedUserSearchResults(directoryQuery.data); + const directPubkey = parseDirectAuthorInput(deferredQuery); + + const baseCandidates = React.useMemo( + () => + mergeAuthorCandidateSources([ + normalizedValue ? [{ pubkey: normalizedValue }] : [], + directPubkey ? [{ pubkey: directPubkey }] : [], + channelMembersQuery.data ?? [], + relayMembersQuery.data ?? [], + directoryResults, + ]), + [ + channelMembersQuery.data, + directPubkey, + directoryResults, + normalizedValue, + relayMembersQuery.data, + ], + ); + const candidatePage = React.useMemo( + () => + filterAuthorCandidatePage( + baseCandidates, + deferredQuery, + directPubkey, + PAGE_SIZE, + ), + [baseCandidates, deferredQuery, directPubkey], + ); + const profileQuery = useUsersBatchQuery( + candidatePage.map(({ pubkey }) => pubkey), + ); + const candidates = React.useMemo( + () => + enrichAuthorCandidates(candidatePage, profileQuery.data?.profiles ?? {}), + [candidatePage, profileQuery.data?.profiles], + ); + const visibleCandidates = candidates; + const listId = `${id}-list`; + + React.useEffect(() => { + if (activeIndex !== null && activeIndex >= visibleCandidates.length) { + setActiveIndex( + visibleCandidates.length > 0 ? visibleCandidates.length - 1 : null, + ); + } + }, [activeIndex, visibleCandidates.length]); + + React.useEffect(() => { + const picker = pickerRef.current; + if (!picker) return; + const updateColumnCount = () => + setColumnCount(picker.clientWidth >= 544 ? 3 : 2); + updateColumnCount(); + const observer = new ResizeObserver(updateColumnCount); + observer.observe(picker); + return () => observer.disconnect(); + }, []); + + React.useEffect(() => { + if (activeIndex === null) return; + const candidate = visibleCandidates[activeIndex]; + if (!candidate) return; + optionRefs.current + .get(candidate.pubkey) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeIndex, visibleCandidates]); + + const loading = + (channelMembersQuery.isLoading || + relayMembersQuery.isLoading || + directoryQuery.isLoading) && + visibleCandidates.length === 0; + const failed = + channelMembersQuery.isError || + relayMembersQuery.isError || + directoryQuery.isError; + + function moveActive(delta: number) { + setActiveIndex((current) => + nextWorkflowAuthorIndex(current, delta, visibleCandidates.length), + ); + } + + return ( +
+
+ + { + setQuery(event.target.value); + setActiveIndex(null); + }} + onKeyDown={(event) => { + const currentQuery = event.currentTarget.value.trim(); + const delta = + event.key === "ArrowDown" + ? columnCount + : event.key === "ArrowUp" + ? -columnCount + : event.key === "ArrowRight" + ? 1 + : event.key === "ArrowLeft" + ? -1 + : 0; + if (delta) { + event.preventDefault(); + moveActive(delta); + } else if ( + event.key === "Enter" && + currentQuery === deferredQuery && + visibleCandidates[activeIndex ?? 0] + ) { + event.preventDefault(); + const candidate = visibleCandidates[activeIndex ?? 0]; + onChange( + candidate.pubkey === normalizedValue ? "" : candidate.pubkey, + ); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (query) { + setQuery(""); + setActiveIndex(null); + } else { + onEscape?.(); + } + } + }} + placeholder="Search people or paste a public key…" + role="combobox" + spellCheck={false} + value={query} + /> +
+ +
{ + const list = event.currentTarget; + if ( + list.scrollHeight - list.scrollTop - list.clientHeight < 64 && + directoryQuery.hasNextPage && + !directoryQuery.isFetchingNextPage + ) { + void directoryQuery.fetchNextPage(); + } + }} + role="listbox" + > + {visibleCandidates.map((candidate, index) => ( + { + setActiveIndex(null); + onChange( + candidate.pubkey === normalizedValue ? "" : candidate.pubkey, + ); + }} + optionRef={(node) => { + if (node) optionRefs.current.set(candidate.pubkey, node); + else optionRefs.current.delete(candidate.pubkey); + }} + selected={candidate.pubkey === normalizedValue} + /> + ))} + {loading ? ( +

+ Loading authors… +

+ ) : visibleCandidates.length === 0 ? ( +

+ {failed ? "Couldn’t load authors." : "No authors found."} +

+ ) : null} + {failed ? ( + + ) : null} + {directoryQuery.hasNextPage ? ( + + ) : null} +
+
+ ); +} + +function AuthorOption({ + active, + candidate, + disabled, + id, + onSelect, + optionRef, + selected, +}: { + active: boolean; + candidate: WorkflowAuthorCandidate; + disabled?: boolean; + id: string; + onSelect: () => void; + optionRef: (node: HTMLButtonElement | null) => void; + selected: boolean; +}) { + const label = resolveUserLabel({ + fallbackName: candidate.displayName, + profiles: { + [candidate.pubkey]: { + displayName: candidate.displayName, + avatarUrl: candidate.avatarUrl, + nip05Handle: candidate.nip05Handle, + ownerPubkey: candidate.ownerPubkey, + isAgent: candidate.isAgent, + }, + }, + pubkey: candidate.pubkey, + }); + return ( + + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index 48ce8d2db60..8556424f878 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -24,14 +24,20 @@ import { getWorkflowActionTiles, getWorkflowCardLabel, getWorkflowTriggerEmoji, + getWorkflowTriggerConfig, getWorkflowTriggerType, } from "./workflowDefinition"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; +import type { WorkflowCardAuthorPresentation } from "./useWorkflowListAuthorPresentations"; +import type { WorkflowMessagePresentation } from "./useWorkflowListMessagePresentations"; +import { workflowTriggerDescription } from "./workflowTriggerDescription"; type WorkflowCardProps = { workflow: Workflow; + authorPresentation?: WorkflowCardAuthorPresentation; channelName?: string; isTogglingEnabled?: boolean; + messagePresentation?: WorkflowMessagePresentation; onView: (workflow: Workflow) => void; onTrigger: (workflowId: string) => void; onToggleEnabled: (workflow: Workflow) => void; @@ -189,8 +195,10 @@ function ActionTileStack({ export function WorkflowCard({ workflow, + authorPresentation, channelName, isTogglingEnabled = false, + messagePresentation, onView, onTrigger, onToggleEnabled, @@ -201,7 +209,17 @@ export function WorkflowCard({ const [triggerAnimationSequence, setTriggerAnimationSequence] = React.useState(0); const isEnabled = getWorkflowEnabled(workflow.definition); - const cardLabel = getWorkflowCardLabel(workflow.definition); + const configuredTrigger = getWorkflowTriggerConfig(workflow.definition); + const cardLabel = getWorkflowCardLabel(workflow.definition, { + triggerDescription: configuredTrigger + ? workflowTriggerDescription(configuredTrigger, { + authorLabel: authorPresentation?.label ?? undefined, + authorLoading: authorPresentation?.loading, + messageLabel: messagePresentation?.messageLabel ?? undefined, + messageLoading: messagePresentation?.messageLoading, + }) + : undefined, + }); const triggerType = getWorkflowTriggerType(workflow.definition); const actionTiles = getWorkflowActionTiles(workflow.definition); const triggerEmoji = getWorkflowTriggerEmoji(workflow.definition); diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index 3f8655a3aa1..198e0fe91c3 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -557,6 +557,13 @@ export function WorkflowDialog({ { + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-workflow-filter-picker-search]") + ) { + event.preventDefault(); + return; + } if (formBuilderRef.current?.closeInspector()) { event.preventDefault(); event.stopPropagation(); diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 58e4b4a960b..8ac97402aa0 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -30,8 +30,10 @@ import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; import { reactionConditionValue } from "./workflowReactionCondition"; import { WorkflowTriggerConditions } from "./WorkflowTriggerConditions"; +import { WorkflowRichTriggerDescription } from "./WorkflowRichTriggerDescription"; +import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation"; +import { compactWorkflowTriggerDescription } from "./workflowTriggerDescription"; import { workflowStepDescription } from "./workflowStepDescription"; -import { workflowTriggerDescription } from "./workflowTriggerDescription"; import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; import { @@ -67,12 +69,14 @@ function TriggerConfigFields({ trigger, onConditionDraftsChange, onUpdate, + workflowChannelId, }: { conditionDrafts: ParsedConditionExpression[] | null; disabled?: boolean; trigger: TriggerConfig; onConditionDraftsChange: (drafts: ParsedConditionExpression[] | null) => void; onUpdate: (trigger: TriggerConfig) => void; + workflowChannelId?: string | null; }) { switch (trigger.on) { case "message_posted": @@ -94,6 +98,7 @@ function TriggerConfigFields({ } triggerType={trigger.on} value={reactionConditionValue(trigger)} + workflowChannelId={workflowChannelId} /> ); case "webhook": @@ -220,7 +225,7 @@ function WorkflowNode({ terminal, title, }: { - description: string; + description: React.ReactNode; disabled?: boolean; icon?: React.ReactNode; label: string; @@ -614,10 +619,23 @@ export const WorkflowFormBuilder = React.forwardRef< ) ?.value.trim(); }, [formState.trigger]); - const triggerDescription = workflowTriggerDescription(formState.trigger); - const visibleTriggerDescription = triggerEmoji - ? "Reaction added" - : triggerDescription; + const triggerPresentation = useWorkflowTriggerPresentation( + formState.trigger, + workflowChannelId, + ); + const triggerDescription = triggerPresentation.description; + const compactTriggerDescription = compactWorkflowTriggerDescription( + triggerDescription, + triggerEmoji, + ); + const visibleTriggerDescription = ( + + ); const TriggerIcon = { diff_posted: GitPullRequest, message_posted: MessageSquare, @@ -889,6 +907,7 @@ export const WorkflowFormBuilder = React.forwardRef< > {selectedNode.type === "trigger" ? ( -
+
) : selectedStep ? ( diff --git a/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx new file mode 100644 index 00000000000..6639d85b0ff --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx @@ -0,0 +1,469 @@ +import { useInfiniteQuery, useQueries, useQuery } from "@tanstack/react-query"; +import { Check, LoaderCircle, Search } from "lucide-react"; +import * as React from "react"; + +import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} 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 } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + mergeMessageCandidateSources, + normalizeMessageEventId, + type WorkflowMessageCandidate, + validatedWorkflowMessageCandidate, + validateWorkflowMessageSearchResults, +} from "./workflowMessageCandidates"; + +const PAGE_SIZE = 25; + +function truncateContent(content: string | null): 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 | null): string | null { + if (unixSeconds === null) return null; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(unixSeconds * 1_000)); +} + +export function WorkflowMessagePicker({ + channelId, + disabled, + id, + onChange, + onEscape, + value, +}: { + channelId?: string | null; + disabled?: boolean; + id: string; + onChange: (messageId: string) => void; + onEscape?: () => void; + value: string; +}) { + const optionRefs = React.useRef(new Map()); + const [query, setQuery] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(null); + const trimmedQuery = query.trim(); + const deferredQuery = React.useDeferredValue(trimmedQuery); + const normalizedQuery = deferredQuery.toLowerCase(); + const selectedId = normalizeMessageEventId(value); + const directId = normalizeMessageEventId(query); + const lookupId = directId ?? selectedId; + + const historyQuery = useInfiniteQuery({ + enabled: Boolean(channelId), + initialPageParam: null as ChannelPageCursor | null, + queryKey: ["workflow-message-picker", channelId], + queryFn: async ({ pageParam }) => { + if (!channelId) throw new Error("Choose a channel first."); + return parseChannelWindowResponse( + await getChannelWindowEvents(channelId, pageParam, PAGE_SIZE), + channelId, + pageParam, + ); + }, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + staleTime: 30_000, + }); + const searchQuery = useSearchMessagesQuery(deferredQuery, { + channelId: channelId ?? undefined, + enabled: Boolean(channelId && normalizedQuery && !directId), + limit: 30, + minimumQueryLength: 1, + }); + const exactQuery = useQuery({ + enabled: Boolean(channelId && lookupId), + queryKey: ["workflow-message-picker-exact", channelId, lookupId], + queryFn: () => getEventById(lookupId ?? ""), + retry: false, + staleTime: 60_000, + }); + + const selectedFallback = selectedId + ? [{ id: selectedId, pubkey: null, content: null, createdAt: null }] + : []; + const directFallback = directId + ? [{ id: directId, pubkey: null, content: null, createdAt: null }] + : []; + const historyCandidates = React.useMemo( + () => + (historyQuery.data?.pages ?? []).flatMap((page) => + page.rows.flatMap(({ event }) => { + const candidate = channelId + ? validatedWorkflowMessageCandidate(event, { channelId }) + : null; + return candidate ? [candidate] : []; + }), + ), + [channelId, historyQuery.data?.pages], + ); + const searchHitIds = React.useMemo( + () => [ + ...new Set( + (searchQuery.data?.hits ?? []).flatMap((hit) => { + const eventId = normalizeMessageEventId(hit.eventId); + return eventId ? [eventId] : []; + }), + ), + ], + [searchQuery.data?.hits], + ); + const searchEventQueries = useQueries({ + queries: searchHitIds.map((eventId) => ({ + enabled: Boolean(channelId), + queryKey: ["workflow-message-picker-search-event", channelId, eventId], + queryFn: () => getEventById(eventId), + retry: false, + staleTime: 60_000, + })), + }); + const searchCandidates = validateWorkflowMessageSearchResults( + searchHitIds.map((requestedId, index) => ({ + requestedId, + event: searchEventQueries[index]?.data, + })), + channelId ?? "", + ); + const exactCandidate = React.useMemo(() => { + if (!channelId || !lookupId) return null; + return validatedWorkflowMessageCandidate(exactQuery.data, { + channelId, + requestedId: lookupId, + }); + }, [channelId, exactQuery.data, lookupId]); + const allCandidates = React.useMemo(() => { + // Preserve the relay-provided history/search order. Exact lookups and raw-ID + // fallbacks only fill gaps; selecting an existing row must not move it. + return mergeMessageCandidateSources([ + historyCandidates, + searchCandidates, + exactCandidate ? [exactCandidate] : [], + selectedFallback, + directFallback, + ]); + }, [ + directFallback, + exactCandidate, + historyCandidates, + searchCandidates, + selectedFallback, + ]); + const visibleCandidates = React.useMemo(() => { + if (directId) return allCandidates.filter(({ id }) => id === directId); + if (!normalizedQuery) return allCandidates; + return allCandidates.filter( + (candidate) => + candidate.id.includes(normalizedQuery) || + candidate.content?.toLowerCase().includes(normalizedQuery), + ); + }, [allCandidates, directId, normalizedQuery]); + const profilePubkeys = React.useMemo( + () => [ + ...new Set( + visibleCandidates.flatMap(({ pubkey }) => (pubkey ? [pubkey] : [])), + ), + ], + [visibleCandidates], + ); + const profilesQuery = useUsersBatchQuery(profilePubkeys); + const listId = `${id}-list`; + + React.useEffect(() => { + if (activeIndex !== null && activeIndex >= visibleCandidates.length) { + setActiveIndex( + visibleCandidates.length > 0 ? visibleCandidates.length - 1 : null, + ); + } + }, [activeIndex, visibleCandidates.length]); + React.useEffect(() => { + if (activeIndex === null) return; + const candidate = visibleCandidates[activeIndex]; + if (!candidate) return; + optionRefs.current + .get(candidate.id) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeIndex, visibleCandidates]); + + const searchEventsFetching = searchEventQueries.some( + ({ isFetching }) => isFetching, + ); + const searchEventsFailed = searchEventQueries.some(({ isError }) => isError); + const loading = + (historyQuery.isLoading || + searchQuery.isFetching || + searchEventsFetching || + exactQuery.isFetching) && + visibleCandidates.length === 0; + const failed = + historyQuery.isError || + searchQuery.isError || + searchEventsFailed || + exactQuery.isError; + const invalidDirectResult = Boolean( + directId && lookupId === directId && exactQuery.data && !exactCandidate, + ); + + return ( +
+
+ + { + setQuery(event.target.value); + setActiveIndex(null); + }} + onKeyDown={(event) => { + const currentQuery = event.currentTarget.value.trim(); + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (visibleCandidates.length > 0) { + setActiveIndex((current) => { + const startingIndex = current ?? -1; + return ( + (startingIndex + + (event.key === "ArrowDown" ? 1 : -1) + + visibleCandidates.length) % + visibleCandidates.length + ); + }); + } + } else if (event.key === "Enter") { + const currentDirectId = normalizeMessageEventId(currentQuery); + if (currentQuery !== deferredQuery && !currentDirectId) return; + const candidate = currentDirectId + ? allCandidates.find(({ id }) => id === currentDirectId) + : visibleCandidates[activeIndex ?? 0]; + if (!candidate) return; + event.preventDefault(); + if (invalidDirectResult) return; + onChange(candidate.id === selectedId ? "" : candidate.id); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (query) { + setQuery(""); + setActiveIndex(null); + } else { + onEscape?.(); + } + } + }} + placeholder={ + channelId + ? "Search messages or paste a message ID…" + : "Choose a channel first" + } + role="combobox" + spellCheck={false} + value={query} + /> + {historyQuery.isFetching || + searchQuery.isFetching || + searchEventsFetching || + exactQuery.isFetching ? ( + + ) : null} +
+ {invalidDirectResult ? ( +

+ That message is not available in this channel. +

+ ) : null} +
{ + const list = event.currentTarget; + if ( + !normalizedQuery && + list.scrollHeight - list.scrollTop - list.clientHeight < 64 && + historyQuery.hasNextPage && + !historyQuery.isFetchingNextPage + ) { + void historyQuery.fetchNextPage(); + } + }} + role="listbox" + > + {visibleCandidates.map((candidate, index) => ( + { + setActiveIndex(null); + if (!invalidDirectResult) { + onChange(candidate.id === selectedId ? "" : candidate.id); + } + }} + optionRef={(node) => { + if (node) optionRefs.current.set(candidate.id, node); + else optionRefs.current.delete(candidate.id); + }} + profiles={profilesQuery.data?.profiles} + selected={candidate.id === selectedId} + /> + ))} + {loading ? ( +

+ Loading messages… +

+ ) : visibleCandidates.length === 0 ? ( +

+ {failed + ? "Couldn’t load messages." + : normalizedQuery + ? "No messages found." + : "No messages yet."} +

+ ) : null} + {failed ? ( + + ) : null} + {!normalizedQuery && historyQuery.hasNextPage ? ( + + ) : null} +
+
+ ); +} + +function MessageOption({ + active, + candidate, + disabled, + id, + onSelect, + optionRef, + profiles, + selected, +}: { + active: boolean; + candidate: WorkflowMessageCandidate; + disabled?: boolean; + id: string; + onSelect: () => void; + optionRef: (node: HTMLButtonElement | null) => void; + profiles?: UserProfileLookup; + selected: boolean; +}) { + const author = candidate.pubkey + ? resolveUserLabel({ profiles, pubkey: candidate.pubkey }) + : "Selected message"; + const profile = candidate.pubkey + ? profiles?.[candidate.pubkey.toLowerCase()] + : undefined; + const timestamp = formatTimestamp(candidate.createdAt); + return ( + + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx new file mode 100644 index 00000000000..50eb5ef6726 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx @@ -0,0 +1,53 @@ +import { LoaderCircle } from "lucide-react"; + +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { splitWorkflowAuthorDescription } from "./workflowTriggerDescription"; + +export function WorkflowRichTriggerDescription({ + avatarUrl, + description, + label, + loading, +}: { + avatarUrl?: string | null; + description: string; + label?: string | null; + loading?: boolean; +}) { + if (loading) { + return ( + + {description} + + + ); + } + + const segments = label + ? splitWorkflowAuthorDescription(description, label) + : null; + if (!label || !segments) return description; + + const { prefix, suffix } = segments; + return ( + + {prefix ? {prefix} : null} + + + {label} + {suffix ? ` ${suffix}` : ""} + + + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx index 39caec253a5..78f6e30606d 100644 --- a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx +++ b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx @@ -3,8 +3,12 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { WorkflowAuthorPicker } from "./WorkflowAuthorPicker"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; +import { WorkflowMessagePicker } from "./WorkflowMessagePicker"; +import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation"; import { FieldLabel } from "./workflowFormPrimitives"; import { buildConditionExpressions, @@ -45,12 +49,81 @@ function compact(value: string): string { : `${trimmed.slice(0, 11)}…${trimmed.slice(-6)}`; } +function fieldUsesFullHeightPicker(field: string): boolean { + return field === "trigger_author" || field === "trigger_message_id"; +} + function fieldPlaceholder(field: string): string { if (field === "trigger_author") return "64-character hex pubkey"; if (field === "trigger_message_id") return "64-character hex event ID"; return "e.g. deploy"; } +function ExclusionStrike() { + return ( +
) : ( -
+
{fields.map((field) => { const existing = conditions.find( (condition) => condition.field === field.value, @@ -179,8 +279,32 @@ export function WorkflowTriggerConditions({ const summary = existing ? `${OPERATOR_LABELS[condition.operator]}${condition.value ? ` ${compact(condition.value)}` : ""}` : "Any"; + const authorSummary = + existing && + field.value === "trigger_author" && + triggerPresentation.pubkey && + triggerPresentation.label + ? triggerPresentation.label + : null; + const messageSummary = + existing && + field.value === "trigger_message_id" && + triggerPresentation.messageId && + triggerPresentation.messageLabel + ? triggerPresentation.messageLabel + .trim() + .replaceAll(/\s+/g, " ") + : null; return ( -
+
{expanded ? ( -
+
Match
@@ -258,6 +417,51 @@ export function WorkflowTriggerConditions({ } value={condition.value} /> + ) : field.value === "trigger_author" ? ( + collapsePicker(field.value)} + onChange={(pubkey) => + updateConditions( + pubkey + ? [ + ...conditions.filter( + (item) => item.field !== field.value, + ), + { ...condition, value: pubkey }, + ] + : conditions.filter( + (item) => item.field !== field.value, + ), + ) + } + value={condition.value} + /> + ) : field.value === "trigger_message_id" ? ( + collapsePicker(field.value)} + onChange={(messageId) => + updateConditions( + messageId + ? [ + ...conditions.filter( + (item) => item.field !== field.value, + ), + { ...condition, value: messageId }, + ] + : conditions.filter( + (item) => item.field !== field.value, + ), + ) + } + value={condition.value} + /> ) : (
) ) : null} - {existing ? ( + {existing && !fieldUsesFullHeightPicker(field.value) ? (