mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
highlight search terms in results and messages (#6702)
**Category:** fix **User Impact:** Search terms are now highlighted in yellow in Cmd+F results and in the message opened from a result. **Problem:** Search returned relevant messages, but users still had to reread each preview and destination message to discover where the query appeared. Search navigation could also lose or misapply highlighting during rapid typing, repeated navigation, thread opening, and forum navigation. **Solution:** Render match-focused previews with a shared yellow treatment, carry the result query through navigation, and apply it only to the clicked destination. The implementation binds highlights to the debounced result set, supports token and prefix matching, and handles channels, threads, forums, diffs, and wave messages consistently. <details> <summary>File changes</summary> **desktop/src/app/AppShell.tsx** Carries the active result query into search-hit navigation so the destination can preserve the user's context. **desktop/src/app/navigation/searchHitEventCache.ts** Adds a bounded, one-shot cache for result queries and navigation IDs alongside cached search-hit events. **desktop/src/app/navigation/searchHitNavigation.test.mjs** Covers query retention, one-shot consumption, repeated same-route activations, forum posts, forced routing, and cancellation. **desktop/src/app/navigation/searchHitNavigation.ts** Associates each search-result activation with a unique navigation ID and forwards it to channel or forum destinations. **desktop/src/app/navigation/useAppNavigation.ts** Extends channel and forum navigation to carry search navigation state without changing normal navigation behavior. **desktop/src/app/routes/ChannelRouteScreen.tsx** Consumes search highlight state after navigation and retains it for the selected message while clearing it on community context changes. **desktop/src/app/routes/channels.$channelId.posts.$postId.tsx** Validates and forwards forum search-navigation IDs. **desktop/src/app/routes/channels.$channelId.tsx** Validates and forwards channel search-navigation IDs. **desktop/src/features/channels/ui/ChannelPane.tsx** Passes the selected result ID and query into the main timeline and open thread panel. **desktop/src/features/channels/ui/ChannelPane.types.ts** Defines the destination-highlight contract for channel panes. **desktop/src/features/channels/ui/ChannelScreen.tsx** Routes destination highlighting to either forum content or the channel timeline. **desktop/src/features/channels/ui/ChannelScreen.types.ts** Defines route-level search-highlight inputs. **desktop/src/features/channels/ui/ForumChannelContent.tsx** Forwards search context into expanded forum threads. **desktop/src/features/forum/ui/ForumThreadPanel.tsx** Highlights the matching text in the selected forum post or reply. **desktop/src/features/forum/ui/ForumView.tsx** Carries selected-result context from forum routing into the thread panel. **desktop/src/features/messages/ui/DiffMessage.tsx** Forwards the query into diff descriptions and rendered diff content. **desktop/src/features/messages/ui/DiffViewer.tsx** Provides a highlighted match excerpt for structured diffs and highlights fallback raw diff text. **desktop/src/features/messages/ui/MessageRow.tsx** Passes destination queries into normal Markdown, diff, and wave message renderers. **desktop/src/features/messages/ui/MessageThreadPanel.tsx** Applies highlighting to the selected thread head or reply without tinting unrelated messages. **desktop/src/features/messages/ui/WaveMessageAttachment.tsx** Highlights matches in wave-message fallback text. **desktop/src/features/search/lib/searchMatch.test.mjs** Covers case-insensitive, literal, multi-term, prefix, one-character, and late-preview matches. **desktop/src/features/search/lib/searchMatch.ts** Centralizes token extraction, case-insensitive match splitting, and match-focused preview generation. **desktop/src/features/search/ui/HighlightedSearchText.tsx** Adds the reusable accessible mark renderer used by result and specialized message surfaces. **desktop/src/features/search/ui/TopbarSearch.tsx** Highlights result previews, centers excerpts around matches, strips search operators, and hides stale results during debounce transitions. **desktop/src/features/sidebar/ui/AppSidebar.types.ts** Updates the sidebar search callback contract to include the result query. **desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx** Passes query-aware result selection through the pinned search entry point. **desktop/src/shared/lib/rehypeSearchHighlight.ts** Uses the shared matcher to mark every matching Markdown text segment while leaving code blocks untouched. **desktop/src/shared/lib/searchHighlightStyle.ts** Defines one yellow highlight treatment for light and dark themes. **desktop/src/shared/ui/markdown/nodeCache.test.mjs** Verifies all Markdown matches, one-character scoped searches, code exclusion, and transient cache behavior. **desktop/src/shared/ui/markdown/nodeCache.ts** Enables transient highlighting for one-character scoped searches without polluting the Markdown cache. **desktop/tests/e2e/smoke.spec.ts** Exercises result and destination highlighting, same-route forum activation, and stale-query suppression end to end. </details> ## Reproduction steps 1. Open a channel containing a longer message and press Cmd+F. 2. Search for a word that appears later in the message, such as `mentions`. 3. Confirm each matching result shows the word in yellow and keeps the match visible in its preview. 4. Open a result and confirm the same term is highlighted in yellow in the destination message. 5. Repeat with a thread reply or forum post, and quickly change the query to confirm stale results are not selectable. ## Demo - **Before:** Search results returned the correct message but offered no visual indication of where the query matched; opening the message also showed no match treatment. - **After:** Every matching term is marked in yellow in the result preview and in the exact channel, thread, or forum message opened from that result. --------- Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
@@ -648,8 +648,8 @@ export function AppShell() {
|
||||
);
|
||||
|
||||
const handleOpenSearchResult = React.useCallback(
|
||||
(hit: SearchHit) => {
|
||||
void openSearchHit(hit);
|
||||
(hit: SearchHit, query: string) => {
|
||||
void openSearchHit(hit, { query });
|
||||
},
|
||||
[openSearchHit],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const { createSearchHighlightNavigation, parseSearchHighlightNavigation } =
|
||||
await import("./searchHighlightNavigation.ts");
|
||||
|
||||
test("creates trimmed transient state with a unique activation id", () => {
|
||||
const first = createSearchHighlightNavigation("message", " Mentions ");
|
||||
const second = createSearchHighlightNavigation("message", "Mentions");
|
||||
|
||||
assert.deepEqual(
|
||||
{ messageId: first.messageId, query: first.query },
|
||||
{ messageId: "message", query: "Mentions" },
|
||||
);
|
||||
assert.notEqual(first.activationId, second.activationId);
|
||||
});
|
||||
|
||||
test("does not create highlight state for an empty query", () => {
|
||||
assert.equal(createSearchHighlightNavigation("message", " "), undefined);
|
||||
assert.equal(
|
||||
createSearchHighlightNavigation("message", undefined),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("parses only complete highlight navigation state", () => {
|
||||
const state = {
|
||||
activationId: "activation",
|
||||
messageId: "message",
|
||||
query: "mentions",
|
||||
};
|
||||
|
||||
assert.deepEqual(parseSearchHighlightNavigation(state), state);
|
||||
assert.equal(
|
||||
parseSearchHighlightNavigation({ messageId: "message", query: "mentions" }),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseSearchHighlightNavigation(null), null);
|
||||
});
|
||||
47
desktop/src/app/navigation/searchHighlightNavigation.ts
Normal file
47
desktop/src/app/navigation/searchHighlightNavigation.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type SearchHighlightNavigation = {
|
||||
activationId: string;
|
||||
messageId: string;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function createSearchHighlightNavigation(
|
||||
messageId: string,
|
||||
query: string | undefined,
|
||||
): SearchHighlightNavigation | undefined {
|
||||
const trimmedQuery = query?.trim();
|
||||
if (!trimmedQuery) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
activationId: crypto.randomUUID(),
|
||||
messageId,
|
||||
query: trimmedQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSearchHighlightNavigation(
|
||||
value: unknown,
|
||||
): SearchHighlightNavigation | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<SearchHighlightNavigation>;
|
||||
if (
|
||||
typeof candidate.activationId !== "string" ||
|
||||
candidate.activationId.length === 0 ||
|
||||
typeof candidate.messageId !== "string" ||
|
||||
candidate.messageId.length === 0 ||
|
||||
typeof candidate.query !== "string" ||
|
||||
candidate.query.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
activationId: candidate.activationId,
|
||||
messageId: candidate.messageId,
|
||||
query: candidate.query,
|
||||
};
|
||||
}
|
||||
@@ -51,6 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn
|
||||
options: {
|
||||
force: true,
|
||||
messageId: "message",
|
||||
searchHighlight: undefined,
|
||||
threadRootId: "thread-root",
|
||||
},
|
||||
},
|
||||
@@ -58,6 +59,45 @@ test("search-hit navigation preserves forced message routing while active", asyn
|
||||
assert.equal(getCachedSearchHitEvent("message")?.id, "message");
|
||||
});
|
||||
|
||||
test("search-hit navigation carries trimmed highlight state and forces repeated activations", async () => {
|
||||
clearSearchHitEventCache();
|
||||
const calls = [];
|
||||
|
||||
await openSearchHitWithNavigation(plainMessage, {
|
||||
goChannel: async (channelId, options) => {
|
||||
calls.push({ channelId, options });
|
||||
return true;
|
||||
},
|
||||
goForumPost: async () => false,
|
||||
query: " Mentions ",
|
||||
});
|
||||
|
||||
assert.equal(calls[0].options.force, true);
|
||||
assert.equal(calls[0].options.searchHighlight.messageId, "message");
|
||||
assert.equal(calls[0].options.searchHighlight.query, "Mentions");
|
||||
assert.match(calls[0].options.searchHighlight.activationId, /.+/);
|
||||
});
|
||||
|
||||
test("forum-post search navigation carries transient same-route activation state", async () => {
|
||||
clearSearchHitEventCache();
|
||||
const forumPost = { ...forumComment, eventId: "post", kind: 45001 };
|
||||
const calls = [];
|
||||
|
||||
await openSearchHitWithNavigation(forumPost, {
|
||||
goChannel: async () => false,
|
||||
goForumPost: async (channelId, postId, options) => {
|
||||
calls.push({ channelId, postId, options });
|
||||
return true;
|
||||
},
|
||||
query: "mentions",
|
||||
});
|
||||
|
||||
assert.equal(calls[0].options.force, true);
|
||||
assert.equal(calls[0].options.searchHighlight.messageId, "post");
|
||||
assert.equal(calls[0].options.searchHighlight.query, "mentions");
|
||||
assert.match(calls[0].options.searchHighlight.activationId, /.+/);
|
||||
});
|
||||
|
||||
test("cancelled search-hit navigation cannot repopulate cache or route", async () => {
|
||||
clearSearchHitEventCache();
|
||||
let resolveLookup;
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination";
|
||||
import { createSearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation";
|
||||
import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
|
||||
import type { SearchHit } from "@/shared/api/types";
|
||||
|
||||
type SearchHitNavigationActions = {
|
||||
force?: boolean;
|
||||
query?: string;
|
||||
goChannel: (
|
||||
channelId: string,
|
||||
options?: {
|
||||
force?: boolean;
|
||||
messageId?: string;
|
||||
searchHighlight?: ReturnType<typeof createSearchHighlightNavigation>;
|
||||
threadRootId?: string | null;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
goForumPost: (
|
||||
channelId: string,
|
||||
postId: string,
|
||||
options?: { force?: boolean; replyId?: string },
|
||||
options?: {
|
||||
force?: boolean;
|
||||
replyId?: string;
|
||||
searchHighlight?: ReturnType<typeof createSearchHighlightNavigation>;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
@@ -30,6 +37,10 @@ export async function openSearchHitWithNavigation(
|
||||
}
|
||||
|
||||
const isLifecycleBound = Boolean(actions.signal);
|
||||
const searchHighlight = createSearchHighlightNavigation(
|
||||
hit.eventId,
|
||||
actions.query,
|
||||
);
|
||||
if (!isLifecycleBound) {
|
||||
cacheSearchHitEvent(hit);
|
||||
}
|
||||
@@ -47,14 +58,16 @@ export async function openSearchHitWithNavigation(
|
||||
|
||||
if (destination.kind === "forum-post") {
|
||||
return actions.goForumPost(destination.channelId, destination.postId, {
|
||||
force: actions.force,
|
||||
force: actions.force || Boolean(searchHighlight),
|
||||
replyId: destination.replyId,
|
||||
searchHighlight,
|
||||
});
|
||||
}
|
||||
|
||||
return actions.goChannel(destination.channelId, {
|
||||
force: actions.force,
|
||||
force: actions.force || Boolean(searchHighlight),
|
||||
messageId: destination.messageId,
|
||||
searchHighlight,
|
||||
threadRootId: destination.threadRootId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
|
||||
import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation";
|
||||
import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
|
||||
import {
|
||||
allowNavigation,
|
||||
@@ -32,14 +33,23 @@ export function useAppNavigation() {
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, string | undefined>;
|
||||
state?: Record<string, unknown>;
|
||||
state?:
|
||||
| Record<string, unknown>
|
||||
| ((
|
||||
previousState: Record<string, unknown>,
|
||||
) => Record<string, unknown>);
|
||||
},
|
||||
behavior: NavigationBehavior = {},
|
||||
guardedTarget?: GuardedNavigation,
|
||||
) => {
|
||||
const nextLocation = router.buildLocation(next as never);
|
||||
const hasStateUpdate = next.state !== undefined;
|
||||
|
||||
if (location.href === nextLocation.href && !behavior.force) {
|
||||
if (
|
||||
location.href === nextLocation.href &&
|
||||
!behavior.force &&
|
||||
!hasStateUpdate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -265,6 +275,9 @@ export function useAppNavigation() {
|
||||
* silently swallowed (block/buzz#3509). */
|
||||
force?: boolean;
|
||||
messageId?: string;
|
||||
/** Preserve an active search highlight; ordinary navigation clears it. */
|
||||
preserveSearchHighlight?: boolean;
|
||||
searchHighlight?: SearchHighlightNavigation;
|
||||
replace?: boolean;
|
||||
/** Open this thread panel directly without waiting for a timeline row. */
|
||||
thread?: string;
|
||||
@@ -290,6 +303,12 @@ export function useAppNavigation() {
|
||||
...(options?.thread ? { thread: options.thread } : {}),
|
||||
...(options?.autoSend ? { autoSend: options.autoSend } : {}),
|
||||
},
|
||||
state: options?.preserveSearchHighlight
|
||||
? undefined
|
||||
: (previousState: Record<string, unknown>) => ({
|
||||
...previousState,
|
||||
searchHighlight: options?.searchHighlight ?? null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
force: options?.force,
|
||||
@@ -329,6 +348,9 @@ export function useAppNavigation() {
|
||||
force?: boolean;
|
||||
replace?: boolean;
|
||||
replyId?: string;
|
||||
/** Preserve an active search highlight; ordinary navigation clears it. */
|
||||
preserveSearchHighlight?: boolean;
|
||||
searchHighlight?: SearchHighlightNavigation;
|
||||
},
|
||||
) => {
|
||||
return commitNavigation(
|
||||
@@ -338,7 +360,15 @@ export function useAppNavigation() {
|
||||
channelId,
|
||||
postId,
|
||||
},
|
||||
search: options?.replyId ? { replyId: options.replyId } : {},
|
||||
search: {
|
||||
...(options?.replyId ? { replyId: options.replyId } : {}),
|
||||
},
|
||||
state: options?.preserveSearchHighlight
|
||||
? undefined
|
||||
: (previousState: Record<string, unknown>) => ({
|
||||
...previousState,
|
||||
searchHighlight: options?.searchHighlight ?? null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
force: options?.force,
|
||||
@@ -406,6 +436,8 @@ export function useAppNavigation() {
|
||||
* Used by desktop-notification activation so a click is never
|
||||
* silently swallowed (block/buzz#3509). */
|
||||
force?: boolean;
|
||||
/** Search text to highlight after opening this result. */
|
||||
query?: string;
|
||||
/** Stop notification-driven routing when its owning lifecycle ends. */
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
@@ -414,6 +446,7 @@ export function useAppNavigation() {
|
||||
force: behavior?.force,
|
||||
goChannel,
|
||||
goForumPost,
|
||||
query: behavior?.query,
|
||||
signal: behavior?.signal,
|
||||
}),
|
||||
[goChannel, goForumPost],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation";
|
||||
import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
@@ -20,6 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
type ChannelRouteScreenProps = {
|
||||
autoSendDraftKey: string | null;
|
||||
channelId: string;
|
||||
searchHighlight: SearchHighlightNavigation | null | undefined;
|
||||
selectedPostId: string | null;
|
||||
targetMessageId: string | null;
|
||||
targetReplyId: string | null;
|
||||
@@ -100,6 +102,7 @@ async function fetchRouteTargetEvents(
|
||||
export function ChannelRouteScreen({
|
||||
autoSendDraftKey,
|
||||
channelId,
|
||||
searchHighlight,
|
||||
selectedPostId,
|
||||
targetMessageId,
|
||||
targetReplyId,
|
||||
@@ -132,23 +135,67 @@ export function ChannelRouteScreen({
|
||||
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
|
||||
return cachedTarget ? [cachedTarget] : [];
|
||||
});
|
||||
|
||||
// Reset spliced target events when the channel context changes (channel
|
||||
// switch or entering/leaving a forum post). Tied to channel identity rather
|
||||
// than the route target so clearing the `messageId` param mid-channel keeps
|
||||
// the deep-linked row in view. Seeded with the mount key so the initial
|
||||
// cache-seeded events survive first commit; only a genuine channel change
|
||||
// clears them. Declared before the fetch effect so a channel switch clears
|
||||
// stale events before the new target is fetched.
|
||||
const previousResetKeyRef = React.useRef<string>(
|
||||
`${channelId}::${selectedPostId ?? ""}`,
|
||||
const [activeSearchHighlight, setActiveSearchHighlight] =
|
||||
React.useState<SearchHighlightNavigation | null>(searchHighlight ?? null);
|
||||
const appliedSearchActivationIdRef = React.useRef<string | null>(
|
||||
searchHighlight?.activationId ?? null,
|
||||
);
|
||||
|
||||
// Router state is transient and can be cleared by the target URL cleanup.
|
||||
// Retain the applied activation locally until an ordinary route transition
|
||||
// explicitly arrives without search state.
|
||||
React.useEffect(() => {
|
||||
const resetKey = `${channelId}::${selectedPostId ?? ""}`;
|
||||
if (previousResetKeyRef.current === resetKey) return;
|
||||
previousResetKeyRef.current = resetKey;
|
||||
if (searchHighlight === null) {
|
||||
appliedSearchActivationIdRef.current = null;
|
||||
setActiveSearchHighlight(null);
|
||||
return;
|
||||
}
|
||||
if (!searchHighlight) {
|
||||
const ordinaryTargetIds = [
|
||||
selectedPostId,
|
||||
targetMessageId,
|
||||
targetReplyId,
|
||||
targetThreadRootId,
|
||||
].filter((targetId): targetId is string => targetId !== null);
|
||||
if (
|
||||
ordinaryTargetIds.length > 0 &&
|
||||
activeSearchHighlight &&
|
||||
!ordinaryTargetIds.includes(activeSearchHighlight.messageId)
|
||||
) {
|
||||
appliedSearchActivationIdRef.current = null;
|
||||
setActiveSearchHighlight(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (appliedSearchActivationIdRef.current === searchHighlight.activationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedSearchActivationIdRef.current = searchHighlight.activationId;
|
||||
setActiveSearchHighlight(searchHighlight);
|
||||
}, [
|
||||
activeSearchHighlight,
|
||||
searchHighlight,
|
||||
selectedPostId,
|
||||
targetMessageId,
|
||||
targetReplyId,
|
||||
targetThreadRootId,
|
||||
]);
|
||||
|
||||
// Reset spliced target events when the channel changes. Tied to channel
|
||||
// identity rather than the route target so clearing the `messageId` param
|
||||
// mid-channel keeps the deep-linked row in view. Seeded with the mount key so
|
||||
// the initial cache-seeded events survive first commit; only a genuine
|
||||
// channel change clears them. Declared before the fetch effect so a channel
|
||||
// switch clears stale events before the new target is fetched.
|
||||
const previousResetKeyRef = React.useRef<string>(channelId);
|
||||
React.useEffect(() => {
|
||||
if (previousResetKeyRef.current === channelId) return;
|
||||
previousResetKeyRef.current = channelId;
|
||||
appliedSearchActivationIdRef.current = null;
|
||||
setTargetMessageEvents([]);
|
||||
}, [channelId, selectedPostId]);
|
||||
setActiveSearchHighlight(null);
|
||||
}, [channelId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
@@ -234,6 +281,8 @@ export function ChannelRouteScreen({
|
||||
targetForumReplyId={targetReplyId}
|
||||
targetMessageEvents={targetMessageEvents}
|
||||
targetMessageId={targetMessageId}
|
||||
targetSearchMessageId={activeSearchHighlight?.messageId}
|
||||
targetSearchQuery={activeSearchHighlight?.query}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router";
|
||||
|
||||
import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState";
|
||||
import { usePreviewFeatureWarning } from "@/shared/features";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
@@ -33,6 +34,9 @@ function ForumPostRouteComponent() {
|
||||
usePreviewFeatureWarning("forum");
|
||||
const { channelId, postId } = Route.useParams();
|
||||
const search = Route.useSearch();
|
||||
const searchHighlight = useLocation({
|
||||
select: selectSearchHighlightRouteState,
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Suspense
|
||||
@@ -41,6 +45,7 @@ function ForumPostRouteComponent() {
|
||||
<ChannelRouteScreen
|
||||
autoSendDraftKey={null}
|
||||
channelId={channelId}
|
||||
searchHighlight={searchHighlight}
|
||||
selectedPostId={postId}
|
||||
targetMessageId={null}
|
||||
targetReplyId={search.replyId ?? null}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router";
|
||||
|
||||
import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState";
|
||||
|
||||
import {
|
||||
parseProfilePanelTab,
|
||||
@@ -59,6 +61,9 @@ const ChannelRouteScreen = React.lazy(async () => {
|
||||
function ChannelRouteComponent() {
|
||||
const { channelId } = Route.useParams();
|
||||
const search = Route.useSearch();
|
||||
const searchHighlight = useLocation({
|
||||
select: selectSearchHighlightRouteState,
|
||||
});
|
||||
const isHuddleTranscript = huddleWindowChannelId() !== null;
|
||||
|
||||
return (
|
||||
@@ -74,6 +79,7 @@ function ChannelRouteComponent() {
|
||||
<ChannelRouteScreen
|
||||
autoSendDraftKey={search.autoSend ?? null}
|
||||
channelId={channelId}
|
||||
searchHighlight={searchHighlight}
|
||||
selectedPostId={null}
|
||||
targetMessageId={search.messageId ?? null}
|
||||
targetReplyId={null}
|
||||
|
||||
39
desktop/src/app/routes/searchHighlightRouteState.test.mjs
Normal file
39
desktop/src/app/routes/searchHighlightRouteState.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const { selectSearchHighlightRouteState } = await import(
|
||||
"./searchHighlightRouteState.ts"
|
||||
);
|
||||
|
||||
const searchHighlight = {
|
||||
activationId: "activation",
|
||||
messageId: "message",
|
||||
query: "mentions",
|
||||
};
|
||||
|
||||
test("selects valid transient search highlight state", () => {
|
||||
assert.deepEqual(
|
||||
selectSearchHighlightRouteState({ state: { searchHighlight } }),
|
||||
searchHighlight,
|
||||
);
|
||||
});
|
||||
|
||||
test("target cleanup without highlight state preserves the selection", () => {
|
||||
assert.equal(selectSearchHighlightRouteState({ state: {} }), undefined);
|
||||
});
|
||||
|
||||
test("ordinary navigation explicitly clears the selection", () => {
|
||||
assert.equal(
|
||||
selectSearchHighlightRouteState({ state: { searchHighlight: null } }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores malformed router state", () => {
|
||||
assert.equal(
|
||||
selectSearchHighlightRouteState({
|
||||
state: { searchHighlight: { messageId: "message", query: "mentions" } },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
17
desktop/src/app/routes/searchHighlightRouteState.ts
Normal file
17
desktop/src/app/routes/searchHighlightRouteState.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
parseSearchHighlightNavigation,
|
||||
type SearchHighlightNavigation,
|
||||
} from "@/app/navigation/searchHighlightNavigation";
|
||||
|
||||
export function selectSearchHighlightRouteState(location: {
|
||||
state: unknown;
|
||||
}): SearchHighlightNavigation | null | undefined {
|
||||
const state = location.state as { searchHighlight?: unknown } | undefined;
|
||||
if (!(state && "searchHighlight" in state)) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.searchHighlight === null) {
|
||||
return null;
|
||||
}
|
||||
return parseSearchHighlightNavigation(state.searchHighlight) ?? undefined;
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner";
|
||||
import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers";
|
||||
import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle";
|
||||
import { useSearchHighlightProps } from "@/features/channels/ui/useSearchHighlightProps";
|
||||
import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
|
||||
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
|
||||
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
|
||||
@@ -152,6 +153,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
profilePanelTab,
|
||||
profilePanelView,
|
||||
targetMessageId,
|
||||
targetSearchMessageId,
|
||||
targetSearchQuery,
|
||||
threadAllMessages,
|
||||
threadHeadMessage,
|
||||
threadMessages,
|
||||
@@ -176,6 +179,10 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
currentPubkey,
|
||||
);
|
||||
const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true });
|
||||
const searchHighlightProps = useSearchHighlightProps(
|
||||
targetSearchMessageId,
|
||||
targetSearchQuery,
|
||||
);
|
||||
const [isMainDeferredEditPending, setMainDeferredEditPending] =
|
||||
React.useState(false);
|
||||
const isNonMemberView =
|
||||
@@ -194,8 +201,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
channelPaneMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
// Clear only the auto-send key so thread state survives deferred submission;
|
||||
// older wrappers fall back to goChannel to prevent back-navigation replay.
|
||||
const handleAutoSubmitComplete = React.useCallback(() => {
|
||||
if (onAutoSendComplete) {
|
||||
onAutoSendComplete();
|
||||
@@ -252,9 +257,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
);
|
||||
|
||||
const timeoutState = useTimeoutState();
|
||||
// A moderation DM (1:1 with the relay identity) is read-only for the member;
|
||||
// only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` →
|
||||
// ordinary DM, composer enabled.
|
||||
const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm");
|
||||
const isModerationDmChannel = isModerationDm(
|
||||
activeChannel ?? null,
|
||||
@@ -339,10 +341,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
!isMainDeferredEditPending &&
|
||||
!isSinglePanelView;
|
||||
const hasTypingActivity = typingPubkeys.length > 0;
|
||||
// Unified working set for the composer bar: observer-derived turns primary,
|
||||
// bot typing fallback (both folded together by agentWorkingSignal). This is
|
||||
// what makes the bar show for an agent whose observer stream is live but
|
||||
// whose typing signal never arrives — and vice versa.
|
||||
const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys(
|
||||
activeChannel?.id ?? null,
|
||||
);
|
||||
@@ -696,6 +694,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
}
|
||||
onTargetReached={onTargetReached}
|
||||
onToggleReaction={onToggleReaction}
|
||||
{...searchHighlightProps.timeline}
|
||||
targetMessageId={targetMessageId}
|
||||
splitThreadPanelOpen={
|
||||
useSplitAuxiliaryPane &&
|
||||
@@ -894,6 +893,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
scrollTargetHighlights={!layoutScrollTargetId}
|
||||
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
|
||||
{...searchHighlightProps.thread}
|
||||
threadHead={threadHeadMessage}
|
||||
videoReviewPresentation={threadVideoReviewPresentation}
|
||||
widthPx={threadPanelWidthPx}
|
||||
@@ -940,10 +940,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
})()
|
||||
) : activeChannel && selectedAgent ? (
|
||||
(() => {
|
||||
// When the panel was opened from a different channel than the
|
||||
// currently active one, re-scope it to the active channel so
|
||||
// that both the content/header AND channel-backed actions (e.g.
|
||||
// Stop current turn) operate on the same channel object.
|
||||
const effectiveAgentSessionChannelId =
|
||||
openAgentSessionChannelId &&
|
||||
activeChannel.id !== openAgentSessionChannelId
|
||||
|
||||
@@ -181,6 +181,10 @@ export type ChannelPaneProps = {
|
||||
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
|
||||
threadFirstUnreadReplyId?: string | null;
|
||||
targetMessageId: string | null;
|
||||
/** Exact clicked result id, including a reply routed into the thread panel. */
|
||||
targetSearchMessageId?: string | null;
|
||||
/** Search text to highlight within the clicked result. */
|
||||
targetSearchQuery?: string;
|
||||
typingPubkeys: string[];
|
||||
isFollowingThread?: boolean;
|
||||
onFollowThread?: () => void;
|
||||
|
||||
@@ -7,24 +7,14 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle
|
||||
import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys";
|
||||
import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles";
|
||||
import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync";
|
||||
import {
|
||||
useChannelMembersQuery,
|
||||
useJoinChannelMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import {
|
||||
MSG_PREFIX,
|
||||
THREAD_PREFIX,
|
||||
} from "@/features/channels/readState/readStateFormat";
|
||||
import * as channelHooks from "@/features/channels/hooks";
|
||||
import * as readStateFormat from "@/features/channels/readState/readStateFormat";
|
||||
import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState";
|
||||
import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader";
|
||||
import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog";
|
||||
import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent";
|
||||
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
|
||||
import {
|
||||
useManagedAgentsQuery,
|
||||
usePersonasQuery,
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import * as agentHooks from "@/features/agents/hooks";
|
||||
import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
|
||||
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
|
||||
import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide";
|
||||
@@ -44,10 +34,7 @@ import {
|
||||
import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs";
|
||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog";
|
||||
import {
|
||||
getThreadReference,
|
||||
isThreadReply,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import * as threading from "@/features/messages/lib/threading";
|
||||
import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache";
|
||||
import {
|
||||
resolveTimelineLoadingLatch,
|
||||
@@ -62,10 +49,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { useRelaySelfQuery } from "@/features/moderation/hooks";
|
||||
import type { RelayEvent, RespondToMode } from "@/shared/api/types";
|
||||
import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback";
|
||||
import {
|
||||
useHuddleChannelMessages,
|
||||
useIsHuddleTranscript,
|
||||
} from "@/features/channels/ui/useHuddleChannelMessages";
|
||||
import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages";
|
||||
import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker";
|
||||
import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation";
|
||||
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
|
||||
@@ -89,6 +73,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState";
|
||||
import type { ChannelScreenProps } from "./ChannelScreen.types";
|
||||
import { GuardedChannelPane } from "./GuardedChannelPane";
|
||||
import { useNavigationGuard } from "./useNavigationGuard";
|
||||
import * as searchForwarding from "./searchTargetForwarding";
|
||||
const EMPTY_RELAY_EVENTS: RelayEvent[] = [];
|
||||
export function ChannelScreen({
|
||||
activeChannel,
|
||||
@@ -101,6 +86,7 @@ export function ChannelScreen({
|
||||
targetForumReplyId,
|
||||
targetMessageEvents,
|
||||
targetMessageId,
|
||||
...searchTarget
|
||||
}: ChannelScreenProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { goHome } = useAppNavigation();
|
||||
@@ -171,7 +157,8 @@ export function ChannelScreen({
|
||||
const mainInsetRef = useMainInsetRef();
|
||||
const currentPubkey = currentIdentity?.pubkey;
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
const isHuddleTranscript = useIsHuddleTranscript(activeChannelId);
|
||||
const isHuddleTranscript =
|
||||
huddleMessages.useIsHuddleTranscript(activeChannelId);
|
||||
const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data;
|
||||
const requireThreadEditResolutionRef = React.useRef<() => boolean>(
|
||||
() => true,
|
||||
@@ -214,7 +201,7 @@ export function ChannelScreen({
|
||||
const messages = messagesQuery.data;
|
||||
if (!messages) return null;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (getThreadReference(messages[index].tags).parentId === null)
|
||||
if (threading.getThreadReference(messages[index].tags).parentId === null)
|
||||
return messages[index];
|
||||
}
|
||||
return null;
|
||||
@@ -233,7 +220,8 @@ export function ChannelScreen({
|
||||
return;
|
||||
}
|
||||
setContextParentResolver((contextId) =>
|
||||
contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX)
|
||||
contextId.startsWith(readStateFormat.THREAD_PREFIX) ||
|
||||
contextId.startsWith(readStateFormat.MSG_PREFIX)
|
||||
? activeChannelId
|
||||
: null,
|
||||
);
|
||||
@@ -253,13 +241,14 @@ export function ChannelScreen({
|
||||
const toggleReactionMutation = useToggleReactionMutation();
|
||||
const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
|
||||
const editMessageMutation = useEditMessageMutation(activeChannel);
|
||||
const joinChannelMutation = useJoinChannelMutation(activeChannelId);
|
||||
const joinChannelMutation =
|
||||
channelHooks.useJoinChannelMutation(activeChannelId);
|
||||
const {
|
||||
resolvedMessages,
|
||||
threadSummaries,
|
||||
threadRepliesError: huddleThreadRepliesError,
|
||||
onRetryThreadReplies: onRetryHuddleThreadReplies,
|
||||
} = useHuddleChannelMessages({
|
||||
} = huddleMessages.useHuddleChannelMessages({
|
||||
activeChannel,
|
||||
isHuddleTranscript,
|
||||
messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS,
|
||||
@@ -305,9 +294,11 @@ export function ChannelScreen({
|
||||
: [],
|
||||
[activeChannel],
|
||||
);
|
||||
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
|
||||
const channelMembersQuery = channelHooks.useChannelMembersQuery(
|
||||
activeChannel?.id ?? null,
|
||||
);
|
||||
const channelMembers = channelMembersQuery.data;
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const managedAgentsQuery = agentHooks.useManagedAgentsQuery();
|
||||
const managedAgents = managedAgentsQuery.data ?? [];
|
||||
const welcomeGuideAgent = React.useMemo(
|
||||
() => pickWelcomeGuideAgent(managedAgents),
|
||||
@@ -318,7 +309,7 @@ export function ChannelScreen({
|
||||
currentIdentity,
|
||||
welcomeGuideAgent,
|
||||
});
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
const relayAgentsQuery = agentHooks.useRelayAgentsQuery();
|
||||
const relayAgents = relayAgentsQuery.data ?? [];
|
||||
const knownAgentPubkeys = React.useMemo(
|
||||
() =>
|
||||
@@ -385,7 +376,7 @@ export function ChannelScreen({
|
||||
}
|
||||
return pubkeys;
|
||||
}, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]);
|
||||
const personasQuery = usePersonasQuery();
|
||||
const personasQuery = agentHooks.usePersonasQuery();
|
||||
const { personaLookup, respondToLookup } = React.useMemo(() => {
|
||||
const agents = managedAgentsQuery.data ?? [];
|
||||
const personaById = new Map(
|
||||
@@ -502,7 +493,8 @@ export function ChannelScreen({
|
||||
editMessageMutation,
|
||||
editTargetId,
|
||||
editTargetIsThreadReply:
|
||||
editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []),
|
||||
editTargetMessage !== null &&
|
||||
threading.isThreadReply(editTargetMessage.tags ?? []),
|
||||
expandedThreadReplyIds,
|
||||
getFirstReplyIdForMessage,
|
||||
getReplyDescendantIdsForMessage,
|
||||
@@ -632,9 +624,6 @@ export function ChannelScreen({
|
||||
isPlaceholderData: messagesQuery.isPlaceholderData,
|
||||
dataLength: messagesQuery.data?.length ?? null,
|
||||
},
|
||||
// A persisted head only counts as hydrated when it has rows to paint
|
||||
// (channelHeadCache.ts), so this bypass never settles onto an empty
|
||||
// placeholder while the authoritative refresh is still in flight.
|
||||
hasSettledThisChannel ||
|
||||
(activeChannelId !== null &&
|
||||
hasPersistedHydratedChannel(queryClient, activeChannelId)),
|
||||
@@ -825,27 +814,30 @@ export function ChannelScreen({
|
||||
>
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<ForumChannelContent
|
||||
canResetPanelWidth={canResetThreadPanelWidth}
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
header={channelHeader}
|
||||
onClosePost={onCloseForumPost}
|
||||
onCloseProfilePanel={handleCloseProfilePanel}
|
||||
onOpenDm={handleOpenDm}
|
||||
onOpenProfilePanel={handleOpenProfilePanel}
|
||||
onPanelResizeStart={handleThreadPanelResizeStart}
|
||||
onProfilePanelTabChange={setProfilePanelTab}
|
||||
onProfilePanelViewChange={setProfilePanelView}
|
||||
onResetPanelWidth={handleThreadPanelWidthReset}
|
||||
onSelectPost={onSelectForumPost}
|
||||
panelWidthPx={threadPanelWidthPx}
|
||||
profilePanelPubkey={profilePanelPubkey}
|
||||
profilePanelTab={profilePanelTab}
|
||||
profilePanelView={profilePanelView}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>
|
||||
searchForwarding.renderSearchAwareForum(
|
||||
<ForumChannelContent
|
||||
canResetPanelWidth={canResetThreadPanelWidth}
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
header={channelHeader}
|
||||
onClosePost={onCloseForumPost}
|
||||
onCloseProfilePanel={handleCloseProfilePanel}
|
||||
onOpenDm={handleOpenDm}
|
||||
onOpenProfilePanel={handleOpenProfilePanel}
|
||||
onPanelResizeStart={handleThreadPanelResizeStart}
|
||||
onProfilePanelTabChange={setProfilePanelTab}
|
||||
onProfilePanelViewChange={setProfilePanelView}
|
||||
onResetPanelWidth={handleThreadPanelWidthReset}
|
||||
onSelectPost={onSelectForumPost}
|
||||
panelWidthPx={threadPanelWidthPx}
|
||||
profilePanelPubkey={profilePanelPubkey}
|
||||
profilePanelTab={profilePanelTab}
|
||||
profilePanelView={profilePanelView}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>,
|
||||
searchTarget,
|
||||
)
|
||||
) : (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
@@ -854,141 +846,144 @@ export function ChannelScreen({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GuardedChannelPane
|
||||
activeChannel={activeChannel}
|
||||
activityAgents={channelAgentSessionAgents}
|
||||
agentPubkeys={agentPubkeys}
|
||||
agentPubkeysPending={agentPubkeysPending}
|
||||
agentSessionAgents={agentSessionAgents}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
onAutoSendComplete={clearAutoSend}
|
||||
botTypingEntries={botTypingEntries}
|
||||
channelManagementOpen={channelManagementOpen}
|
||||
currentPubkey={currentPubkey}
|
||||
canResetThreadPanelWidth={canResetThreadPanelWidth}
|
||||
fetchOlder={fetchOlder}
|
||||
header={channelHeader}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
historyExhausted={historyExhausted}
|
||||
onAddAgent={handleOpenAddBot}
|
||||
onBrowseChannels={openBrowseChannels}
|
||||
onCreateChannel={openCreateChannel}
|
||||
onOpenMembers={handleOpenMembersSidebar}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
isHuddleTranscript={isHuddleTranscript}
|
||||
entranceMessageId={welcomeEntranceMessageId}
|
||||
onEntranceMessageComplete={handleWelcomeEntranceComplete}
|
||||
welcomeKickoffStage={welcomeKickoffStage}
|
||||
welcomeKickoffSettingUp={welcomeKickoffSettingUp}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? buildMessageComposerEditTarget(
|
||||
editTargetMessage,
|
||||
messageProfiles,
|
||||
(pubkey) =>
|
||||
knownAgentPubkeys.has(pubkey) ||
|
||||
!!messageProfiles?.[pubkey]?.isAgent,
|
||||
)
|
||||
: null
|
||||
}
|
||||
followThreadById={followThread}
|
||||
unfollowThreadById={unfollowThread}
|
||||
isFollowingThreadById={isFollowingThread}
|
||||
isMessageUnreadById={isMessageUnread}
|
||||
isFollowingThread={isNotifiedForEffectiveThread}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
threadSummaries={threadSummaries}
|
||||
huddleThreadRepliesError={huddleThreadRepliesError}
|
||||
onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onChannelManagementDeleted={handleChannelManagementDeleted}
|
||||
onFollowThread={
|
||||
effectiveOpenThreadHeadId != null &&
|
||||
!isNotifiedForEffectiveThread
|
||||
? () => followThread(effectiveOpenThreadHeadId)
|
||||
: undefined
|
||||
}
|
||||
onUnfollowThread={
|
||||
effectiveOpenThreadHeadId != null &&
|
||||
isNotifiedForEffectiveThread
|
||||
? () => unfollowThread(effectiveOpenThreadHeadId)
|
||||
: undefined
|
||||
}
|
||||
onCloseAgentSession={handleCloseAgentSession}
|
||||
onBackFromAgentSession={
|
||||
hasAgentSessionReturnTarget
|
||||
? handleBackFromAgentSession
|
||||
: undefined
|
||||
}
|
||||
onCloseChannelManagement={handleCloseChannelManagement}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={
|
||||
activeChannel?.archivedAt ? undefined : handleDelete
|
||||
}
|
||||
onEdit={activeChannel?.archivedAt ? undefined : handleEdit}
|
||||
onEditSave={
|
||||
activeChannel?.archivedAt ? undefined : handleEditSave
|
||||
}
|
||||
onMarkUnread={handleMessageMarkUnread}
|
||||
onMarkRead={handleMessageMarkRead}
|
||||
onExpandThreadReplies={handleExpandThreadReplies}
|
||||
onOpenAgentSession={handleOpenAgentSession}
|
||||
onOpenDm={handleOpenDm}
|
||||
onOpenProfilePanel={handleOpenProfilePanel}
|
||||
onResetThreadPanelWidth={handleThreadPanelWidthReset}
|
||||
onCloseProfilePanel={handleCloseProfilePanel}
|
||||
onOpenThread={handleOpenThreadAndCloseAgentSession}
|
||||
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendToChannel={handleSendToChannel}
|
||||
onSendVideoReviewComment={effectiveSendVideoReviewComment}
|
||||
onSendThreadReply={handleSendThreadReply}
|
||||
onThreadScrollTargetResolved={() =>
|
||||
setThreadScrollTargetId(null)
|
||||
}
|
||||
onThreadPanelResizeStart={handleThreadPanelResizeStart}
|
||||
onTargetReached={() =>
|
||||
clearMessageRouteTarget({ replace: true })
|
||||
}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
openAgentSessionChannelId={openAgentSessionChannelId}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
openThreadHeadId={effectiveOpenThreadHeadId}
|
||||
shouldShowThreadSkeleton={shouldShowThreadSkeleton}
|
||||
onProfilePanelViewChange={setProfilePanelView}
|
||||
onProfilePanelTabChange={setProfilePanelTab}
|
||||
profilePanelPubkey={profilePanelPubkey}
|
||||
profilePanelTab={profilePanelTab}
|
||||
profilePanelView={profilePanelView}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
ownerProfiles={messageOwnerProfiles}
|
||||
firstUnreadMessageId={firstUnreadMessageId}
|
||||
unreadCount={unreadCount}
|
||||
targetMessageId={mainTimelineTargetMessageId}
|
||||
threadAllMessages={displayedThreadAllMessages}
|
||||
threadHeadMessage={displayedThreadHeadMessage}
|
||||
threadMessages={displayedThreadMessages}
|
||||
threadMessagesPending={threadRepliesQuery.isPending}
|
||||
threadMessagesError={threadRepliesQuery.isError}
|
||||
onRetryThreadReplies={() => {
|
||||
void threadRepliesQuery.refetch();
|
||||
}}
|
||||
threadPanelWidthPx={threadPanelWidthPx}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
|
||||
threadScrollTargetId={threadScrollTargetId}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
threadReplyUnreadCounts={threadReplyUnreadCounts}
|
||||
threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
typingPubkeys={humanTypingPubkeys}
|
||||
/>
|
||||
{searchForwarding.renderSearchAwareChannel(
|
||||
<GuardedChannelPane
|
||||
activeChannel={activeChannel}
|
||||
activityAgents={channelAgentSessionAgents}
|
||||
agentPubkeys={agentPubkeys}
|
||||
agentPubkeysPending={agentPubkeysPending}
|
||||
agentSessionAgents={agentSessionAgents}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
onAutoSendComplete={clearAutoSend}
|
||||
botTypingEntries={botTypingEntries}
|
||||
channelManagementOpen={channelManagementOpen}
|
||||
currentPubkey={currentPubkey}
|
||||
canResetThreadPanelWidth={canResetThreadPanelWidth}
|
||||
fetchOlder={fetchOlder}
|
||||
header={channelHeader}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
historyExhausted={historyExhausted}
|
||||
onAddAgent={handleOpenAddBot}
|
||||
onBrowseChannels={openBrowseChannels}
|
||||
onCreateChannel={openCreateChannel}
|
||||
onOpenMembers={handleOpenMembersSidebar}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
isHuddleTranscript={isHuddleTranscript}
|
||||
entranceMessageId={welcomeEntranceMessageId}
|
||||
onEntranceMessageComplete={handleWelcomeEntranceComplete}
|
||||
welcomeKickoffStage={welcomeKickoffStage}
|
||||
welcomeKickoffSettingUp={welcomeKickoffSettingUp}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? buildMessageComposerEditTarget(
|
||||
editTargetMessage,
|
||||
messageProfiles,
|
||||
(pubkey) =>
|
||||
knownAgentPubkeys.has(pubkey) ||
|
||||
!!messageProfiles?.[pubkey]?.isAgent,
|
||||
)
|
||||
: null
|
||||
}
|
||||
followThreadById={followThread}
|
||||
unfollowThreadById={unfollowThread}
|
||||
isFollowingThreadById={isFollowingThread}
|
||||
isMessageUnreadById={isMessageUnread}
|
||||
isFollowingThread={isNotifiedForEffectiveThread}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
threadSummaries={threadSummaries}
|
||||
huddleThreadRepliesError={huddleThreadRepliesError}
|
||||
onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onChannelManagementDeleted={handleChannelManagementDeleted}
|
||||
onFollowThread={
|
||||
effectiveOpenThreadHeadId != null &&
|
||||
!isNotifiedForEffectiveThread
|
||||
? () => followThread(effectiveOpenThreadHeadId)
|
||||
: undefined
|
||||
}
|
||||
onUnfollowThread={
|
||||
effectiveOpenThreadHeadId != null &&
|
||||
isNotifiedForEffectiveThread
|
||||
? () => unfollowThread(effectiveOpenThreadHeadId)
|
||||
: undefined
|
||||
}
|
||||
onCloseAgentSession={handleCloseAgentSession}
|
||||
onBackFromAgentSession={
|
||||
hasAgentSessionReturnTarget
|
||||
? handleBackFromAgentSession
|
||||
: undefined
|
||||
}
|
||||
onCloseChannelManagement={handleCloseChannelManagement}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={
|
||||
activeChannel?.archivedAt ? undefined : handleDelete
|
||||
}
|
||||
onEdit={activeChannel?.archivedAt ? undefined : handleEdit}
|
||||
onEditSave={
|
||||
activeChannel?.archivedAt ? undefined : handleEditSave
|
||||
}
|
||||
onMarkUnread={handleMessageMarkUnread}
|
||||
onMarkRead={handleMessageMarkRead}
|
||||
onExpandThreadReplies={handleExpandThreadReplies}
|
||||
onOpenAgentSession={handleOpenAgentSession}
|
||||
onOpenDm={handleOpenDm}
|
||||
onOpenProfilePanel={handleOpenProfilePanel}
|
||||
onResetThreadPanelWidth={handleThreadPanelWidthReset}
|
||||
onCloseProfilePanel={handleCloseProfilePanel}
|
||||
onOpenThread={handleOpenThreadAndCloseAgentSession}
|
||||
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendToChannel={handleSendToChannel}
|
||||
onSendVideoReviewComment={effectiveSendVideoReviewComment}
|
||||
onSendThreadReply={handleSendThreadReply}
|
||||
onThreadScrollTargetResolved={() =>
|
||||
setThreadScrollTargetId(null)
|
||||
}
|
||||
onThreadPanelResizeStart={handleThreadPanelResizeStart}
|
||||
onTargetReached={() =>
|
||||
clearMessageRouteTarget({ replace: true })
|
||||
}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
openAgentSessionChannelId={openAgentSessionChannelId}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
openThreadHeadId={effectiveOpenThreadHeadId}
|
||||
shouldShowThreadSkeleton={shouldShowThreadSkeleton}
|
||||
onProfilePanelViewChange={setProfilePanelView}
|
||||
onProfilePanelTabChange={setProfilePanelTab}
|
||||
profilePanelPubkey={profilePanelPubkey}
|
||||
profilePanelTab={profilePanelTab}
|
||||
profilePanelView={profilePanelView}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
ownerProfiles={messageOwnerProfiles}
|
||||
firstUnreadMessageId={firstUnreadMessageId}
|
||||
unreadCount={unreadCount}
|
||||
targetMessageId={mainTimelineTargetMessageId}
|
||||
threadAllMessages={displayedThreadAllMessages}
|
||||
threadHeadMessage={displayedThreadHeadMessage}
|
||||
threadMessages={displayedThreadMessages}
|
||||
threadMessagesPending={threadRepliesQuery.isPending}
|
||||
threadMessagesError={threadRepliesQuery.isError}
|
||||
onRetryThreadReplies={() => {
|
||||
void threadRepliesQuery.refetch();
|
||||
}}
|
||||
threadPanelWidthPx={threadPanelWidthPx}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
|
||||
threadScrollTargetId={threadScrollTargetId}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
threadReplyUnreadCounts={threadReplyUnreadCounts}
|
||||
threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
typingPubkeys={humanTypingPubkeys}
|
||||
/>,
|
||||
searchTarget,
|
||||
)}
|
||||
</React.Suspense>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -22,4 +22,8 @@ export type ChannelScreenProps = {
|
||||
targetForumReplyId: string | null;
|
||||
targetMessageEvents: RelayEvent[];
|
||||
targetMessageId: string | null;
|
||||
/** Exact clicked result id, retained after route target cleanup. */
|
||||
targetSearchMessageId?: string;
|
||||
/** Search text to highlight within the opened result message. */
|
||||
targetSearchQuery?: string;
|
||||
};
|
||||
|
||||
@@ -42,6 +42,8 @@ type ForumChannelContentProps = {
|
||||
profilePanelView: ProfilePanelView;
|
||||
selectedPostId: string | null;
|
||||
targetReplyId: string | null;
|
||||
targetSearchMessageId?: string;
|
||||
targetSearchQuery?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,8 @@ export function ForumChannelContent({
|
||||
profilePanelView,
|
||||
selectedPostId,
|
||||
targetReplyId,
|
||||
targetSearchMessageId,
|
||||
targetSearchQuery,
|
||||
}: ForumChannelContentProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -88,6 +92,8 @@ export function ForumChannelContent({
|
||||
onSelectPost={onSelectPost}
|
||||
selectedPostId={selectedPostId}
|
||||
targetReplyId={targetReplyId}
|
||||
targetSearchMessageId={targetSearchMessageId}
|
||||
targetSearchQuery={targetSearchQuery}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</section>
|
||||
|
||||
20
desktop/src/features/channels/ui/searchTargetForwarding.tsx
Normal file
20
desktop/src/features/channels/ui/searchTargetForwarding.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ForumChannelContent } from "./ForumChannelContent";
|
||||
import type { GuardedChannelPane } from "./GuardedChannelPane";
|
||||
import type { ChannelScreenProps } from "./ChannelScreen.types";
|
||||
|
||||
type SearchTarget = Pick<
|
||||
ChannelScreenProps,
|
||||
"targetSearchMessageId" | "targetSearchQuery"
|
||||
>;
|
||||
|
||||
export const renderSearchAwareForum = (
|
||||
node: React.ReactElement<React.ComponentProps<typeof ForumChannelContent>>,
|
||||
target: SearchTarget,
|
||||
) => React.cloneElement(node, target);
|
||||
|
||||
export const renderSearchAwareChannel = (
|
||||
node: React.ReactElement<React.ComponentProps<typeof GuardedChannelPane>>,
|
||||
target: SearchTarget,
|
||||
) => React.cloneElement(node, target);
|
||||
18
desktop/src/features/channels/ui/useSearchHighlightProps.ts
Normal file
18
desktop/src/features/channels/ui/useSearchHighlightProps.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function useSearchHighlightProps(
|
||||
messageId: string | null | undefined,
|
||||
query: string | undefined,
|
||||
) {
|
||||
const searchMatchingMessageIds = React.useMemo(
|
||||
() => (messageId ? new Set([messageId]) : undefined),
|
||||
[messageId],
|
||||
);
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
thread: { searchMessageId: messageId, searchQuery: query },
|
||||
timeline: { searchMatchingMessageIds, searchQuery: query },
|
||||
}),
|
||||
[messageId, query, searchMatchingMessageIds],
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,8 @@ type ForumThreadPanelProps = {
|
||||
canDeletePost?: boolean;
|
||||
isDeletingPost?: boolean;
|
||||
targetEventId?: string | null;
|
||||
targetSearchMessageId?: string;
|
||||
targetSearchQuery?: string;
|
||||
};
|
||||
|
||||
function canDeleteReply(
|
||||
@@ -57,12 +59,14 @@ function ReplyRow({
|
||||
profiles,
|
||||
channelNames,
|
||||
onDelete,
|
||||
searchQuery,
|
||||
}: {
|
||||
reply: ThreadReply;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
channelNames?: string[];
|
||||
onDelete?: (eventId: string) => void;
|
||||
searchQuery?: string;
|
||||
}) {
|
||||
const replyAuthorLabel = resolveUserLabel({
|
||||
pubkey: reply.pubkey,
|
||||
@@ -122,6 +126,7 @@ function ReplyRow({
|
||||
imetaByUrl={parseImetaTags(reply.tags)}
|
||||
mentionNames={replyMentionNames}
|
||||
mentionPubkeysByName={replyMentionPubkeysByName}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,6 +148,8 @@ export function ForumThreadPanel({
|
||||
canDeletePost,
|
||||
isDeletingPost,
|
||||
targetEventId,
|
||||
targetSearchMessageId,
|
||||
targetSearchQuery,
|
||||
}: ForumThreadPanelProps) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { channels } = useChannelNavigation();
|
||||
@@ -268,6 +275,11 @@ export function ForumThreadPanel({
|
||||
imetaByUrl={parseImetaTags(post.tags)}
|
||||
mentionNames={postMentionNames}
|
||||
mentionPubkeysByName={postMentionPubkeysByName}
|
||||
searchQuery={
|
||||
targetSearchMessageId === post.eventId
|
||||
? targetSearchQuery
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,6 +298,11 @@ export function ForumThreadPanel({
|
||||
onDelete={onDeleteReply}
|
||||
profiles={profiles}
|
||||
reply={reply}
|
||||
searchQuery={
|
||||
targetSearchMessageId === reply.eventId
|
||||
? targetSearchQuery
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ type ForumViewProps = {
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
selectedPostId: string | null;
|
||||
targetReplyId: string | null;
|
||||
targetSearchMessageId?: string;
|
||||
targetSearchQuery?: string;
|
||||
};
|
||||
|
||||
function canDelete(postPubkey: string, currentPubkey?: string): boolean {
|
||||
@@ -47,6 +49,8 @@ export function ForumView({
|
||||
onTargetReached,
|
||||
selectedPostId,
|
||||
targetReplyId,
|
||||
targetSearchMessageId,
|
||||
targetSearchQuery,
|
||||
}: ForumViewProps) {
|
||||
const [isComposerOpen, setIsComposerOpen] = React.useState(false);
|
||||
const postsScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -156,6 +160,8 @@ export function ForumView({
|
||||
onTargetReached={onTargetReached}
|
||||
profiles={profiles}
|
||||
targetEventId={targetReplyId}
|
||||
targetSearchMessageId={targetSearchMessageId}
|
||||
targetSearchQuery={targetSearchQuery}
|
||||
thread={threadQuery.data}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { FileDiff, Maximize2 } from "lucide-react";
|
||||
|
||||
import { getDiffTitleBadge } from "@/features/messages/lib/parseDiff";
|
||||
import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText";
|
||||
import { isSafeUrl } from "@/shared/lib/url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
||||
@@ -14,6 +15,7 @@ type DiffMessageProps = {
|
||||
filePath?: string;
|
||||
commitSha?: string;
|
||||
description?: string;
|
||||
searchQuery?: string;
|
||||
truncated?: boolean;
|
||||
onExpand?: () => void;
|
||||
};
|
||||
@@ -32,6 +34,7 @@ export default function DiffMessage({
|
||||
filePath,
|
||||
commitSha,
|
||||
description,
|
||||
searchQuery,
|
||||
truncated,
|
||||
onExpand,
|
||||
}: DiffMessageProps) {
|
||||
@@ -116,7 +119,7 @@ export default function DiffMessage({
|
||||
|
||||
{description && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-b border-border/40 bg-muted/20">
|
||||
{description}
|
||||
<HighlightedSearchText query={searchQuery ?? ""} text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,6 +129,7 @@ export default function DiffMessage({
|
||||
className="p-3"
|
||||
content={content}
|
||||
fallbackFilePath={filePath}
|
||||
searchQuery={searchQuery}
|
||||
viewType="unified"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Diff, Hunk, type ViewType } from "react-diff-view";
|
||||
import "react-diff-view/style/index.css";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { buildSearchResultPreview } from "@/features/search/lib/searchMatch";
|
||||
import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText";
|
||||
import {
|
||||
countDiffFileChanges,
|
||||
DIFF_TYPE_LABELS,
|
||||
@@ -18,6 +20,7 @@ type DiffViewerProps = {
|
||||
fallbackFilePath?: string;
|
||||
viewType?: ViewType;
|
||||
className?: string;
|
||||
searchQuery?: string;
|
||||
};
|
||||
|
||||
function FileChangeBadge({
|
||||
@@ -46,16 +49,20 @@ export function DiffViewer({
|
||||
fallbackFilePath,
|
||||
viewType = "unified",
|
||||
className,
|
||||
searchQuery,
|
||||
}: DiffViewerProps) {
|
||||
const { files, parseError } = useMemo(
|
||||
() => parseUnifiedDiff(content),
|
||||
[content],
|
||||
);
|
||||
const searchPreview = searchQuery
|
||||
? buildSearchResultPreview(content, searchQuery, 160)
|
||||
: null;
|
||||
|
||||
if (parseError) {
|
||||
return (
|
||||
<pre className="p-3 whitespace-pre-wrap font-mono text-xs text-muted-foreground">
|
||||
{content}
|
||||
<HighlightedSearchText query={searchQuery ?? ""} text={content} />
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +77,17 @@ export function DiffViewer({
|
||||
|
||||
return (
|
||||
<div className={cn("buzz-diff-theme", className)}>
|
||||
{searchPreview ? (
|
||||
<pre
|
||||
className="mb-3 whitespace-pre-wrap rounded-lg bg-muted/35 p-2 font-mono text-xs text-muted-foreground"
|
||||
data-testid="diff-search-preview"
|
||||
>
|
||||
<HighlightedSearchText
|
||||
query={searchQuery ?? ""}
|
||||
text={searchPreview}
|
||||
/>
|
||||
</pre>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => {
|
||||
const label = getDiffFileLabel(file, fallbackFilePath);
|
||||
|
||||
@@ -396,6 +396,7 @@ export const MessageRow = React.memo(
|
||||
setExpandedDiffId(message.id);
|
||||
}}
|
||||
repoUrl={getTag("repo")}
|
||||
searchQuery={searchQuery}
|
||||
truncated={getTag("truncated") === "true"}
|
||||
/>
|
||||
</React.Suspense>
|
||||
@@ -417,6 +418,7 @@ export const MessageRow = React.memo(
|
||||
fallbackText={waveMessage.fallbackText}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
onScrollTargetResolved: () => void;
|
||||
onScrollTargetSettled?: (messageId: string) => void;
|
||||
scrollTargetHighlights?: boolean;
|
||||
searchMessageId?: string | null;
|
||||
searchQuery?: string;
|
||||
onSelectReplyTarget: (message: TimelineMessage) => void;
|
||||
onSend: (
|
||||
content: string,
|
||||
@@ -185,6 +187,8 @@ export function MessageThreadPanel({
|
||||
replyTargetMessage,
|
||||
scrollTargetId,
|
||||
scrollTargetHighlights = true,
|
||||
searchMessageId,
|
||||
searchQuery,
|
||||
threadHead,
|
||||
videoReviewPresentation,
|
||||
threadReplies,
|
||||
@@ -562,6 +566,9 @@ export function MessageThreadPanel({
|
||||
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
searchQuery={
|
||||
searchMessageId === threadHead.id ? searchQuery : undefined
|
||||
}
|
||||
showDepthGuides={shouldShowThreadBranchGuides}
|
||||
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
|
||||
threadHead.id,
|
||||
@@ -725,6 +732,11 @@ export function MessageThreadPanel({
|
||||
onSendToChannel={stableSendToChannel}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
searchQuery={
|
||||
searchMessageId === entry.message.id
|
||||
? searchQuery
|
||||
: undefined
|
||||
}
|
||||
showDepthGuides={shouldShowThreadBranchGuides}
|
||||
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
|
||||
entry.message.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText";
|
||||
import { useHuddle } from "@/features/huddle";
|
||||
import { formatHuddleActionError } from "@/features/huddle/lib/huddleError";
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ type WaveMessageAttachmentProps = {
|
||||
fallbackText: string;
|
||||
huddleMemberPubkeys?: readonly string[];
|
||||
huddleMemberPubkeysPending?: boolean;
|
||||
searchQuery?: string;
|
||||
};
|
||||
|
||||
export function WaveMessageAttachment({
|
||||
@@ -27,6 +29,7 @@ export function WaveMessageAttachment({
|
||||
fallbackText,
|
||||
huddleMemberPubkeys = [],
|
||||
huddleMemberPubkeysPending = false,
|
||||
searchQuery,
|
||||
}: WaveMessageAttachmentProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { isStarting, startHuddle } = useHuddle();
|
||||
@@ -68,7 +71,12 @@ export function WaveMessageAttachment({
|
||||
<span className="buzz-wave-hand">👋</span>
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{fallbackText}</AttachmentTitle>
|
||||
<AttachmentTitle>
|
||||
<HighlightedSearchText
|
||||
query={searchQuery ?? ""}
|
||||
text={fallbackText}
|
||||
/>
|
||||
</AttachmentTitle>
|
||||
<AttachmentDescription>
|
||||
Start a huddle to talk to them.
|
||||
</AttachmentDescription>
|
||||
|
||||
113
desktop/src/features/search/lib/searchMatch.test.mjs
Normal file
113
desktop/src/features/search/lib/searchMatch.test.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts";
|
||||
|
||||
test("splitSearchMatches highlights every case-insensitive lexeme match", () => {
|
||||
assert.deepEqual(splitSearchMatches("Mentions and mentions", "mentions"), [
|
||||
{ isMatch: true, key: "0-8", text: "Mentions" },
|
||||
{ isMatch: false, key: "8-5", text: " and " },
|
||||
{ isMatch: true, key: "13-8", text: "mentions" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("splitSearchMatches normalizes punctuation into search lexemes", () => {
|
||||
assert.deepEqual(splitSearchMatches("foo bar release", "foo-bar"), [
|
||||
{ isMatch: true, key: "0-3", text: "foo" },
|
||||
{ isMatch: false, key: "3-1", text: " " },
|
||||
{ isMatch: true, key: "4-3", text: "bar" },
|
||||
{ isMatch: false, key: "7-8", text: " release" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("splitSearchMatches keeps completed tokens on lexeme boundaries", () => {
|
||||
assert.deepEqual(
|
||||
splitSearchMatches("projectile notes about project planning", "project pl"),
|
||||
[
|
||||
{
|
||||
isMatch: false,
|
||||
key: "0-23",
|
||||
text: "projectile notes about ",
|
||||
},
|
||||
{ isMatch: true, key: "23-7", text: "project" },
|
||||
{ isMatch: false, key: "30-1", text: " " },
|
||||
{ isMatch: true, key: "31-2", text: "pl" },
|
||||
{ isMatch: false, key: "33-6", text: "anning" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("splitSearchMatches preserves exact and prefix modes for a repeated term", () => {
|
||||
assert.deepEqual(splitSearchMatches("foo foobar", "foo foo"), [
|
||||
{ isMatch: true, key: "0-3", text: "foo" },
|
||||
{ isMatch: false, key: "3-1", text: " " },
|
||||
{ isMatch: true, key: "4-3", text: "foo" },
|
||||
{ isMatch: false, key: "7-3", text: "bar" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("splitSearchMatches highlights non-adjacent prefix-search terms", () => {
|
||||
assert.deepEqual(splitSearchMatches("agent status mentions", "agent ment"), [
|
||||
{ isMatch: true, key: "0-5", text: "agent" },
|
||||
{ isMatch: false, key: "5-8", text: " status " },
|
||||
{ isMatch: true, key: "13-4", text: "ment" },
|
||||
{ isMatch: false, key: "17-4", text: "ions" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("splitSearchMatches keeps one-character prefixes on lexeme boundaries", () => {
|
||||
assert.deepEqual(splitSearchMatches("A plan", "a"), [
|
||||
{ isMatch: true, key: "0-1", text: "A" },
|
||||
{ isMatch: false, key: "1-5", text: " plan" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("splitSearchMatches maps expanding lowercase prefixes to original spans", () => {
|
||||
assert.deepEqual(splitSearchMatches("İstanbul release", "İs"), [
|
||||
{ isMatch: true, key: "0-2", text: "İs" },
|
||||
{ isMatch: false, key: "2-14", text: "tanbul release" },
|
||||
]);
|
||||
assert.deepEqual(splitSearchMatches("İstanbul release", "İst"), [
|
||||
{ isMatch: true, key: "0-3", text: "İst" },
|
||||
{ isMatch: false, key: "3-13", text: "anbul release" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("splitSearchMatches does not split a character whose lowercase form expands", () => {
|
||||
assert.deepEqual(splitSearchMatches("İstanbul release", "i"), [
|
||||
{ isMatch: true, key: "0-1", text: "İ" },
|
||||
{ isMatch: false, key: "1-15", text: "stanbul release" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("splitSearchMatches preserves UTF-16 boundaries for supplementary letters", () => {
|
||||
assert.deepEqual(splitSearchMatches("𐐀İstanbul release", "𐐨İs"), [
|
||||
{ isMatch: true, key: "0-4", text: "𐐀İs" },
|
||||
{ isMatch: false, key: "4-14", text: "tanbul release" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSearchResultPreview keeps a late match visible", () => {
|
||||
const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`;
|
||||
const preview = buildSearchResultPreview(content, "mentions", 96);
|
||||
|
||||
assert.equal(preview.length <= 96, true);
|
||||
assert.match(preview, /mentions/i);
|
||||
assert.match(preview, /^\.\.\./);
|
||||
assert.match(preview, /\.\.\.$/);
|
||||
});
|
||||
|
||||
test("buildSearchResultPreview ignores an invalid completed-token substring", () => {
|
||||
const content = `${"projectile filler ".repeat(20)}project planning release notes`;
|
||||
const preview = buildSearchResultPreview(content, "project pl", 80);
|
||||
|
||||
assert.match(preview, /project planning/);
|
||||
assert.match(preview, /^\.\.\./);
|
||||
});
|
||||
|
||||
test("buildSearchResultPreview keeps the existing leading excerpt without a match", () => {
|
||||
assert.equal(
|
||||
buildSearchResultPreview("abcdefghijklmnopqrstuvwxyz", "missing", 10),
|
||||
"abcdefg...",
|
||||
);
|
||||
});
|
||||
201
desktop/src/features/search/lib/searchMatch.ts
Normal file
201
desktop/src/features/search/lib/searchMatch.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
export type SearchMatchPart = {
|
||||
isMatch: boolean;
|
||||
key: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type SearchHighlightTerm = {
|
||||
isPrefix: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type TextLexeme = {
|
||||
end: number;
|
||||
normalized: string;
|
||||
start: number;
|
||||
};
|
||||
|
||||
// PostgreSQL's `simple` text-search configuration breaks ordinary punctuation
|
||||
// into lexemes (for example, `foo-bar` contributes `foo` and `bar`). Keep the
|
||||
// desktop highlighter on those lexical boundaries rather than treating raw
|
||||
// whitespace tokens as unrestricted substrings.
|
||||
const LEXEME_PATTERN = /[\p{L}\p{N}]+/gu;
|
||||
|
||||
function extractLexemes(value: string): string[] {
|
||||
return Array.from(value.matchAll(LEXEME_PATTERN), (match) =>
|
||||
match[0].toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function getSearchHighlightMatchers(query: string): SearchHighlightTerm[] {
|
||||
const rawTokens = query.trim().split(/\s+/).filter(Boolean);
|
||||
const matchers: SearchHighlightTerm[] = [];
|
||||
|
||||
rawTokens.forEach((rawToken, tokenIndex) => {
|
||||
const isPrefix = tokenIndex === rawTokens.length - 1;
|
||||
for (const value of extractLexemes(rawToken)) {
|
||||
matchers.push({ isPrefix, value });
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate repeated constraints without collapsing exact and prefix modes:
|
||||
// `foo foo` asks Postgres for both an exact `foo` and a `foo:*` lexeme.
|
||||
const deduped = new Map<string, SearchHighlightTerm>();
|
||||
for (const matcher of matchers) {
|
||||
deduped.set(
|
||||
`${matcher.isPrefix ? "prefix" : "exact"}:${matcher.value}`,
|
||||
matcher,
|
||||
);
|
||||
}
|
||||
return [...deduped.values()].sort(
|
||||
(left, right) => right.value.length - left.value.length,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexemes used by desktop prefix search after punctuation normalization.
|
||||
* Completed whitespace-delimited tokens match exactly; only lexemes from the
|
||||
* trailing token match prefixes.
|
||||
*/
|
||||
export function getSearchHighlightTerms(query: string): string[] {
|
||||
return getSearchHighlightMatchers(query).map((matcher) => matcher.value);
|
||||
}
|
||||
|
||||
function getTextLexemes(text: string): TextLexeme[] {
|
||||
return Array.from(text.matchAll(LEXEME_PATTERN), (match) => ({
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
normalized: match[0].toLowerCase(),
|
||||
start: match.index ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function getOriginalPrefixLength(
|
||||
original: string,
|
||||
normalizedLength: number,
|
||||
): number {
|
||||
let normalizedOffset = 0;
|
||||
let originalOffset = 0;
|
||||
|
||||
for (const character of original) {
|
||||
normalizedOffset += character.toLowerCase().length;
|
||||
originalOffset += character.length;
|
||||
if (normalizedOffset >= normalizedLength) {
|
||||
return originalOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return original.length;
|
||||
}
|
||||
|
||||
function getMatchSpans(
|
||||
text: string,
|
||||
query: string,
|
||||
): Array<{ end: number; start: number }> {
|
||||
const matchers = getSearchHighlightMatchers(query);
|
||||
if (matchers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const spans: Array<{ end: number; start: number }> = [];
|
||||
for (const lexeme of getTextLexemes(text)) {
|
||||
const exactMatch = matchers.find(
|
||||
(matcher) => !matcher.isPrefix && matcher.value === lexeme.normalized,
|
||||
);
|
||||
if (exactMatch) {
|
||||
spans.push({ start: lexeme.start, end: lexeme.end });
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefixMatch = matchers.find(
|
||||
(matcher) =>
|
||||
matcher.isPrefix && lexeme.normalized.startsWith(matcher.value),
|
||||
);
|
||||
if (prefixMatch) {
|
||||
const originalLexeme = text.slice(lexeme.start, lexeme.end);
|
||||
spans.push({
|
||||
start: lexeme.start,
|
||||
end:
|
||||
lexeme.start +
|
||||
getOriginalPrefixLength(originalLexeme, prefixMatch.value.length),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** Split text around case-insensitive lexeme/prefix matches of the query. */
|
||||
export function splitSearchMatches(
|
||||
text: string,
|
||||
query: string,
|
||||
): SearchMatchPart[] {
|
||||
const spans = getMatchSpans(text, query);
|
||||
if (spans.length === 0) {
|
||||
return [{ isMatch: false, key: "0", text }];
|
||||
}
|
||||
|
||||
const parts: SearchMatchPart[] = [];
|
||||
let offset = 0;
|
||||
for (const span of spans) {
|
||||
if (span.start > offset) {
|
||||
parts.push({
|
||||
isMatch: false,
|
||||
key: `${offset}-${span.start - offset}`,
|
||||
text: text.slice(offset, span.start),
|
||||
});
|
||||
}
|
||||
parts.push({
|
||||
isMatch: true,
|
||||
key: `${span.start}-${span.end - span.start}`,
|
||||
text: text.slice(span.start, span.end),
|
||||
});
|
||||
offset = span.end;
|
||||
}
|
||||
if (offset < text.length) {
|
||||
parts.push({
|
||||
isMatch: false,
|
||||
key: `${offset}-${text.length - offset}`,
|
||||
text: text.slice(offset),
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact result excerpt that keeps the first matching search term
|
||||
* visible. Context is biased before the match so the excerpt still reads like
|
||||
* a sentence while avoiding a match that is clipped offscreen.
|
||||
*/
|
||||
export function buildSearchResultPreview(
|
||||
content: string,
|
||||
query: string,
|
||||
maxLength = 96,
|
||||
): string {
|
||||
const text = content.trim();
|
||||
if (!text) {
|
||||
return "No message body.";
|
||||
}
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const matchIndex = getMatchSpans(text, query)[0]?.start ?? -1;
|
||||
if (matchIndex < 0) {
|
||||
return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
const contextBefore = Math.min(32, Math.floor(maxLength / 3));
|
||||
let start = Math.max(0, matchIndex - contextBefore);
|
||||
const end = Math.min(text.length, start + maxLength);
|
||||
|
||||
if (end === text.length) {
|
||||
start = Math.max(0, end - maxLength);
|
||||
}
|
||||
|
||||
const prefix = start > 0 ? "..." : "";
|
||||
const suffix = end < text.length ? "..." : "";
|
||||
const available = Math.max(0, maxLength - prefix.length - suffix.length);
|
||||
const excerpt = text.slice(start, start + available).trim();
|
||||
|
||||
return `${prefix}${excerpt}${suffix}`;
|
||||
}
|
||||
26
desktop/src/features/search/ui/HighlightedSearchText.tsx
Normal file
26
desktop/src/features/search/ui/HighlightedSearchText.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { splitSearchMatches } from "@/features/search/lib/searchMatch";
|
||||
import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle";
|
||||
|
||||
export function HighlightedSearchText({
|
||||
query,
|
||||
text,
|
||||
}: {
|
||||
query: string;
|
||||
text: string;
|
||||
}) {
|
||||
return splitSearchMatches(text, query).map((part) =>
|
||||
part.isMatch ? (
|
||||
<mark
|
||||
className={SEARCH_MATCH_HIGHLIGHT_CLASS}
|
||||
data-search-match="true"
|
||||
key={part.key}
|
||||
>
|
||||
{part.text}
|
||||
</mark>
|
||||
) : (
|
||||
<React.Fragment key={part.key}>{part.text}</React.Fragment>
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import * as React from "react";
|
||||
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { getMinimumSearchQueryLength } from "@/features/search/hooks";
|
||||
import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators";
|
||||
import { buildSearchResultPreview } from "@/features/search/lib/searchMatch";
|
||||
import { useSearchResults } from "@/features/search/useSearchResults";
|
||||
import {
|
||||
resultIcon,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
getChannelScopeLabel,
|
||||
SearchDialogInputRow,
|
||||
} from "@/features/search/ui/SearchScopeControls";
|
||||
import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText";
|
||||
import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation";
|
||||
import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -36,7 +39,7 @@ type TopbarSearchProps = {
|
||||
currentChannelId?: string | null;
|
||||
focusRequest?: number;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onOpenResult: (hit: SearchHit) => void;
|
||||
onOpenResult: (hit: SearchHit, query: string) => void;
|
||||
onOpenUser?: (user: UserSearchResult) => void | Promise<void>;
|
||||
onBrowseChannels?: () => void | Promise<void>;
|
||||
onCreateAgent?: () => void | Promise<void>;
|
||||
@@ -72,19 +75,6 @@ type SearchHitContextLabel = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
function truncateResultText(content: string, maxLength = 96) {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return "No message body.";
|
||||
}
|
||||
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(unixSeconds: number) {
|
||||
const diff = Math.floor(Date.now() / 1_000) - unixSeconds;
|
||||
|
||||
@@ -436,6 +426,10 @@ export function TopbarSearch({
|
||||
scopeChannelId,
|
||||
});
|
||||
const trimmedQuery = query.trim();
|
||||
// Bind highlights to the debounced result source so stale results can never
|
||||
// pair with newly typed text during the debounce window.
|
||||
const resultQuery = parseSearchOperators(debouncedQuery).text;
|
||||
const resultsAreCurrent = debouncedQuery === trimmedQuery;
|
||||
const isIconVariant = variant === "icon";
|
||||
const currentChannel = currentChannelId
|
||||
? (channelLookup.get(currentChannelId) ?? null)
|
||||
@@ -504,9 +498,10 @@ export function TopbarSearch({
|
||||
),
|
||||
[currentPubkeyNormalized, results],
|
||||
);
|
||||
const visibleSearchableResults = resultsAreCurrent ? searchableResults : [];
|
||||
const searchResultSections = React.useMemo(
|
||||
() => groupSearchResults(searchableResults),
|
||||
[searchableResults],
|
||||
() => groupSearchResults(visibleSearchableResults),
|
||||
[visibleSearchableResults],
|
||||
);
|
||||
const groupedSearchResults = React.useMemo(
|
||||
() => searchResultSections.flatMap((section) => section.results),
|
||||
@@ -516,8 +511,11 @@ export function TopbarSearch({
|
||||
? scopeChannel
|
||||
? []
|
||||
: suggestionResults
|
||||
: groupedSearchResults;
|
||||
: resultsAreCurrent
|
||||
? groupedSearchResults
|
||||
: [];
|
||||
const isSearchLoading =
|
||||
(!isShowingSuggestions && !resultsAreCurrent) ||
|
||||
isWaitingOnFromResolution ||
|
||||
searchQuery.isLoading ||
|
||||
fuzzyUserCandidatesQuery.isLoading ||
|
||||
@@ -581,7 +579,7 @@ export function TopbarSearch({
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenResult(result.hit);
|
||||
onOpenResult(result.hit, resultQuery);
|
||||
},
|
||||
[
|
||||
onBrowseChannels,
|
||||
@@ -592,6 +590,7 @@ export function TopbarSearch({
|
||||
onOpenUser,
|
||||
openAfterExit,
|
||||
setQuery,
|
||||
resultQuery,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -697,7 +696,7 @@ export function TopbarSearch({
|
||||
? result.action.description
|
||||
: result.kind === "user"
|
||||
? getUserSecondaryLabel(result.user)
|
||||
: truncateResultText(result.hit.content);
|
||||
: buildSearchResultPreview(result.hit.content, resultQuery);
|
||||
const trailingLabel =
|
||||
result.kind === "channel"
|
||||
? getChannelSuggestionMeta(result.channel)
|
||||
@@ -771,7 +770,7 @@ export function TopbarSearch({
|
||||
) : null}
|
||||
{preview ? (
|
||||
<span className="col-start-1 mt-1.5 block min-w-0 truncate text-sm leading-5 text-muted-foreground">
|
||||
{preview}
|
||||
<HighlightedSearchText query={resultQuery} text={preview} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -873,12 +872,13 @@ export function TopbarSearch({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : isSearchLoading && searchableResults.length === 0 ? (
|
||||
) : isSearchLoading && visibleSearchableResults.length === 0 ? (
|
||||
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
|
||||
{currentChannelSearchAction}
|
||||
<SearchResultsSkeleton />
|
||||
</div>
|
||||
) : searchQuery.error instanceof Error && searchableResults.length === 0 ? (
|
||||
) : searchQuery.error instanceof Error &&
|
||||
visibleSearchableResults.length === 0 ? (
|
||||
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
|
||||
{currentChannelSearchAction}
|
||||
<p
|
||||
@@ -890,7 +890,7 @@ export function TopbarSearch({
|
||||
{searchQuery.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : searchableResults.length === 0 ? (
|
||||
) : visibleSearchableResults.length === 0 ? (
|
||||
<div className="max-h-[min(60vh,32rem)] overflow-y-auto">
|
||||
{currentChannelSearchAction}
|
||||
<p
|
||||
@@ -964,7 +964,7 @@ export function TopbarSearch({
|
||||
)}
|
||||
</button>
|
||||
<DialogContent
|
||||
aria-busy={isSearchLoading && searchableResults.length === 0}
|
||||
aria-busy={isSearchLoading && visibleSearchableResults.length === 0}
|
||||
className="mt-[18vh] max-w-2xl self-start gap-0 overflow-hidden rounded-2xl p-0 shadow-2xl"
|
||||
data-testid="search-results"
|
||||
onOpenAutoFocus={(event) => {
|
||||
|
||||
@@ -88,7 +88,7 @@ export type AppSidebarProps = {
|
||||
onSelectWorkflows: () => void;
|
||||
onSelectHome: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onOpenSearchResult: (hit: SearchHit) => void;
|
||||
onOpenSearchResult: (hit: SearchHit, query: string) => void;
|
||||
/** Full channel set for global search, including channels outside the joined sidebar list. */
|
||||
searchChannels: Channel[];
|
||||
searchFocusRequests: readonly [global: number, channel: number];
|
||||
|
||||
@@ -30,7 +30,7 @@ type AppSidebarPinnedHeaderProps = {
|
||||
onCreateAgent: () => void;
|
||||
onCreateChannel: () => void;
|
||||
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
|
||||
onOpenSearchResult: (hit: SearchHit) => void;
|
||||
onOpenSearchResult: (hit: SearchHit, query: string) => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
searchChannels: Channel[];
|
||||
searchFocusRequest: number;
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* ReactMarkdown's architecture — no post-render tree walking needed.
|
||||
*/
|
||||
|
||||
import { splitSearchMatches } from "@/features/search/lib/searchMatch";
|
||||
import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle";
|
||||
|
||||
// Minimal HAST types — matches the pattern in rehypeImageGallery.ts.
|
||||
interface HastText {
|
||||
type: "text";
|
||||
@@ -34,45 +37,32 @@ function isText(node: HastNode): node is HastText {
|
||||
return node.type === "text";
|
||||
}
|
||||
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export default function rehypeSearchHighlight({ query }: { query: string }) {
|
||||
return (tree: HastRoot) => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 2) return;
|
||||
|
||||
const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "i");
|
||||
|
||||
function walk(nodes: HastNode[]): HastNode[] {
|
||||
const result: HastNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (isText(node)) {
|
||||
const parts = node.value.split(pattern);
|
||||
if (parts.length === 1) {
|
||||
const parts = splitSearchMatches(node.value, query);
|
||||
if (!parts.some((part) => part.isMatch)) {
|
||||
result.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
|
||||
if (i % 2 === 1) {
|
||||
// Odd indices from split-with-capture are always the match.
|
||||
for (const part of parts) {
|
||||
if (part.isMatch) {
|
||||
result.push({
|
||||
type: "element",
|
||||
tagName: "mark",
|
||||
properties: {
|
||||
className:
|
||||
"rounded-xs bg-primary/20 text-foreground dark:bg-primary/30",
|
||||
className: SEARCH_MATCH_HIGHLIGHT_CLASS,
|
||||
"data-search-match": "true",
|
||||
},
|
||||
children: [{ type: "text", value: part }],
|
||||
children: [{ type: "text", value: part.text }],
|
||||
});
|
||||
} else {
|
||||
result.push({ type: "text", value: part });
|
||||
result.push({ type: "text", value: part.text });
|
||||
}
|
||||
}
|
||||
} else if (isElement(node)) {
|
||||
|
||||
3
desktop/src/shared/lib/searchHighlightStyle.ts
Normal file
3
desktop/src/shared/lib/searchHighlightStyle.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/** Shared visual treatment for literal search matches. */
|
||||
export const SEARCH_MATCH_HIGHLIGHT_CLASS =
|
||||
"rounded-xs bg-yellow-300/80 text-yellow-950 dark:bg-yellow-300/70 dark:text-yellow-950";
|
||||
@@ -227,9 +227,27 @@ test("hardLineBreaks changes the parse and the cache key", () => {
|
||||
assert.equal(withoutBreaks, withoutBreaksAgain);
|
||||
});
|
||||
|
||||
test("active search queries bypass the cache", () => {
|
||||
clearMarkdownNodeCache();
|
||||
const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" });
|
||||
const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" });
|
||||
assert.notEqual(first, second);
|
||||
test("single-character scoped search stays on lexeme boundaries", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
renderCachedMarkdown({ ...BASE, content: "A plan", searchQuery: "a" }),
|
||||
);
|
||||
|
||||
assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("active search queries bypass the cache and highlight every match", () => {
|
||||
clearMarkdownNodeCache();
|
||||
const input = {
|
||||
...BASE,
|
||||
content: "Bold and bold, but not code `bold`.",
|
||||
searchQuery: "bold",
|
||||
};
|
||||
const first = renderCachedMarkdown(input);
|
||||
const second = renderCachedMarkdown(input);
|
||||
const html = renderToStaticMarkup(first);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2);
|
||||
assert.match(html, /bg-yellow-300/);
|
||||
assert.match(html, /<code>bold<\/code>/);
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement {
|
||||
if (input.leadingInlineContent) {
|
||||
rehypePlugins.push(rehypeLeadingInlineContent);
|
||||
}
|
||||
if (input.searchQuery && input.searchQuery.trim().length >= 2) {
|
||||
if (input.searchQuery && input.searchQuery.trim().length >= 1) {
|
||||
rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]);
|
||||
}
|
||||
// Called as a plain function rather than rendered as <ReactMarkdown/>:
|
||||
@@ -131,7 +131,7 @@ export function renderCachedMarkdown(
|
||||
// than churn the cache with per-query variants. Oversized content parses
|
||||
// fresh too — see MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH.
|
||||
if (
|
||||
(input.searchQuery && input.searchQuery.trim().length >= 2) ||
|
||||
(input.searchQuery && input.searchQuery.trim().length >= 1) ||
|
||||
input.content.length > MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH
|
||||
) {
|
||||
return buildMarkdownElement(input);
|
||||
|
||||
@@ -4418,6 +4418,15 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||
content: "Release checklist: async feedback thread.",
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
},
|
||||
{
|
||||
id: "mock-forum-offsite-thread",
|
||||
pubkey: ALICE_PUBKEY,
|
||||
created_at: Math.floor(Date.now() / 1000) - 85 * 60,
|
||||
kind: 45001,
|
||||
tags: [["h", channelId]],
|
||||
content: "Team offsite planning and travel notes.",
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
},
|
||||
{
|
||||
id: "mock-forum-release-reply",
|
||||
pubkey: ALICE_PUBKEY,
|
||||
|
||||
@@ -346,6 +346,171 @@ test("opens sidebar search with the shortcut and loads the exact result", async
|
||||
);
|
||||
});
|
||||
|
||||
test("highlights the query in search results and the opened message", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await page.getByTestId("search-dialog-input").fill("SHIPPED");
|
||||
|
||||
const result = page.getByTestId("search-result-mock-engineering-shipped");
|
||||
await expect(result).toBeVisible();
|
||||
await expect(result.locator("mark")).toHaveText("shipped");
|
||||
await expect(result.locator("mark")).toHaveClass(/bg-yellow-300/);
|
||||
|
||||
await result.click();
|
||||
|
||||
const message = page
|
||||
.getByTestId("message-timeline")
|
||||
.locator('[data-message-id="mock-engineering-shipped"]');
|
||||
await expect(message).toBeVisible();
|
||||
await expect(message.locator('[data-search-match="true"]')).toHaveText(
|
||||
"shipped",
|
||||
);
|
||||
});
|
||||
|
||||
test("highlights the clicked forum post when its route is already open", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
"/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread",
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-forum-event-id="mock-forum-release-thread"]'),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await page.getByTestId("search-dialog-input").fill("checklist");
|
||||
|
||||
const result = page.getByTestId("search-result-mock-forum-release-thread");
|
||||
await expect(result).toBeVisible();
|
||||
await result.click();
|
||||
|
||||
const post = page.locator(
|
||||
'[data-forum-event-id="mock-forum-release-thread"]',
|
||||
);
|
||||
await expect(post.locator('[data-search-match="true"]')).toHaveText(
|
||||
"checklist",
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary same-channel activation clears a prior search highlight", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await page.getByTestId("search-dialog-input").fill("shipped");
|
||||
await page.getByTestId("search-result-mock-engineering-shipped").click();
|
||||
|
||||
const message = page
|
||||
.getByTestId("message-timeline")
|
||||
.locator('[data-message-id="mock-engineering-shipped"]');
|
||||
await expect(message.locator('[data-search-match="true"]')).toHaveText(
|
||||
"shipped",
|
||||
);
|
||||
await expect(page).toHaveURL(
|
||||
/#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9(?:\?thread=mock-engineering-shipped)?$/,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
|
||||
await expect(message.locator('[data-search-match="true"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ordinary rendered channel link clears a prior search highlight", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await page.getByTestId("search-dialog-input").fill("welcome");
|
||||
await page.getByTestId("search-result-mock-general-welcome").click();
|
||||
|
||||
const message = page
|
||||
.getByTestId("message-timeline")
|
||||
.locator('[data-message-id="mock-general-welcome"]');
|
||||
await expect(message.locator('[data-search-match="true"]')).toHaveText(
|
||||
"Welcome",
|
||||
);
|
||||
|
||||
await message.locator('[data-channel-link=""]').click();
|
||||
|
||||
await expect(message.locator('[data-search-match="true"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ordinary same-forum activation clears a prior search highlight", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
"/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread",
|
||||
);
|
||||
await page.getByTestId("open-search").click();
|
||||
await page.getByTestId("search-dialog-input").fill("checklist");
|
||||
await page.getByTestId("search-result-mock-forum-release-thread").click();
|
||||
|
||||
const post = page.locator(
|
||||
'[data-forum-event-id="mock-forum-release-thread"]',
|
||||
);
|
||||
await expect(post.locator('[data-search-match="true"]')).toHaveText(
|
||||
"checklist",
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await page.getByText("Release checklist: async feedback thread.").click();
|
||||
|
||||
await expect(post.locator('[data-search-match="true"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ordinary forum navigation clears a prior search highlight", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
"/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread",
|
||||
);
|
||||
await page.getByTestId("open-search").click();
|
||||
await page.getByTestId("search-dialog-input").fill("checklist");
|
||||
await page.getByTestId("search-result-mock-forum-release-thread").click();
|
||||
|
||||
const releasePost = page.locator(
|
||||
'[data-forum-event-id="mock-forum-release-thread"]',
|
||||
);
|
||||
await expect(releasePost.locator('[data-search-match="true"]')).toHaveText(
|
||||
"checklist",
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await page.getByText("Team offsite planning and travel notes.").click();
|
||||
await expect(
|
||||
page.locator('[data-forum-event-id="mock-forum-offsite-thread"]'),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await page.getByText("Release checklist: async feedback thread.").click();
|
||||
|
||||
await expect(releasePost).toBeVisible();
|
||||
await expect(releasePost.locator('[data-search-match="true"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not expose stale search results with a newly typed query", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
const input = page.getByTestId("search-dialog-input");
|
||||
await input.fill("shipped");
|
||||
await expect(
|
||||
page.getByTestId("search-result-mock-engineering-shipped"),
|
||||
).toBeVisible();
|
||||
|
||||
await input.fill("mentions");
|
||||
await expect(
|
||||
page.getByTestId("search-result-mock-engineering-shipped"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opens channel matches from search", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user