diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index 790c9ee44d..2812681140 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -604,12 +604,15 @@ pub async fn run_prompt_task( return; }; - // πŸ’¬ β€” awaited inline so it completes before the prompt fires. - // This guarantees add-before-remove ordering for πŸ’¬: the guard's - // cleanup (spawned on drop) always runs after this returns. - // πŸ‘€ is fire-and-forget from main.rs (see race note in guard docs). + // πŸ’¬ β€” fire-and-forget so the prompt fires immediately. + // The guard's cleanup (spawned on drop) removes πŸ’¬ after the turn completes. + // A brief race where πŸ’¬ appears slightly after the agent starts is acceptable. if !reaction_ids.is_empty() { - react_working(&ctx.rest_client, &reaction_ids).await; + let rest = ctx.rest_client.clone(); + let ids = reaction_ids.clone(); + tokio::spawn(async move { + react_working(&rest, &ids).await; + }); } // ── Send the actual prompt ──────────────────────────────────────────── @@ -1035,8 +1038,8 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { /// /// ## Ordering /// -/// πŸ’¬ (`react_working`) is awaited inline before the prompt fires, so it is -/// guaranteed to precede cleanup. No race possible. +/// πŸ’¬ (`react_working`) is fire-and-forget (spawned before the prompt fires). +/// A brief race where πŸ’¬ appears slightly after the agent starts is acceptable. /// /// πŸ‘€ (`react_seen`) is fire-and-forget from `main.rs` at queue-push time. /// On rare fast-failure paths (e.g., `session_new` error on an idle agent), diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index bd1864e34f..a75535076a 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -37,7 +37,7 @@ pub use event::EventQuery; use chrono::{DateTime, Utc}; use sqlx::mysql::MySqlPoolOptions; -use sqlx::{MySqlPool, Row}; +use sqlx::{MySqlPool, QueryBuilder, Row}; use std::time::Duration; use uuid::Uuid; @@ -78,31 +78,42 @@ pub async fn insert_mentions( let channel_id_bytes = channel_id.map(|id| id.as_bytes().to_vec()); let kind = event.kind.as_u16() as u32; - for pubkey_hex in p_tags { - // Validate: must be exactly 64 hex characters (32-byte pubkey) - if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) { - tracing::debug!( - event_id = %event.id, - invalid_ptag = pubkey_hex, - "skipping malformed p-tag in mentions insert" - ); - continue; - } - // Normalize to lowercase β€” queries use hex::encode which produces lowercase. - let pubkey_lower = pubkey_hex.to_ascii_lowercase(); - sqlx::query( - "INSERT IGNORE INTO event_mentions \ - (pubkey_hex, event_id, event_created_at, channel_id, event_kind) \ - VALUES (?, ?, ?, ?, ?)", - ) - .bind(&pubkey_lower) - .bind(event_id_bytes.as_slice()) - .bind(created_at) - .bind(channel_id_bytes.as_deref()) - .bind(kind) - .execute(pool) - .await?; + // Validate and normalize pubkeys, logging any malformed ones. + let valid_pubkeys: Vec = p_tags + .into_iter() + .filter(|pk| { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + tracing::debug!( + event_id = %event.id, + invalid_ptag = pk, + "skipping malformed p-tag in mentions insert" + ); + return false; + } + true + }) + .map(|pk| pk.to_ascii_lowercase()) + .collect(); + + if valid_pubkeys.is_empty() { + return Ok(()); } + + // Single multi-row INSERT IGNORE β€” one round-trip regardless of mention count. + let mut qb: QueryBuilder<'_, sqlx::MySql> = QueryBuilder::new( + "INSERT IGNORE INTO event_mentions \ + (pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", + ); + + qb.push_values(&valid_pubkeys, |mut b, pubkey| { + b.push_bind(pubkey.as_str()) + .push_bind(event_id_bytes.as_slice()) + .push_bind(created_at) + .push_bind(channel_id_bytes.as_deref()) + .push_bind(kind); + }); + + qb.build().execute(pool).await?; Ok(()) } diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index f6a826fda7..150e0e87d1 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -100,22 +100,15 @@ pub(crate) async fn dispatch_persistent_event( // Skip search indexing for NIP-17 gift wraps β€” content is ciphertext, // and indexing would leak #p tag metadata into the search index. - if kind_u32 != KIND_GIFT_WRAP { - let search = Arc::clone(&state.search); - let stored_for_search = stored_event.clone(); - tokio::spawn(async move { - let t = std::time::Instant::now(); - match search.index_event(&stored_for_search).await { - Ok(()) => { - metrics::histogram!("sprout_search_index_seconds") - .record(t.elapsed().as_secs_f64()); - } - Err(e) => { - metrics::counter!("sprout_search_index_errors_total").increment(1); - error!(event_id = %stored_for_search.event.id.to_hex(), "Search index failed: {e}"); - } - } - }); + // Use the bounded mpsc channel to prevent OOM if Typesense is slow/down. + if kind_u32 != KIND_GIFT_WRAP + && state + .search_index_tx + .try_send(stored_event.clone()) + .is_err() + { + metrics::counter!("sprout_search_index_errors_total").increment(1); + warn!(event_id = %event_id_hex, "Search index channel full β€” dropping event"); } let audit = Arc::clone(&state.audit); @@ -651,8 +644,39 @@ async fn check_channel_membership( conn_id: uuid::Uuid, event_id_hex: &str, ) -> Result<(), String> { + let cache_key = (ch_id, pubkey_bytes.to_vec()); + + // Check cache first. + if let Some(is_member) = state.membership_cache.get(&cache_key) { + if is_member { + return Ok(()); + } + // Cached as non-member β€” still need to check open channel fallback. + let is_open = match state.db.get_channel(ch_id).await { + Ok(ch) => ch.visibility == "open", + Err(e) => { + tracing::warn!(conn_id = %conn_id, channel = %ch_id, error = %e, + "channel lookup failed during membership fallback, denying"); + false + } + }; + return if is_open { + Ok(()) + } else { + Err(RelayMessage::ok( + event_id_hex, + false, + "restricted: not a channel member", + )) + }; + } + + // Cache miss β€” query DB. match state.db.is_member(ch_id, pubkey_bytes).await { - Ok(true) => Ok(()), + Ok(true) => { + state.membership_cache.insert(cache_key, true); + Ok(()) + } Ok(false) => { let is_open = match state.db.get_channel(ch_id).await { Ok(ch) => ch.visibility == "open", @@ -663,8 +687,11 @@ async fn check_channel_membership( } }; if is_open { + // Don't cache β€” non-members in open channels are always allowed, + // and caching false would force a redundant get_channel on every hit. Ok(()) } else { + state.membership_cache.insert(cache_key, false); Err(RelayMessage::ok( event_id_hex, false, @@ -890,10 +917,12 @@ async fn resolve_nip10_thread_meta( let parent_bytes = hex::decode(&parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; - let parent_event = state - .db - .get_event_by_id(&parent_bytes) - .await + let (parent_event_result, parent_meta_result) = tokio::join!( + state.db.get_event_by_id(&parent_bytes), + state.db.get_thread_metadata_by_event(&parent_bytes), + ); + + let parent_event = parent_event_result .map_err(|e| format!("db error looking up parent: {e}"))? .ok_or_else(|| "reply parent not found".to_string())?; @@ -913,11 +942,8 @@ async fn resolve_nip10_thread_meta( let client_root_bytes = hex::decode(&root_hex).map_err(|_| "invalid root event ID hex".to_string())?; - let parent_meta = state - .db - .get_thread_metadata_by_event(&parent_bytes) - .await - .map_err(|e| format!("db error looking up thread metadata: {e}"))?; + let parent_meta = + parent_meta_result.map_err(|e| format!("db error looking up thread metadata: {e}"))?; let (final_root_bytes, root_created, depth) = match parent_meta { Some(meta) => { diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index 0cf19b7c88..dd55e3179f 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -13,6 +13,7 @@ use uuid::Uuid; use deadpool_redis; use sprout_audit::AuditService; use sprout_auth::AuthService; +use sprout_core::event::StoredEvent; use sprout_db::Db; use sprout_media::MediaStorage; use sprout_pubsub::PubSubManager; @@ -151,6 +152,13 @@ pub struct AppState { /// consumer skips them to avoid double delivery. Entries expire after /// 60 seconds via moka's TTL eviction β€” bounded regardless of subscriber health. pub local_event_ids: Arc>, + /// Membership cache: (channel_id, pubkey_bytes) β†’ is_member. + /// Short TTL (10s) β€” membership changes are rare but must propagate. + pub membership_cache: Arc), bool>>, + + /// Bounded channel for search indexing β€” prevents OOM if Typesense is slow/down. + /// Capacity 1000: at ~1KB/event that's ~1MB of backlog before we start dropping. + pub search_index_tx: mpsc::Sender, /// Media storage client (S3/MinIO). pub media_storage: Arc, /// Set to `true` on SIGTERM β€” readiness probe returns 503. @@ -176,6 +184,30 @@ impl AppState { ) -> Self { let max_connections = config.max_connections; let max_concurrent_handlers = config.max_concurrent_handlers; + let search_arc = Arc::new(search); + + let (search_index_tx, mut search_index_rx) = mpsc::channel::(1000); + let search_for_worker = Arc::clone(&search_arc); + tokio::spawn(async move { + while let Some(stored_event) = search_index_rx.recv().await { + let t = std::time::Instant::now(); + match search_for_worker.index_event(&stored_event).await { + Ok(()) => { + metrics::histogram!("sprout_search_index_seconds") + .record(t.elapsed().as_secs_f64()); + } + Err(e) => { + metrics::counter!("sprout_search_index_errors_total").increment(1); + tracing::error!( + event_id = %stored_event.event.id.to_hex(), + "Search index failed: {e}" + ); + } + } + } + tracing::warn!("search index worker exited (expected on shutdown)"); + }); + Self { config: Arc::new(config), db, @@ -183,7 +215,7 @@ impl AppState { audit: Arc::new(audit), pubsub, auth: Arc::new(auth), - search: Arc::new(search), + search: search_arc, sub_registry: Arc::new(SubscriptionRegistry::new()), conn_manager: Arc::new(ConnectionManager::new()), conn_semaphore: Arc::new(Semaphore::new(max_connections)), @@ -198,6 +230,14 @@ impl AppState { .time_to_live(std::time::Duration::from_secs(60)) .build(), ), + membership_cache: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(std::time::Duration::from_secs(10)) + .build(), + ), + + search_index_tx, media_storage: Arc::new(media_storage), shutting_down: Arc::new(AtomicBool::new(false)), started_at: Instant::now(), diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index a9147a5cda..777dd9c3f7 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -150,8 +150,9 @@ export function useChannelsQuery() { return useQuery({ queryKey: channelsQueryKey, queryFn: async () => sortChannels(await getChannels()), - staleTime: 30_000, - refetchInterval: 15_000, + staleTime: 60_000, + refetchInterval: 60_000, + refetchIntervalInBackground: false, }); } diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index f641cb843e..88df69d0f6 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -17,6 +17,13 @@ import { KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; +const HEX_RE = /^[0-9a-f]+$/i; + +const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", +}); + function isTimelineContentEvent(event: RelayEvent) { return ( event.kind === KIND_STREAM_MESSAGE || @@ -32,7 +39,7 @@ function getDeletionTargets(tags: string[][]) { tag[0] === "e" && typeof tag[1] === "string" && tag[1].length === 64 && - /^[0-9a-f]+$/i.test(tag[1]), + HEX_RE.test(tag[1]), ) .map((tag) => tag[1]); } @@ -44,7 +51,7 @@ function getReactionTargetId(tags: string[][]) { tag?.[0] === "e" && typeof tag[1] === "string" && tag[1].length === 64 && - /^[0-9a-f]+$/i.test(tag[1]) + HEX_RE.test(tag[1]) ) { return tag[1]; } @@ -250,10 +257,7 @@ export function formatTimelineMessages( currentUserAvatarUrl, profiles, }), - time: new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", - }).format(new Date(event.created_at * 1_000)), + time: TIME_FORMATTER.format(new Date(event.created_at * 1_000)), body: event.content, parentId: thread.parentId, rootId: thread.rootId, diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx index de9ec7492d..d6f830d7f4 100644 --- a/desktop/src/features/messages/ui/DiffMessage.tsx +++ b/desktop/src/features/messages/ui/DiffMessage.tsx @@ -31,7 +31,7 @@ function getHostname(url: string): string { } } -export function DiffMessage({ +export default function DiffMessage({ content, repoUrl, filePath, diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 39d2d31980..6f88f52d3b 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -231,7 +231,6 @@ export function MessageComposer({ const cursorPosition = textarea.selectionStart ?? content.length; const existingMention = detectMentionQuery(content, cursorPosition); if (existingMention) { - pendingSelectionRef.current = cursorPosition; setMentionStartIndex(existingMention.startIndex); setMentionQuery(existingMention.query); setMentionSelectedIndex(0); @@ -503,6 +502,7 @@ export function MessageComposer({ ], ); + // biome-ignore lint/correctness/useExhaustiveDependencies: content triggers height recalc and pending selection restore React.useLayoutEffect(() => { const textarea = textareaRef.current; if (!textarea) { @@ -528,7 +528,7 @@ export function MessageComposer({ textarea.setSelectionRange(pendingSelection, pendingSelection); pendingSelectionRef.current = null; } - }); + }, [content]); React.useEffect(() => { if (!replyTarget || disabled) { diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 9b7d747cc1..f92625b979 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -8,288 +8,323 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { Markdown } from "@/shared/ui/markdown"; -import { DiffMessage } from "./DiffMessage"; import { MessageActionBar } from "./MessageActionBar"; +const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); -export function MessageRow({ - activeReplyTargetId = null, - message, - onToggleReaction, - onReply, -}: { - activeReplyTargetId?: string | null; - message: TimelineMessage; - onToggleReaction?: ( - message: TimelineMessage, - emoji: string, - remove: boolean, - ) => Promise; - onReply?: (message: TimelineMessage) => void; -}) { - const [hasAvatarError, setHasAvatarError] = React.useState(false); - const [expandedDiffId, setExpandedDiffId] = React.useState( - null, - ); - const [reactionErrorMessage, setReactionErrorMessage] = React.useState< - string | null - >(null); - const [reactionPending, setReactionPending] = React.useState(false); - const visibleDepth = Math.min(message.depth, 6); - const indentPx = visibleDepth * 28; - const initials = message.author - .split(" ") - .map((part) => part[0]) - .join("") - .slice(0, 2) - .toUpperCase(); +export const MessageRow = React.memo( + function MessageRow({ + activeReplyTargetId = null, + highlighted = false, + message, + onToggleReaction, + onReply, + }: { + activeReplyTargetId?: string | null; + highlighted?: boolean; + message: TimelineMessage; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; + onReply?: (message: TimelineMessage) => void; + }) { + const [hasAvatarError, setHasAvatarError] = React.useState(false); + const [expandedDiffId, setExpandedDiffId] = React.useState( + null, + ); + const [reactionErrorMessage, setReactionErrorMessage] = React.useState< + string | null + >(null); + const [reactionPending, setReactionPending] = React.useState(false); + const visibleDepth = Math.min(message.depth, 6); + const indentPx = visibleDepth * 28; + const initials = message.author + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); - const getTag = (name: string) => - message.tags?.find((tag) => tag[0] === name)?.[1]; + const getTag = (name: string) => + message.tags?.find((tag) => tag[0] === name)?.[1]; - const renderBody = () => { - switch (message.kind) { - case KIND_STREAM_MESSAGE_DIFF: - return ( - { - setExpandedDiffId(message.id); - }} - repoUrl={getTag("repo")} - truncated={getTag("truncated") === "true"} - /> - ); - default: - return ; - } - }; - - const reactions = [...(message.reactions ?? [])].sort((left, right) => { - if (left.count !== right.count) { - return right.count - left.count; - } - - return left.emoji.localeCompare(right.emoji); - }); - const canToggleReactions = Boolean(onToggleReaction && !message.pending); - - const handleReactionSelect = React.useCallback( - async (emoji: string) => { - if (!onToggleReaction || reactionPending) { - return; - } - - const remove = reactions.some( - (reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser, - ); - - setReactionErrorMessage(null); - setReactionPending(true); - - try { - await onToggleReaction(message, emoji, remove); - } catch (error) { - const nextMessage = - error instanceof Error - ? error.message - : "Failed to update the reaction."; - setReactionErrorMessage(nextMessage); - throw error; - } finally { - setReactionPending(false); - } - }, - [message, onToggleReaction, reactionPending, reactions], - ); - - return ( -
0 ? { paddingLeft: `${indentPx}px` } : undefined} - > - {message.depth > 0 ? ( -
- ) : null} - -
- {message.pubkey ? ( - - - - ) : message.avatarUrl && !hasAvatarError ? ( - {`${message.author} { - setHasAvatarError(true); - }} - referrerPolicy="no-referrer" - src={message.avatarUrl} - /> - ) : ( -
- {initials} -
- )} - -
-
- {message.pubkey ? ( - - - - ) : ( -

- {message.author} -

- )} - {message.role ? ( -

- {message.role} -

- ) : null} -
- - {message.pending ? ( -

- Sending -

- ) : null} -

{message.time}

-
-
- {renderBody()} - {reactions.length > 0 ? ( -
- {reactions.map((reaction: TimelineReaction) => ( - - ))} -
- ) : null} - {reactionErrorMessage ? ( -

- {reactionErrorMessage} -

- ) : null} - {expandedDiffId === message.id ? ( + const renderBody = () => { + switch (message.kind) { + case KIND_STREAM_MESSAGE_DIFF: + return ( - Loading diff viewer… + Loading diff…
} > - { - setExpandedDiffId(null); + onExpand={() => { + setExpandedDiffId(message.id); }} + repoUrl={getTag("repo")} + truncated={getTag("truncated") === "true"} /> - ) : null} -
- -
- ); -} + ); + default: + return ( + + ); + } + }; + + const reactions = [...(message.reactions ?? [])].sort((left, right) => { + if (left.count !== right.count) { + return right.count - left.count; + } + + return left.emoji.localeCompare(right.emoji); + }); + const canToggleReactions = Boolean(onToggleReaction && !message.pending); + + const handleReactionSelect = React.useCallback( + async (emoji: string) => { + if (!onToggleReaction || reactionPending) { + return; + } + + const remove = reactions.some( + (reaction) => + reaction.emoji === emoji && reaction.reactedByCurrentUser, + ); + + setReactionErrorMessage(null); + setReactionPending(true); + + try { + await onToggleReaction(message, emoji, remove); + } catch (error) { + const nextMessage = + error instanceof Error + ? error.message + : "Failed to update the reaction."; + setReactionErrorMessage(nextMessage); + throw error; + } finally { + setReactionPending(false); + } + }, + [message, onToggleReaction, reactionPending, reactions], + ); + + return ( +
0 ? { paddingLeft: `${indentPx}px` } : undefined} + > + {message.depth > 0 ? ( +
+ ) : null} + +
+ {message.pubkey ? ( + + + + ) : message.avatarUrl && !hasAvatarError ? ( + {`${message.author} { + setHasAvatarError(true); + }} + referrerPolicy="no-referrer" + src={message.avatarUrl} + /> + ) : ( +
+ {initials} +
+ )} + +
+
+ {message.pubkey ? ( + + + + ) : ( +

+ {message.author} +

+ )} + {message.role ? ( +

+ {message.role} +

+ ) : null} +
+ + {message.pending ? ( +

+ Sending +

+ ) : null} +

{message.time}

+
+
+ {renderBody()} + {reactions.length > 0 ? ( +
+ {reactions.map((reaction: TimelineReaction) => ( + + ))} +
+ ) : null} + {reactionErrorMessage ? ( +

+ {reactionErrorMessage} +

+ ) : null} + {expandedDiffId === message.id ? ( + + Loading diff viewer… +
+ } + > + { + setExpandedDiffId(null); + }} + /> + + ) : null} +
+ +
+ ); + // Callbacks (onReply, onToggleReaction) intentionally excluded: inline arrows + // from parent create new refs every render β€” including them defeats memo. + }, + (prev, next) => + prev.message.id === next.message.id && + prev.message.pubkey === next.message.pubkey && + prev.message.body === next.message.body && + prev.message.author === next.message.author && + prev.message.avatarUrl === next.message.avatarUrl && + prev.message.accent === next.message.accent && + prev.message.time === next.message.time && + prev.message.depth === next.message.depth && + prev.message.kind === next.message.kind && + prev.message.pending === next.message.pending && + prev.message.reactions === next.message.reactions && + prev.message.tags === next.message.tags && + prev.message.role === next.message.role && + prev.highlighted === next.highlighted && + prev.activeReplyTargetId === next.activeReplyTargetId, +); + +MessageRow.displayName = "MessageRow"; diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index e7f0c8448a..1a991d43ec 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -109,11 +109,9 @@ export function MessageTimeline({ ) : ( diff --git a/desktop/src/features/search/ui/SearchDialog.tsx b/desktop/src/features/search/ui/SearchDialog.tsx index cbbceea2fb..58de6e51bb 100644 --- a/desktop/src/features/search/ui/SearchDialog.tsx +++ b/desktop/src/features/search/ui/SearchDialog.tsx @@ -136,15 +136,15 @@ export function SearchDialog({ onOpenResult, }: SearchDialogProps) { const [query, setQuery] = React.useState(""); + const [debouncedQuery, setDebouncedQuery] = 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, { + const searchQuery = useSearchMessagesQuery(debouncedQuery, { enabled: open, limit: 12, }); @@ -166,6 +166,22 @@ export function SearchDialog({ [onOpenChange, onOpenResult], ); + React.useEffect(() => { + const trimmed = query.trim(); + if (trimmed.length < MIN_QUERY_LENGTH) { + setDebouncedQuery(""); + return; + } + + const timeout = window.setTimeout(() => { + setDebouncedQuery(trimmed); + }, 300); + + return () => { + window.clearTimeout(timeout); + }; + }, [query]); + React.useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if ( @@ -190,6 +206,7 @@ export function SearchDialog({ React.useEffect(() => { if (!open) { setQuery(""); + setDebouncedQuery(""); setSelectedIndex(0); return; } @@ -276,7 +293,7 @@ export function SearchDialog({
- {deferredQuery.length < MIN_QUERY_LENGTH ? ( + {debouncedQuery.length < MIN_QUERY_LENGTH ? ( (); private pendingEvents = new Map(); + private eventBuffer: Array<{ subId: string; event: RelayEvent }> = []; + private flushTimeout: ReturnType | null = null; private reconnectListeners = new Set<() => void>(); private hasConnectedOnce = false; private notifyReconnectListeners = false; @@ -494,7 +497,26 @@ export class RelayClient { subscription.lastSeenCreatedAt ?? 0, event.created_at, ); - subscription.onEvent(event); + + this.eventBuffer.push({ subId, event }); + this.flushTimeout ??= window.setTimeout( + () => this.flushEventBuffer(), + EVENT_BATCH_MS, + ); + } + + private flushEventBuffer() { + this.flushTimeout = null; + const buffer = this.eventBuffer; + this.eventBuffer = []; + + // Re-lookup: subscriptions removed during batch window are intentionally skipped. + for (const { subId, event } of buffer) { + const subscription = this.subscriptions.get(subId); + if (subscription?.mode === "live") { + subscription.onEvent(event); + } + } } private handleEose(subId: string) { @@ -648,6 +670,10 @@ export class RelayClient { reconnect?: boolean; }, ) { + if (this.flushTimeout !== null) window.clearTimeout(this.flushTimeout); + this.flushTimeout = null; + this.eventBuffer = []; + if (options?.reconnect !== false && this.hasConnectedOnce) { this.notifyReconnectListeners = true; }