mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
**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>
173 lines
7.1 KiB
TypeScript
173 lines
7.1 KiB
TypeScript
import type * as React from "react";
|
|
import ReactMarkdown, { type Components } from "react-markdown";
|
|
import remarkBreaks from "remark-breaks";
|
|
import remarkGfm from "remark-gfm";
|
|
|
|
import remarkChannelDeepLinks from "@/features/messages/lib/remarkChannelDeepLinks";
|
|
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
|
import remarkEntityLinks from "@/features/messages/lib/remarkEntityLinks";
|
|
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
|
|
import rehypeLeadingInlineContent from "@/shared/lib/rehypeLeadingInlineContent";
|
|
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
|
|
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
|
|
import remarkCustomEmoji, {
|
|
type CustomEmoji,
|
|
} from "@/shared/lib/remarkCustomEmoji";
|
|
import remarkMentions from "@/shared/lib/remarkMentions";
|
|
import remarkSpoilers from "@/shared/lib/remarkSpoilers";
|
|
|
|
import { buzzDeepLinkUrlTransform } from "./utils";
|
|
|
|
/**
|
|
* Parsed-markdown element cache.
|
|
*
|
|
* The message timeline's scroll container is keyed by channel id (see
|
|
* MessageTimeline — required so TanStack Router's scroll restoration never
|
|
* writes a stale scrollTop into a reused scroll node), so every channel
|
|
* switch remounts every row and `React.memo` cannot carry the react-markdown
|
|
* parse across the remount. react-markdown's `Markdown` is a plain
|
|
* synchronous hook-free function, so its element tree is a pure function of
|
|
* the parse inputs below and can be reused across mounts. Everything
|
|
* per-mount (channels, imeta lookup, navigation callbacks) flows through
|
|
* `MarkdownRuntimeContext`, read at render time — a cached element never
|
|
* captures per-mount state. The `components` map passed in must be
|
|
* module-stable and fully identified by `variant` (see
|
|
* `getMarkdownComponents`) — the map itself is deliberately not part of the
|
|
* cache key.
|
|
*
|
|
* Recency-ordered via Map insertion order; capacity comfortably covers two
|
|
* window-ceiling channels' worth of rows.
|
|
*/
|
|
const MARKDOWN_NODE_CACHE_LIMIT = 1000;
|
|
/** Oversized messages (large agent pastes) bypass the cache: they rarely
|
|
* repeat enough to benefit, and each entry would retain the full content in
|
|
* both the key and the element tree. Mirrors the searchQuery bypass. */
|
|
const MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH = 32_000;
|
|
const markdownNodeCache = new Map<string, React.ReactElement>();
|
|
|
|
/** Community switches swap relays; drop parses keyed against the old
|
|
* community's mention/channel-name space (see `resetCommunityState`). */
|
|
export function clearMarkdownNodeCache() {
|
|
markdownNodeCache.clear();
|
|
}
|
|
|
|
let markdownParseCount = 0;
|
|
|
|
/** Number of fresh react-markdown parses since app start (cache misses and
|
|
* bypasses). Exposed through the e2e bridge so specs can assert that warm
|
|
* channel switches are pure cache hits (zero fresh parses). */
|
|
export function getMarkdownParseCount(): number {
|
|
return markdownParseCount;
|
|
}
|
|
|
|
/** Inputs that fully determine the parsed element tree. `variant` identifies
|
|
* the module-stable `components` map (see `getMarkdownComponents`); the two
|
|
* must always come from the same call so they cannot drift apart. */
|
|
export type MarkdownParseInputs = {
|
|
channelNames?: string[];
|
|
components: Components;
|
|
content: string;
|
|
customEmoji?: CustomEmoji[];
|
|
/** Omit or true for chat-style `<br>` on every newline. */
|
|
hardLineBreaks?: boolean;
|
|
/** Inserts the runtime-provided leading content marker during parsing. */
|
|
leadingInlineContent?: boolean;
|
|
mentionNames?: string[];
|
|
searchQuery?: string;
|
|
variant: string;
|
|
};
|
|
|
|
/** Length-prefix a segment so no value can forge a boundary — an injective
|
|
* encoding regardless of the bytes in relay-controlled names and URLs. */
|
|
function segment(value: string): string {
|
|
return `${value.length}:${value}`;
|
|
}
|
|
|
|
function listSegment(values: readonly string[] | undefined): string {
|
|
return segment(values?.map(segment).join("") ?? "");
|
|
}
|
|
|
|
function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement {
|
|
markdownParseCount += 1;
|
|
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
|
const rehypePlugins: any[] = [rehypeImageGallery];
|
|
if (input.leadingInlineContent) {
|
|
rehypePlugins.push(rehypeLeadingInlineContent);
|
|
}
|
|
if (input.searchQuery && input.searchQuery.trim().length >= 1) {
|
|
rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]);
|
|
}
|
|
// Called as a plain function rather than rendered as <ReactMarkdown/>:
|
|
// react-markdown's `Markdown` is synchronous and hook-free (the hook
|
|
// variant is `MarkdownHooks`), so this returns the parsed element tree
|
|
// directly, which is what lets it live in a module-level cache.
|
|
return ReactMarkdown({
|
|
children: input.content,
|
|
components: input.components,
|
|
remarkPlugins: [
|
|
remarkGfm,
|
|
...(input.hardLineBreaks === false ? [] : [remarkBreaks]),
|
|
remarkSpoilers,
|
|
remarkChannelDeepLinks,
|
|
remarkMessageLinks,
|
|
remarkEntityLinks,
|
|
[remarkMentions, { mentionNames: input.mentionNames }],
|
|
[remarkChannelLinks, { channelNames: input.channelNames }],
|
|
[remarkCustomEmoji, { customEmoji: input.customEmoji }],
|
|
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
|
] as any[],
|
|
rehypePlugins,
|
|
urlTransform: buzzDeepLinkUrlTransform,
|
|
});
|
|
}
|
|
|
|
/** Return the parsed element tree for the given inputs, reusing a cached
|
|
* tree when an identical parse has been done before. See the module doc
|
|
* comment for why this is safe. */
|
|
export function renderCachedMarkdown(
|
|
input: MarkdownParseInputs,
|
|
): React.ReactElement {
|
|
// Search highlighting is transient and query-specific: parse fresh rather
|
|
// 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 >= 1) ||
|
|
input.content.length > MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH
|
|
) {
|
|
return buildMarkdownElement(input);
|
|
}
|
|
// Everything that changes the parse output must be in the key. Arrays are
|
|
// keyed by value, not identity — callers rebuild them across mounts. Every
|
|
// field is length-prefixed, so relay-controlled values cannot collide two
|
|
// distinct input tuples. Content is last and needs no prefix: everything
|
|
// before it is self-delimiting.
|
|
const key =
|
|
segment(input.hardLineBreaks === false ? "soft" : "hard") +
|
|
segment(input.variant) +
|
|
segment(input.leadingInlineContent ? "leading" : "") +
|
|
listSegment(input.mentionNames) +
|
|
listSegment(input.channelNames) +
|
|
listSegment(
|
|
input.customEmoji?.map(
|
|
(emoji) => segment(emoji.shortcode) + segment(emoji.url),
|
|
),
|
|
) +
|
|
input.content;
|
|
|
|
const hit = markdownNodeCache.get(key);
|
|
if (hit) {
|
|
markdownNodeCache.delete(key);
|
|
markdownNodeCache.set(key, hit);
|
|
return hit;
|
|
}
|
|
const element = buildMarkdownElement(input);
|
|
markdownNodeCache.set(key, element);
|
|
if (markdownNodeCache.size > MARKDOWN_NODE_CACHE_LIMIT) {
|
|
const oldest = markdownNodeCache.keys().next().value;
|
|
if (oldest !== undefined) {
|
|
markdownNodeCache.delete(oldest);
|
|
}
|
|
}
|
|
return element;
|
|
}
|