Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion crates/sprout-relay/src/api/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ pub struct SearchParams {
pub q: Option<String>,
/// Maximum number of results to return. Defaults to 20, capped at 100.
pub limit: Option<u32>,
/// Restrict results to a single channel. When present, ANDed with the
/// ACL-based channel filter so the caller can only see results they already
/// have access to.
pub channel_id: Option<String>,
}

/// Full-text search over messages accessible to the authenticated user.
Expand Down Expand Up @@ -56,7 +60,13 @@ pub async fn search_handler(
if channel_ids.is_empty() && !include_global {
return Ok(Json(serde_json::json!({ "hits": [], "found": 0 })));
}
let filter_by = if channel_ids.is_empty() {
let filter_by = if let Some(ref cid) = params.channel_id {
// Scoped to a single channel — verify it's in the accessible set.
if !channel_ids.iter().any(|id| id.to_string() == *cid) {
return Ok(Json(serde_json::json!({ "hits": [], "found": 0 })));
}
Some(format!("channel_id:={cid}"))
} else if channel_ids.is_empty() {
Some("channel_id:=__global__".to_string())
} else if include_global {
let ids: Vec<String> = channel_ids.iter().map(|id| id.to_string()).collect();
Expand Down
7 changes: 6 additions & 1 deletion desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ pub async fn get_feed(
pub async fn search_messages(
q: String,
limit: Option<u32>,
channel_id: Option<String>,
state: State<'_, AppState>,
) -> Result<SearchResponse, String> {
let request = build_authed_request(&state.http_client, Method::GET, "/api/search", &state)?
.query(&SearchQueryParams { q: q.trim(), limit });
.query(&SearchQueryParams {
q: q.trim(),
limit,
channel_id: channel_id.as_deref(),
});

send_json_request(request).await
}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ pub struct SearchQueryParams<'a> {
pub q: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_id: Option<&'a str>,
}

#[derive(Serialize)]
Expand Down
18 changes: 18 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
Expand Down Expand Up @@ -45,6 +47,7 @@ function getInitialThreadPanelWidth(): number {

type ChannelPaneProps = {
activeChannel: Channel | null;
channelFind: ReturnType<typeof useChannelFind>;
currentPubkey?: string;
editTarget?: {
author: string;
Expand Down Expand Up @@ -99,6 +102,7 @@ type ChannelPaneProps = {

export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
channelFind,
currentPubkey,
editTarget = null,
fetchOlder,
Expand Down Expand Up @@ -198,6 +202,17 @@ export const ChannelPane = React.memo(function ChannelPane({
return (
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{channelFind.isOpen ? (
<ChannelFindBar
matchCount={channelFind.matchCount}
matchIndex={channelFind.activeIndex}
onClose={channelFind.close}
onNext={channelFind.goToNext}
onPrevious={channelFind.goToPrevious}
onQueryChange={channelFind.setQuery}
query={channelFind.query}
/>
) : null}
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={openThreadHeadId}
Expand Down Expand Up @@ -226,6 +241,9 @@ export const ChannelPane = React.memo(function ChannelPane({
onReply={onOpenThread}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
searchActiveMessageId={channelFind.activeMatch?.messageId ?? null}
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
/>
<MessageComposer
Expand Down
8 changes: 7 additions & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
Profile,
RelayEvent,
} from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

const ChannelPane = React.lazy(async () => {
Expand Down Expand Up @@ -237,6 +238,11 @@ export function ChannelScreen({
resolvedMessages,
],
);
const channelFind = useChannelFind({
channelId: activeChannelId,
messages: timelineMessages,
});

const directReplyIdsByParentId = React.useMemo(() => {
const map = new Map<string, string[]>();

Expand Down Expand Up @@ -341,7 +347,6 @@ export function ChannelScreen({
React.useEffect(() => {
resetComposerTargets(activeChannelId);
}, [activeChannelId, resetComposerTargets]);

React.useEffect(() => {
if (openThreadHeadId && !openThreadHeadMessage) {
setOpenThreadHeadId(null);
Expand Down Expand Up @@ -430,6 +435,7 @@ export function ChannelScreen({
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
<ChannelPane
activeChannel={activeChannel}
channelFind={channelFind}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/messages/ui/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const MessageRow = React.memo(
onToggleReaction,
onReply,
profiles,
searchQuery,
}: {
activeReplyTargetId?: string | null;
highlighted?: boolean;
Expand All @@ -44,6 +45,7 @@ export const MessageRow = React.memo(
) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
profiles?: UserProfileLookup;
searchQuery?: string;
}) {
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
Expand Down Expand Up @@ -111,6 +113,7 @@ export const MessageRow = React.memo(
content={message.body}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
searchQuery={searchQuery}
tight
/>
);
Expand Down Expand Up @@ -393,7 +396,8 @@ export const MessageRow = React.memo(
prev.highlighted === next.highlighted &&
prev.activeReplyTargetId === next.activeReplyTargetId &&
prev.layoutVariant === next.layoutVariant &&
prev.profiles === next.profiles,
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery,
);

MessageRow.displayName = "MessageRow";
35 changes: 35 additions & 0 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ type MessageTimelineProps = {
emoji: string,
remove: boolean,
) => Promise<void>;
/** The message ID of the currently active find-in-channel match. */
searchActiveMessageId?: string | null;
/** Set of message IDs that match the current find-in-channel query. */
searchMatchingMessageIds?: Set<string>;
/** The current find-in-channel query string. */
searchQuery?: string;
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
};
Expand All @@ -56,6 +62,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onEdit,
onReply,
onToggleReaction,
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
targetMessageId = null,
onTargetReached,
}: MessageTimelineProps) {
Expand All @@ -80,6 +89,29 @@ export const MessageTimeline = React.memo(function MessageTimeline({
targetMessageId,
});

// Scroll to the active search match when it changes.
const prevSearchActiveRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (
!searchActiveMessageId ||
searchActiveMessageId === prevSearchActiveRef.current
) {
prevSearchActiveRef.current = searchActiveMessageId;
return;
}
prevSearchActiveRef.current = searchActiveMessageId;

const container = scrollContainerRef.current;
if (!container) return;

const el = container.querySelector<HTMLElement>(
`[data-message-id="${searchActiveMessageId}"]`,
);
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [searchActiveMessageId]);

useLoadOlderOnScroll({
fetchOlder,
hasOlderMessages,
Expand Down Expand Up @@ -161,6 +193,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
/>
) : null}

Expand Down
15 changes: 14 additions & 1 deletion desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ type TimelineMessageListProps = {
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
/** The message ID of the currently active find-in-channel match. */
searchActiveMessageId?: string | null;
/** Set of message IDs that match the current find-in-channel query. */
searchMatchingMessageIds?: Set<string>;
/** The current find-in-channel query string. */
searchQuery?: string;
};

export const TimelineMessageList = React.memo(function TimelineMessageList({
Expand All @@ -42,6 +48,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onToggleReaction,
personaLookup,
profiles,
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
}: TimelineMessageListProps) {
const elements: React.ReactNode[] = [];
const entries = React.useMemo(
Expand Down Expand Up @@ -75,11 +84,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
/>,
);
} else {
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;

elements.push(
<MessageRow
key={message.id}
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
highlighted={message.id === highlightedMessageId || isSearchActive}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
Expand All @@ -94,6 +106,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
/>,
);

Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/search/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ import { searchMessages } from "@/shared/api/tauri";
export function useSearchMessagesQuery(
query: string,
options?: {
channelId?: string;
enabled?: boolean;
limit?: number;
},
) {
const trimmedQuery = query.trim();
const enabled = options?.enabled ?? true;
const limit = options?.limit ?? 12;
const channelId = options?.channelId;

return useQuery({
queryKey: ["search-messages", trimmedQuery, limit],
queryKey: ["search-messages", trimmedQuery, limit, channelId ?? null],
queryFn: () =>
searchMessages({
q: trimmedQuery,
limit,
channelId,
}),
enabled: enabled && trimmedQuery.length >= 2,
staleTime: 30_000,
Expand Down
Loading