mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add desktop search and result anchors (#15)
This commit is contained in:
@@ -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(),
|
||||
})))
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ type MessageQueryContext = {
|
||||
queryKey: readonly ["channel-messages", string];
|
||||
};
|
||||
|
||||
function mergeMessages(
|
||||
export function mergeMessages(
|
||||
current: RelayEvent[],
|
||||
incoming: RelayEvent,
|
||||
): RelayEvent[] {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user