Add desktop search and result anchors (#15)

This commit is contained in:
Wes
2026-03-10 13:54:25 -07:00
committed by GitHub
parent 4fcc4c35cc
commit 1be84cc315
16 changed files with 1079 additions and 45 deletions
+57
View File
@@ -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<Arc<AppState>>,
headers: HeaderMap,
Path(event_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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(),
})))
}
+4
View File
@@ -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};
+1
View File
@@ -29,6 +29,7 @@ pub fn build_router(state: Arc<AppState>) -> 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))
+77
View File
@@ -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<u32>,
}
#[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<SearchHitInfo>,
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<u32>,
state: tauri::State<'_, AppState>,
) -> Result<SearchResponse, String> {
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::<SearchResponse>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
#[tauri::command]
async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result<String, String> {
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");
+110 -15
View File
@@ -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<AppView>("home");
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
null,
);
const [searchAnchorChannelId, setSearchAnchorChannelId] = React.useState<
string | null
>(null);
const [searchAnchorEvent, setSearchAnchorEvent] =
React.useState<RelayEvent | null>(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 (
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
@@ -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
}
/>
<MessageComposer
channelName={activeChannel?.name ?? "channel"}
@@ -177,6 +264,7 @@ export function AppShell() {
sendMessageMutation.isPending
}
isSending={sendMessageMutation.isPending}
key={activeChannel?.id ?? "no-channel"}
onSend={async (content) => {
await sendMessageMutation.mutateAsync(content);
}}
@@ -191,6 +279,13 @@ export function AppShell() {
</>
)}
</div>
<SearchDialog
channels={channels}
onOpenResult={handleOpenSearchResult}
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
/>
</SidebarInset>
</SidebarProvider>
);
+1 -1
View File
@@ -10,7 +10,7 @@ type MessageQueryContext = {
queryKey: readonly ["channel-messages", string];
};
function mergeMessages(
export function mergeMessages(
current: RelayEvent[],
incoming: RelayEvent,
): RelayEvent[] {
+1
View File
@@ -6,4 +6,5 @@ export type TimelineMessage = {
body: string;
accent?: boolean;
pending?: boolean;
highlighted?: boolean;
};
@@ -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 (
<article className="flex gap-3" data-testid="message-row">
<article
className={cn(
"flex gap-3 rounded-2xl px-2 py-2 transition-colors",
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
)}
data-message-id={message.id}
data-testid="message-row"
>
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
@@ -94,6 +103,8 @@ export function MessageTimeline({
isLoading = false,
emptyTitle = "No messages yet",
emptyDescription = "Send the first message to start the thread.",
targetMessageId = null,
onTargetReached,
}: MessageTimelineProps) {
const timelineRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
@@ -107,7 +118,11 @@ export function MessageTimeline({
const lockedScrollTopRef = React.useRef<number | null>(null);
const previousLastMessageIdRef = React.useRef<string | undefined>(undefined);
const previousMessageCountRef = React.useRef(0);
const handledTargetMessageIdRef = React.useRef<string | null>(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<HTMLElement>(
`[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 (
<div className="relative flex-1 min-h-0">
<div
@@ -393,7 +456,13 @@ export function MessageTimeline({
{!isLoading
? messages.map((message) => (
<MessageRow key={message.id} message={message} />
<MessageRow
key={message.id}
message={{
...message,
highlighted: message.id === highlightedMessageId,
}}
/>
))
: null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
+27
View File
@@ -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,
});
}
@@ -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 (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
<p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{description}
</p>
</div>
);
}
function SearchLoadingState() {
return (
<div className="space-y-3 px-3 py-3" data-testid="search-loading">
{["first", "second", "third"].map((row) => (
<div
className="rounded-2xl border border-border/80 bg-card/60 p-4"
key={row}
>
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
))}
</div>
);
}
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<HTMLInputElement>(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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="gap-0 overflow-hidden p-0"
data-testid="search-dialog"
>
<DialogHeader className="border-b border-border/80 px-6 py-5">
<DialogTitle className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
<Search className="h-4 w-4" />
</span>
Search
</DialogTitle>
<DialogDescription>
Full-text search across accessible channels.
</DialogDescription>
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-sm">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
data-testid="search-input"
onChange={(event) => {
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}
/>
<div className="hidden items-center gap-1 rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground sm:flex">
<Command className="h-3 w-3" />K
</div>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{deferredQuery.length < MIN_QUERY_LENGTH ? (
<SearchState
description="Type at least two characters to search the relay-backed history for streams, forums, DMs, approvals, and agent updates."
icon={MessagesSquare}
title="Search message history"
/>
) : searchQuery.isLoading ? (
<SearchLoadingState />
) : searchQuery.error instanceof Error ? (
<SearchState
description={searchQuery.error.message}
icon={LoaderCircle}
title="Search unavailable"
/>
) : results.length === 0 ? (
<SearchState
description="Try a different keyword, channel name, or phrase from the message body."
icon={Search}
title="No matches found"
/>
) : (
<div className="p-3" data-testid="search-results">
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<span>{searchQuery.data?.found ?? results.length} results</span>
<span>Enter to open</span>
</div>
<div className="space-y-2">
{results.map((hit, index) => {
const channel = channelLookup.get(hit.channelId);
return (
<button
className={
index === selectedIndex
? "w-full rounded-2xl border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-sm outline-none transition-colors"
: "w-full rounded-2xl border border-border/80 bg-card/60 px-4 py-4 text-left shadow-sm outline-none transition-colors hover:border-primary/20 hover:bg-accent"
}
data-testid={`search-result-${hit.eventId}`}
key={hit.eventId}
onClick={() => openResult(hit)}
onMouseEnter={() => setSelectedIndex(index)}
type="button"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground">
{channel?.channelType === "forum" ? (
<FileText className="h-4 w-4" />
) : (
<Hash className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">
{hit.channelName}
</p>
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{describeSearchHit(hit)}
</p>
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
{formatRelativeTime(hit.createdAt)}
</p>
</div>
<p className="mt-2 text-sm leading-6 text-foreground">
{truncateContent(hit.content)}
</p>
</div>
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</button>
);
})}
</div>
</div>
)}
</div>
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
Search is relay-backed and scoped to channels you can access.
</div>
</DialogContent>
</Dialog>
);
}
+24 -27
View File
@@ -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<void>;
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<HTMLInputElement>(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({
</p>
</div>
</div>
<div className="flex items-center gap-2">
<SidebarInput
onChange={(event) => setQuery(event.target.value)}
placeholder="Jump to channel"
value={query}
/>
</div>
<Button
className="w-full justify-between rounded-xl border border-sidebar-border/80 bg-sidebar-accent/60 px-3 text-sidebar-foreground/80 shadow-sm hover:bg-sidebar-accent hover:text-sidebar-foreground"
data-testid="open-search"
onClick={onOpenSearch}
size="sm"
type="button"
variant="ghost"
>
<span className="flex items-center gap-2">
<Search className="h-4 w-4" />
Search messages
</span>
<span className="rounded-md border border-sidebar-border/80 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-sidebar-foreground/60">
Cmd K
</span>
</Button>
</SidebarHeader>
<SidebarSeparator />
@@ -416,9 +413,9 @@ export function AppSidebar({
</>
) : null}
{!isLoading && filteredChannels.length === 0 ? (
{!isLoading && channels.length === 0 ? (
<div className="px-3 py-2 text-sm text-sidebar-foreground/70">
No channels match that filter.
No channels available yet.
</div>
) : null}
+47
View File
@@ -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<Identity> {
const identity = await invoke<RawIdentity>("get_identity");
@@ -120,6 +151,22 @@ export async function getHomeFeed(
};
}
export async function searchMessages(
input: SearchMessagesInput,
): Promise<SearchMessagesResponse> {
const response = await invoke<RawSearchResponse>("search_messages", input);
return {
hits: response.hits.map(fromRawSearchHit),
found: response.found,
};
}
export async function getEventById(eventId: string): Promise<RelayEvent> {
const eventJson = await invoke<string>("get_event", { eventId });
return JSON.parse(eventJson) as RelayEvent;
}
export async function signRelayEvent(input: {
kind: number;
content: string;
+21
View File
@@ -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;
};
+97
View File
@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
ref={ref}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-[12vh] z-50 grid w-[calc(100vw-2rem)] max-w-2xl -translate-x-1/2 gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
ref={ref}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-2 text-left", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
className={cn("text-lg font-semibold tracking-tight", className)}
ref={ref}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)}
ref={ref}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+162
View File
@@ -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<RawSearchResponse> {
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<typeof handleCreateChannel>[0],
activeConfig,
);
case "search_messages":
return handleSearchMessages(
payload as Parameters<typeof handleSearchMessages>[0],
activeConfig,
);
case "get_event":
return handleGetEvent(
payload as Parameters<typeof handleGetEvent>[0],
activeConfig,
);
case "sign_event":
if (identity) {
return JSON.stringify(
+27
View File
@@ -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("/");