From 4fe556430b109d65e77cb94c51a82999799024b4 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 10 Mar 2026 10:36:00 -0700 Subject: [PATCH] Add desktop search and result anchors --- crates/sprout-relay/src/api/events.rs | 57 +++ crates/sprout-relay/src/api/mod.rs | 4 + crates/sprout-relay/src/router.rs | 1 + desktop/src-tauri/src/lib.rs | 77 ++++ desktop/src/app/AppShell.tsx | 125 ++++++- desktop/src/features/messages/hooks.ts | 2 +- desktop/src/features/messages/types.ts | 1 + .../features/messages/ui/MessageTimeline.tsx | 73 +++- desktop/src/features/search/hooks.ts | 27 ++ .../src/features/search/ui/SearchDialog.tsx | 352 ++++++++++++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 51 ++- desktop/src/shared/api/tauri.ts | 47 +++ desktop/src/shared/api/types.ts | 21 ++ desktop/src/shared/ui/dialog.tsx | 97 +++++ desktop/src/testing/e2eBridge.ts | 162 ++++++++ desktop/tests/e2e/smoke.spec.ts | 27 ++ 16 files changed, 1079 insertions(+), 45 deletions(-) create mode 100644 crates/sprout-relay/src/api/events.rs create mode 100644 desktop/src/features/search/hooks.ts create mode 100644 desktop/src/features/search/ui/SearchDialog.tsx create mode 100644 desktop/src/shared/ui/dialog.tsx diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs new file mode 100644 index 000000000..63de830df --- /dev/null +++ b/crates/sprout-relay/src/api/events.rs @@ -0,0 +1,57 @@ +//! Event lookup endpoints. +//! +//! Endpoints: +//! GET /api/events/:id — fetch a single stored event by ID + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; + +use crate::state::AppState; + +use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found}; + +/// Fetch a single stored event by its 64-char hex ID. +pub async fn get_event( + State(state): State>, + headers: HeaderMap, + Path(event_id): Path, +) -> Result, (StatusCode, Json)> { + let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?; + + let id_bytes = hex::decode(&event_id) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event ID"))?; + if id_bytes.len() != 32 { + return Err(api_error(StatusCode::BAD_REQUEST, "invalid event ID")); + } + + let stored_event = state + .db + .get_event_by_id(&id_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))? + .ok_or_else(|| not_found("event not found"))?; + + if let Some(channel_id) = stored_event.channel_id { + check_channel_access(&state, channel_id, &pubkey_bytes).await?; + } else { + return Err(not_found("event not found")); + } + + let tags = serde_json::to_value(&stored_event.event.tags) + .map_err(|e| internal_error(&format!("tag serialization error: {e}")))?; + + Ok(Json(serde_json::json!({ + "id": stored_event.event.id.to_hex(), + "pubkey": stored_event.event.pubkey.to_hex(), + "created_at": stored_event.event.created_at.as_u64(), + "kind": stored_event.event.kind.as_u16(), + "tags": tags, + "content": stored_event.event.content, + "sig": stored_event.event.sig.to_string(), + }))) +} diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 127aa8746..8a9fcef97 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -2,6 +2,7 @@ //! //! Endpoints are split into focused submodules: //! - `channels` — GET/POST /api/channels +//! - `events` — GET /api/events/:id //! - `search` — GET /api/search //! - `agents` — GET /api/agents //! - `presence` — GET /api/presence @@ -19,6 +20,8 @@ pub mod channels; pub mod channels_metadata; /// Direct message endpoints. pub mod dms; +/// Event lookup endpoint. +pub mod events; /// Personalized home feed endpoint. pub mod feed; /// Channel membership endpoints. @@ -47,6 +50,7 @@ pub use channels_metadata::{ set_topic_handler, unarchive_channel_handler, update_channel_handler, }; pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler}; +pub use events::get_event; pub use feed::feed_handler; pub use members::{add_members, join_channel, leave_channel, list_members, remove_member}; pub use messages::{delete_message, get_thread, list_messages, send_message}; diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 800274ac4..d64dca7ed 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -29,6 +29,7 @@ pub fn build_router(state: Arc) -> Router { "/api/channels", get(api::channels_handler).post(api::create_channel), ) + .route("/api/events/{id}", get(api::get_event)) .route("/api/search", get(api::search_handler)) .route("/api/agents", get(api::agents_handler)) .route("/api/presence", get(api::presence_handler)) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3ebd5ed75..0f0b442ef 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -43,6 +43,13 @@ struct GetFeedQuery<'a> { types: Option<&'a str>, } +#[derive(Serialize)] +struct SearchQueryParams<'a> { + q: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, +} + #[derive(Serialize, Deserialize)] pub struct FeedItemInfo { pub id: String, @@ -77,6 +84,24 @@ pub struct FeedResponse { pub meta: FeedMeta, } +#[derive(Serialize, Deserialize)] +pub struct SearchHitInfo { + pub event_id: String, + pub content: String, + pub kind: u32, + pub pubkey: String, + pub channel_id: String, + pub channel_name: String, + pub created_at: u64, + pub score: f64, +} + +#[derive(Serialize, Deserialize)] +pub struct SearchResponse { + pub hits: Vec, + pub found: u64, +} + fn relay_ws_url() -> String { std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) } @@ -273,6 +298,56 @@ async fn get_feed( .map_err(|e| format!("parse failed: {e}")) } +#[tauri::command] +async fn search_messages( + q: String, + limit: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let pubkey_hex = auth_pubkey_header(&state)?; + let url = format!("{}{}", relay_api_base_url(), "/api/search"); + let response = state + .http_client + .get(url) + .header("X-Pubkey", pubkey_hex) + .query(&SearchQueryParams { + q: q.trim(), + limit, + }) + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + response + .json::() + .await + .map_err(|e| format!("parse failed: {e}")) +} + +#[tauri::command] +async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result { + let request = build_authed_request( + &state.http_client, + &format!("/api/events/{event_id}"), + &state, + ) + .await?; + let response = request + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + response.text().await.map_err(|e| format!("parse failed: {e}")) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let app_state = AppState { @@ -299,6 +374,8 @@ pub fn run() { get_channels, create_channel, get_feed, + search_messages, + get_event, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6777975bc..4fef4199e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -11,19 +11,44 @@ import { HomeView } from "@/features/home/ui/HomeView"; import { useChannelMessagesQuery, useChannelSubscription, + mergeMessages, useSendMessageMutation, } from "@/features/messages/hooks"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; +import { SearchDialog } from "@/features/search/ui/SearchDialog"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; +import { getEventById } from "@/shared/api/tauri"; import { useIdentityQuery } from "@/shared/api/hooks"; +import type { RelayEvent, SearchHit } from "@/shared/api/types"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; type AppView = "home" | "channel"; +function createSearchAnchorEvent(hit: SearchHit): RelayEvent { + return { + id: hit.eventId, + pubkey: hit.pubkey, + created_at: hit.createdAt, + kind: hit.kind, + tags: [["e", hit.channelId]], + content: hit.content, + sig: "", + }; +} + export function AppShell() { const [selectedView, setSelectedView] = React.useState("home"); + const [isSearchOpen, setIsSearchOpen] = React.useState(false); + const [searchAnchor, setSearchAnchor] = React.useState( + null, + ); + const [searchAnchorChannelId, setSearchAnchorChannelId] = React.useState< + string | null + >(null); + const [searchAnchorEvent, setSearchAnchorEvent] = + React.useState(null); const identityQuery = useIdentityQuery(); const homeFeedQuery = useHomeFeedQuery(); const channelsQuery = useChannelsQuery(); @@ -49,15 +74,33 @@ export function AppShell() { () => new Set(channels.map((channel) => channel.id)), [channels], ); + const resolvedMessages = React.useMemo(() => { + const currentMessages = messagesQuery.data ?? []; + + if ( + !activeChannel || + !searchAnchorEvent || + searchAnchorChannelId !== activeChannel.id + ) { + return currentMessages; + } + + return mergeMessages(currentMessages, searchAnchorEvent); + }, [ + activeChannel, + messagesQuery.data, + searchAnchorChannelId, + searchAnchorEvent, + ]); const timelineMessages = React.useMemo( () => formatTimelineMessages( - messagesQuery.data ?? [], + resolvedMessages, activeChannel, identityQuery.data?.pubkey, ), - [activeChannel, identityQuery.data?.pubkey, messagesQuery.data], + [activeChannel, identityQuery.data?.pubkey, resolvedMessages], ); const channelDescription = activeChannel @@ -67,6 +110,46 @@ export function AppShell() { : "Connect to the relay to browse channels and read messages."; const contentPaneKey = selectedView === "home" ? "home" : `channel:${activeChannel?.id ?? "none"}`; + const isTimelineLoading = + messagesQuery.isLoading && resolvedMessages.length === 0; + + const handleOpenChannel = React.useCallback( + (channelId: string) => { + React.startTransition(() => { + setSelectedChannelId(channelId); + setSelectedView("channel"); + }); + }, + [setSelectedChannelId], + ); + + const handleOpenSearchResult = React.useCallback( + (hit: SearchHit) => { + setSearchAnchor(hit); + setSearchAnchorChannelId(hit.channelId); + setSearchAnchorEvent(createSearchAnchorEvent(hit)); + handleOpenChannel(hit.channelId); + + void getEventById(hit.eventId) + .then((event) => { + setSearchAnchorEvent((current) => { + if (current?.id !== hit.eventId) { + return current; + } + + return event; + }); + }) + .catch((error) => { + console.error( + "Failed to load search result event", + hit.eventId, + error, + ); + }); + }, + [handleOpenChannel], + ); return ( @@ -93,6 +176,9 @@ export function AppShell() { setSelectedView("channel"); }); }} + onOpenSearch={() => { + setIsSearchOpen(true); + }} onSelectHome={() => { React.startTransition(() => { setSelectedView("home"); @@ -100,12 +186,7 @@ export function AppShell() { void homeFeedQuery.refetch(); }} - onSelectChannel={(channelId) => { - React.startTransition(() => { - setSelectedChannelId(channelId); - setSelectedView("channel"); - }); - }} + onSelectChannel={handleOpenChannel} selectedChannelId={selectedChannel?.id ?? null} selectedView={selectedView} /> @@ -141,12 +222,7 @@ export function AppShell() { feed={homeFeedQuery.data} isLoading={homeFeedQuery.isLoading} isRefreshing={homeFeedQuery.isRefetching} - onOpenChannel={(channelId) => { - React.startTransition(() => { - setSelectedChannelId(channelId); - setSelectedView("channel"); - }); - }} + onOpenChannel={handleOpenChannel} onRefresh={() => { void homeFeedQuery.refetch(); }} @@ -166,8 +242,19 @@ export function AppShell() { : "No messages yet" : "No channel selected" } - isLoading={messagesQuery.isLoading} + isLoading={isTimelineLoading} + key={activeChannel?.id ?? "no-channel"} messages={timelineMessages} + onTargetReached={(messageId) => { + setSearchAnchor((current) => + current?.eventId === messageId ? null : current, + ); + }} + targetMessageId={ + activeChannel && searchAnchor?.channelId === activeChannel.id + ? searchAnchor.eventId + : null + } /> { await sendMessageMutation.mutateAsync(content); }} @@ -191,6 +279,13 @@ export function AppShell() { )} + + ); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 77138adb9..45a56f294 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -10,7 +10,7 @@ type MessageQueryContext = { queryKey: readonly ["channel-messages", string]; }; -function mergeMessages( +export function mergeMessages( current: RelayEvent[], incoming: RelayEvent, ): RelayEvent[] { diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index a1c3d1744..4b4290b25 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -6,4 +6,5 @@ export type TimelineMessage = { body: string; accent?: boolean; pending?: boolean; + highlighted?: boolean; }; diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 5f60f8892..453978ca6 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -13,6 +13,8 @@ type MessageTimelineProps = { isLoading?: boolean; emptyTitle?: string; emptyDescription?: string; + targetMessageId?: string | null; + onTargetReached?: (messageId: string) => void; }; const BOTTOM_THRESHOLD_PX = 72; @@ -33,7 +35,14 @@ function MessageRow({ message }: { message: TimelineMessage }) { .toUpperCase(); return ( -
+
(null); const contentRef = React.useRef(null); @@ -107,7 +118,11 @@ export function MessageTimeline({ const lockedScrollTopRef = React.useRef(null); const previousLastMessageIdRef = React.useRef(undefined); const previousMessageCountRef = React.useRef(0); + const handledTargetMessageIdRef = React.useRef(null); const [isAtBottom, setIsAtBottom] = React.useState(true); + const [highlightedMessageId, setHighlightedMessageId] = React.useState< + string | null + >(null); const [newMessageCount, setNewMessageCount] = React.useState(0); const latestMessage = messages.length > 0 ? messages[messages.length - 1] : undefined; @@ -352,6 +367,54 @@ export function MessageTimeline({ previousMessageCountRef.current = messages.length; }, [isLoading, latestMessage, messages.length, scrollToBottom]); + React.useEffect(() => { + if (!targetMessageId) { + handledTargetMessageIdRef.current = null; + setHighlightedMessageId(null); + return; + } + + if (handledTargetMessageIdRef.current === targetMessageId || isLoading) { + return; + } + + const timeline = timelineRef.current; + if (!timeline) { + return; + } + + const targetElement = timeline.querySelector( + `[data-message-id="${targetMessageId}"]`, + ); + if (!targetElement) { + return; + } + + handledTargetMessageIdRef.current = targetMessageId; + shouldStickToBottomRef.current = false; + isAtBottomRef.current = false; + isProgrammaticBottomScrollRef.current = false; + targetElement.scrollIntoView({ + block: "center", + behavior: "smooth", + }); + previousScrollTopRef.current = timeline.scrollTop; + setIsAtBottom(false); + setHighlightedMessageId(targetMessageId); + setNewMessageCount(0); + onTargetReached?.(targetMessageId); + + const timeout = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === targetMessageId ? null : current, + ); + }, 2_000); + + return () => { + window.clearTimeout(timeout); + }; + }, [isLoading, onTargetReached, targetMessageId]); + return (
( - + )) : null}
diff --git a/desktop/src/features/search/hooks.ts b/desktop/src/features/search/hooks.ts new file mode 100644 index 000000000..ebbc5ad02 --- /dev/null +++ b/desktop/src/features/search/hooks.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { searchMessages } from "@/shared/api/tauri"; + +export function useSearchMessagesQuery( + query: string, + options?: { + enabled?: boolean; + limit?: number; + }, +) { + const trimmedQuery = query.trim(); + const enabled = options?.enabled ?? true; + const limit = options?.limit ?? 12; + + return useQuery({ + queryKey: ["search-messages", trimmedQuery, limit], + queryFn: () => + searchMessages({ + q: trimmedQuery, + limit, + }), + enabled: enabled && trimmedQuery.length >= 2, + staleTime: 30_000, + gcTime: 5 * 60 * 1_000, + }); +} diff --git a/desktop/src/features/search/ui/SearchDialog.tsx b/desktop/src/features/search/ui/SearchDialog.tsx new file mode 100644 index 000000000..87f30c5fc --- /dev/null +++ b/desktop/src/features/search/ui/SearchDialog.tsx @@ -0,0 +1,352 @@ +import * as React from "react"; +import { + ArrowRight, + Command, + FileText, + Hash, + LoaderCircle, + MessagesSquare, + Search, + type LucideIcon, +} from "lucide-react"; + +import { useSearchMessagesQuery } from "@/features/search/hooks"; +import type { Channel, SearchHit } from "@/shared/api/types"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +const MIN_QUERY_LENGTH = 2; + +function describeSearchHit(hit: SearchHit) { + switch (hit.kind) { + case 45001: + return "Forum post"; + case 45003: + return "Forum reply"; + case 43001: + return "Agent job"; + case 43003: + return "Agent update"; + case 46010: + return "Approval request"; + default: + return "Message"; + } +} + +function truncateContent(content: string) { + const trimmed = content.trim(); + if (trimmed.length === 0) { + return "No message body."; + } + + if (trimmed.length <= 180) { + return trimmed; + } + + return `${trimmed.slice(0, 177)}...`; +} + +function formatRelativeTime(unixSeconds: number) { + const diff = Math.floor(Date.now() / 1_000) - unixSeconds; + + if (diff < 60) { + return "just now"; + } + + if (diff < 60 * 60) { + return `${Math.floor(diff / 60)}m ago`; + } + + if (diff < 60 * 60 * 24) { + return `${Math.floor(diff / (60 * 60))}h ago`; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(unixSeconds * 1_000)); +} + +function SearchState({ + icon: Icon, + title, + description, +}: { + icon: LucideIcon; + title: string; + description: string; +}) { + return ( +
+
+ +
+

{title}

+

+ {description} +

+
+ ); +} + +function SearchLoadingState() { + return ( +
+ {["first", "second", "third"].map((row) => ( +
+ + + +
+ ))} +
+ ); +} + +type SearchDialogProps = { + channels: Channel[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onOpenResult: (hit: SearchHit) => void; +}; + +export function SearchDialog({ + channels, + open, + onOpenChange, + onOpenResult, +}: SearchDialogProps) { + const [query, setQuery] = React.useState(""); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const deferredQuery = React.useDeferredValue(query.trim()); + const inputRef = React.useRef(null); + const channelLookup = React.useMemo( + () => new Map(channels.map((channel) => [channel.id, channel])), + [channels], + ); + + const searchQuery = useSearchMessagesQuery(deferredQuery, { + enabled: open, + limit: 12, + }); + + const results = searchQuery.data?.hits ?? []; + + const openResult = React.useCallback( + (hit: SearchHit) => { + onOpenChange(false); + onOpenResult(hit); + }, + [onOpenChange, onOpenResult], + ); + + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if ( + event.key.toLowerCase() !== "k" || + !(event.metaKey || event.ctrlKey) || + event.altKey || + event.shiftKey + ) { + return; + } + + event.preventDefault(); + onOpenChange(true); + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onOpenChange]); + + React.useEffect(() => { + if (!open) { + setQuery(""); + setSelectedIndex(0); + return; + } + + const timeout = window.setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + + return () => { + window.clearTimeout(timeout); + }; + }, [open]); + + React.useEffect(() => { + setSelectedIndex((current) => { + if (results.length === 0) { + return 0; + } + + return Math.min(current, results.length - 1); + }); + }, [results]); + + const selectedHit = results[selectedIndex]; + + return ( + + + + + + + + Search + + + Full-text search across accessible channels. + +
+ + { + setQuery(event.target.value); + setSelectedIndex(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown" && results.length > 0) { + event.preventDefault(); + setSelectedIndex((current) => + Math.min(current + 1, results.length - 1), + ); + return; + } + + if (event.key === "ArrowUp" && results.length > 0) { + event.preventDefault(); + setSelectedIndex((current) => Math.max(current - 1, 0)); + return; + } + + if ( + event.key === "Enter" && + !event.nativeEvent.isComposing && + selectedHit + ) { + event.preventDefault(); + openResult(selectedHit); + } + }} + placeholder="Search messages, approvals, and forum posts" + ref={inputRef} + value={query} + /> +
+ K +
+
+
+ +
+ {deferredQuery.length < MIN_QUERY_LENGTH ? ( + + ) : searchQuery.isLoading ? ( + + ) : searchQuery.error instanceof Error ? ( + + ) : results.length === 0 ? ( + + ) : ( +
+
+ {searchQuery.data?.found ?? results.length} results + Enter to open +
+ +
+ {results.map((hit, index) => { + const channel = channelLookup.get(hit.channelId); + + return ( + + ); + })} +
+
+ )} +
+ +
+ Search is relay-backed and scoped to channels you can access. +
+
+
+ ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 519a02a22..a2cff35f2 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { CircleDot, FileText, Hash, Home, Plus } from "lucide-react"; +import { CircleDot, FileText, Hash, Home, Plus, Search } from "lucide-react"; import * as React from "react"; import type { Channel } from "@/shared/api/types"; @@ -14,7 +14,6 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarHeader, - SidebarInput, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -34,6 +33,7 @@ type AppSidebarProps = { name: string; description?: string; }) => Promise; + onOpenSearch: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; }; @@ -227,37 +227,25 @@ export function AppSidebar({ selectedChannelId, selectedView, onCreateChannel, + onOpenSearch, onSelectHome, onSelectChannel, }: AppSidebarProps) { const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; - const [query, setQuery] = React.useState(""); const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [draftName, setDraftName] = React.useState(""); const [draftDescription, setDraftDescription] = React.useState(""); const [createErrorMessage, setCreateErrorMessage] = React.useState< string | undefined >(); - const deferredQuery = React.useDeferredValue(query.trim().toLowerCase()); const createInputRef = React.useRef(null); - - const filteredChannels = React.useMemo(() => { - if (!deferredQuery) { - return channels; - } - - return channels.filter((channel) => - channel.name.toLowerCase().includes(deferredQuery), - ); - }, [channels, deferredQuery]); - - const streamChannels = filteredChannels.filter( + const streamChannels = channels.filter( (channel) => channel.channelType === "stream", ); - const forumChannels = filteredChannels.filter( + const forumChannels = channels.filter( (channel) => channel.channelType === "forum", ); - const directMessages = filteredChannels.filter( + const directMessages = channels.filter( (channel) => channel.channelType === "dm", ); @@ -314,13 +302,22 @@ export function AppSidebar({

-
- setQuery(event.target.value)} - placeholder="Jump to channel" - value={query} - /> -
+ @@ -416,9 +413,9 @@ export function AppSidebar({ ) : null} - {!isLoading && filteredChannels.length === 0 ? ( + {!isLoading && channels.length === 0 ? (
- No channels match that filter. + No channels available yet.
) : null} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 113d45f7d..76b8ac06f 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -8,6 +8,8 @@ import type { HomeFeedResponse, Identity, RelayEvent, + SearchMessagesInput, + SearchMessagesResponse, } from "@/shared/api/types"; type RawIdentity = { @@ -50,6 +52,22 @@ type RawHomeFeedResponse = { }; }; +type RawSearchHit = { + event_id: string; + content: string; + kind: number; + pubkey: string; + channel_id: string; + channel_name: string; + created_at: number; + score: number; +}; + +type RawSearchResponse = { + hits: RawSearchHit[]; + found: number; +}; + function fromRawChannel(channel: RawChannel): Channel { return { id: channel.id, @@ -75,6 +93,19 @@ function fromRawFeedItem(item: RawFeedItem) { }; } +function fromRawSearchHit(hit: RawSearchHit) { + return { + eventId: hit.event_id, + content: hit.content, + kind: hit.kind, + pubkey: hit.pubkey, + channelId: hit.channel_id, + channelName: hit.channel_name, + createdAt: hit.created_at, + score: hit.score, + }; +} + export async function getIdentity(): Promise { const identity = await invoke("get_identity"); @@ -120,6 +151,22 @@ export async function getHomeFeed( }; } +export async function searchMessages( + input: SearchMessagesInput, +): Promise { + const response = await invoke("search_messages", input); + + return { + hits: response.hits.map(fromRawSearchHit), + found: response.found, + }; +} + +export async function getEventById(eventId: string): Promise { + const eventJson = await invoke("get_event", { eventId }); + return JSON.parse(eventJson) as RelayEvent; +} + export async function signRelayEvent(input: { kind: number; content: string; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index add116a47..2d6a6d6f4 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -74,3 +74,24 @@ export type GetHomeFeedInput = { limit?: number; types?: string; }; + +export type SearchMessagesInput = { + q: string; + limit?: number; +}; + +export type SearchHit = { + eventId: string; + content: string; + kind: number; + pubkey: string; + channelId: string; + channelName: string; + createdAt: number; + score: number; +}; + +export type SearchMessagesResponse = { + hits: SearchHit[]; + found: number; +}; diff --git a/desktop/src/shared/ui/dialog.tsx b/desktop/src/shared/ui/dialog.tsx new file mode 100644 index 000000000..59db92844 --- /dev/null +++ b/desktop/src/shared/ui/dialog.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b46cd4852..455bae496 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -52,6 +52,22 @@ type RawHomeFeedResponse = { }; }; +type RawSearchHit = { + event_id: string; + content: string; + kind: number; + pubkey: string; + channel_id: string; + channel_name: string; + created_at: number; + score: number; +}; + +type RawSearchResponse = { + hits: RawSearchHit[]; + found: number; +}; + type WsHandler = (message: unknown) => void; type MockSocket = { @@ -62,6 +78,7 @@ type MockSocket = { declare global { interface Window { __SPROUT_E2E__?: E2eConfig; + __SPROUT_E2E_COMMANDS__?: string[]; } } @@ -493,6 +510,139 @@ async function handleGetFeed( return response.json(); } +async function handleSearchMessages( + args: { + q: string; + limit?: number; + }, + config: E2eConfig | undefined, +): Promise { + const identity = getIdentity(config); + if (!identity) { + const query = args.q.trim().toLowerCase(); + const limit = args.limit ?? 20; + const now = Math.floor(Date.now() / 1000); + + const mockHits: RawSearchHit[] = [ + { + event_id: "mock-general-welcome", + content: "Welcome to #general", + kind: 40001, + pubkey: DEFAULT_MOCK_IDENTITY.pubkey, + channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + channel_name: "general", + created_at: now - 60, + score: 8.5, + }, + { + event_id: "mock-engineering-shipped", + content: "Engineering shipped the desktop build.", + kind: 40001, + pubkey: + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260", + channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + channel_name: "engineering", + created_at: now - 42 * 60, + score: 7.2, + }, + { + event_id: "mock-forum-release-thread", + content: "Release checklist: async feedback thread.", + kind: 45001, + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", + channel_name: "watercooler", + created_at: now - 90 * 60, + score: 5.8, + }, + ]; + + const hits = mockHits + .filter((hit) => { + if (!query) { + return true; + } + + return ( + hit.content.toLowerCase().includes(query) || + hit.channel_name.toLowerCase().includes(query) + ); + }) + .slice(0, limit); + + return { + hits, + found: hits.length, + }; + } + + const url = new URL("/api/search", getRelayHttpUrl(config)); + url.searchParams.set("q", args.q); + if (args.limit !== undefined) { + url.searchParams.set("limit", String(args.limit)); + } + + const response = await fetch(url, { + headers: { + "X-Pubkey": identity.pubkey, + }, + }); + await assertOk(response); + return response.json(); +} + +async function handleGetEvent( + args: { + eventId: string; + }, + config: E2eConfig | undefined, +) { + const identity = getIdentity(config); + if (!identity) { + const knownEvents: RelayEvent[] = [ + ...Array.from(mockMessages.values()).flat(), + { + id: "mock-engineering-shipped", + pubkey: + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260", + created_at: Math.floor(Date.now() / 1000) - 42 * 60, + kind: 40001, + tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]], + content: "Engineering shipped the desktop build.", + sig: "mocksig".repeat(20).slice(0, 128), + }, + { + id: "mock-forum-release-thread", + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + created_at: Math.floor(Date.now() / 1000) - 90 * 60, + kind: 45001, + tags: [["e", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"]], + content: "Release checklist: async feedback thread.", + sig: "mocksig".repeat(20).slice(0, 128), + }, + ]; + const event = knownEvents.find((item) => item.id === args.eventId); + if (!event) { + throw new Error(`Event not found: ${args.eventId}`); + } + + return JSON.stringify(event); + } + + const response = await fetch( + `${getRelayHttpUrl(config)}/api/events/${args.eventId}`, + { + headers: { + "X-Pubkey": identity.pubkey, + }, + }, + ); + await assertOk(response); + return JSON.stringify(await response.json()); +} + async function connectRealSocket(args: { url?: string; onMessage: unknown }) { const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); @@ -657,9 +807,11 @@ export function maybeInstallE2eTauriMocks() { } mockWindows("main"); + window.__SPROUT_E2E_COMMANDS__ = []; mockIPC(async (command, payload) => { const activeConfig = getConfig(); const identity = getIdentity(activeConfig); + window.__SPROUT_E2E_COMMANDS__?.push(command); switch (command) { case "get_identity": @@ -685,6 +837,16 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "search_messages": + return handleSearchMessages( + payload as Parameters[0], + activeConfig, + ); + case "get_event": + return handleGetEvent( + payload as Parameters[0], + activeConfig, + ); case "sign_event": if (identity) { return JSON.stringify( diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index ab0325e9c..0a5fa84ee 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -91,6 +91,33 @@ test("opens a mocked channel from the home feed", async ({ page }) => { ); }); +test("opens relay-backed search from the sidebar and loads the exact result", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page.getByTestId("open-search")).toBeVisible(); + await page.keyboard.press( + process.platform === "darwin" ? "Meta+K" : "Control+K", + ); + await expect(page.getByTestId("search-dialog")).toBeVisible(); + + await page.getByTestId("search-input").fill("shipped"); + await expect(page.getByTestId("search-results")).toContainText( + "Engineering shipped the desktop build.", + ); + + await page + .getByTestId("search-results") + .getByText("Engineering shipped the desktop build.") + .click(); + + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Engineering shipped the desktop build.", + ); +}); + test("replaces the channel pane when switching channels", async ({ page }) => { await page.goto("/");