diff --git a/apps/web/src/components/views/elements/SearchWarning.tsx b/apps/web/src/components/views/elements/SearchWarning.tsx index c8c0ec0bffe..091a32ec234 100644 --- a/apps/web/src/components/views/elements/SearchWarning.tsx +++ b/apps/web/src/components/views/elements/SearchWarning.tsx @@ -6,10 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, type ReactNode } from "react"; +import React, { type JSX, type ReactNode, useCallback, useEffect, useState } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import EventIndexPeg from "../../../indexing/EventIndexPeg"; +import type EventIndex from "../../../indexing/EventIndex"; +import { SearchScope } from "../../../Searching"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; import dis from "../../../dispatcher/dispatcher"; @@ -26,11 +28,131 @@ interface IProps { isRoomEncrypted?: boolean; kind: WarningKind; showLogo?: boolean; + /** The scope of the search being warned about; only meaningful for {@link WarningKind.Search}. */ + scope?: SearchScope; + /** The room being searched. Mirrors `SearchInfo.roomId`: `undefined` when searching all rooms. */ + roomId?: string; } -export default function SearchWarning({ isRoomEncrypted, kind, showLogo = true }: IProps): JSX.Element { +/** + * Track whether the index is still missing history that is relevant to the given search. + * + * A room-scoped search is incomplete if the crawler still holds a checkpoint for the room + * ({@link EventIndex.crawlingRooms}, which covers both the checkpoint being crawled right now and + * those still queued behind it), or if the index holds no events for it at all + * ({@link EventIndex.isRoomIndexed}) — which is how a room looks before its checkpoint has been + * seeded, and is the case the checkpoint set alone cannot see. + * + * The second question is only asked while the crawler still has work outstanding, for two reasons. + * It is what the warning claims ("your search index is still being built"), and the index has no + * event for its contents changing — `changedCheckpoint` fires only on checkpoint transitions, and + * an idle crawler is silent — so a warning raised once the crawler has drained would never be + * re-evaluated and would stick. + * + * Neither signal proves completeness: `isRoomIndexed` reports only that the index holds *some* + * events for a room, not all of them, and a room has no checkpoint if it never had a + * back-pagination token to crawl from or if its checkpoint was dropped because the server rejected + * the request. So this under-warns rather than over-warns. + * + * An all-rooms search cannot ask the per-room question, so it uses the checkpoint set alone. + * + * The `changedCheckpoint` payload carries only the globally-current room and so cannot answer a + * per-room question: we re-read the index on each event rather than trust it. + * + * @param index The event index to observe, or `null` if there is no index. + * @param scope The scope of the search, if this warning is being rendered for one. + * @param roomId The room being searched, or `undefined` when searching all rooms. + * @returns `true` while the index is known to be missing history for the search, `false` otherwise. + */ +function useIsIndexIncomplete(index: EventIndex | null, scope?: SearchScope, roomId?: string): boolean { + const readCheckpoints = useCallback((): { relevant: boolean; anyOutstanding: boolean } => { + if (!index) return { relevant: false, anyOutstanding: false }; + const { crawlingRooms } = index.crawlingRooms(); + // Fall back to the global check when we don't know which room is being searched: the room + // id may still be undefined while a room alias is being resolved. + const roomScoped = scope === SearchScope.Room && roomId !== undefined; + return { + relevant: roomScoped ? crawlingRooms.has(roomId) : crawlingRooms.size > 0, + anyOutstanding: crawlingRooms.size > 0, + }; + }, [index, scope, roomId]); + + // The checkpoint half of the answer is known synchronously, so seed from it rather than + // rendering an unwarned search for a room we already know is being crawled. + const [incomplete, setIncomplete] = useState(() => readCheckpoints().relevant); + + useEffect(() => { + if (!index) { + setIncomplete(false); + return; + } + + // Guards against a slow isRoomIndexed() response overwriting a newer one, or landing after + // the scope changed or the component unmounted. + let generation = 0; + + // Answer this scope and room from the checkpoint set up front, so that the previous + // search's result is not left on screen while the first lookup below is in flight. Doing + // this per effect run rather than per event matters: a checkpoint change is not a new + // question, and resetting on one would blink an already-earned warning off and on again. + setIncomplete(readCheckpoints().relevant); + + const update = async (): Promise => { + const current = ++generation; + const { relevant, anyOutstanding } = readCheckpoints(); + + if (relevant) { + setIncomplete(true); + return; + } + if (!anyOutstanding || scope !== SearchScope.Room || roomId === undefined) { + setIncomplete(false); + return; + } + + // Nothing is queued for this room yet, but the index may hold nothing for it at all. + // `undefined` means there is no index manager to ask, which is not evidence either way. + const indexed = await index.isRoomIndexed(roomId); + if (current === generation) setIncomplete(indexed === false); + }; + + const onChangedCheckpoint = (): void => { + void update(); + }; + + // Re-sync in case the crawl state changed between the initial render and the subscription. + onChangedCheckpoint(); + index.on("changedCheckpoint", onChangedCheckpoint); + + return () => { + generation++; + index.removeListener("changedCheckpoint", onChangedCheckpoint); + }; + }, [index, scope, roomId, readCheckpoints]); + + return incomplete; +} + +export default function SearchWarning({ isRoomEncrypted, kind, showLogo = true, scope, roomId }: IProps): JSX.Element { + const eventIndex = EventIndexPeg.get(); + const indexIncomplete = useIsIndexIncomplete(eventIndex, scope, roomId); + if (!isRoomEncrypted) return <>; - if (EventIndexPeg.get()) return <>; + + if (eventIndex) { + // The index is still missing history for this search, so it may silently return partial + // results (#32253). Warn the user. + if (indexIncomplete && kind === WarningKind.Search) { + // This warning appears dynamically while a search panel is already open (the crawler + // finishes draining mid-session), so mark it as a polite live region for screen readers. + return ( +
+ {_t("seshat|warning_kind_search_partial")} +
+ ); + } + return <>; + } if (EventIndexPeg.error) { return ( diff --git a/apps/web/src/components/views/rooms/RoomSearchAuxPanel.tsx b/apps/web/src/components/views/rooms/RoomSearchAuxPanel.tsx index 14c7ad511ed..b2a7119d3a7 100644 --- a/apps/web/src/components/views/rooms/RoomSearchAuxPanel.tsx +++ b/apps/web/src/components/views/rooms/RoomSearchAuxPanel.tsx @@ -45,7 +45,13 @@ const RoomSearchAuxPanel: React.FC = ({ searchInfo, isRoomEncrypted, onSe ) : ( )} - +
diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 1671f797e01..1a030bb50ce 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -2434,7 +2434,8 @@ "warning_kind_files": "This version of %(brand)s does not support viewing some encrypted files", "warning_kind_files_app": "Use the Desktop app to see all encrypted files", "warning_kind_search": "This version of %(brand)s does not support searching encrypted messages", - "warning_kind_search_app": "Use the Desktop app to search encrypted messages" + "warning_kind_search_app": "Use the Desktop app to search encrypted messages", + "warning_kind_search_partial": "Results may be incomplete because your search index is still being built." }, "setting": { "help_about": { diff --git a/apps/web/src/indexing/EventIndex.ts b/apps/web/src/indexing/EventIndex.ts index 2e3539fc5aa..8c81523447e 100644 --- a/apps/web/src/indexing/EventIndex.ts +++ b/apps/web/src/indexing/EventIndex.ts @@ -992,7 +992,10 @@ export default class EventIndex extends EventEmitter { } public crawlingRooms(): { - /** The rooms that we are currently crawling. */ + /** + * The rooms with an outstanding crawler checkpoint: the one being crawled right now, and + * those still queued behind it. + */ crawlingRooms: Set; /** All the encrypted rooms known by the MatrixClient. */ diff --git a/apps/web/test/unit-tests/components/views/elements/SearchWarning-test.tsx b/apps/web/test/unit-tests/components/views/elements/SearchWarning-test.tsx index 117870d7e90..6722bd0dc8a 100644 --- a/apps/web/test/unit-tests/components/views/elements/SearchWarning-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/SearchWarning-test.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 hayaksi1 Copyright 2024 New Vector Ltd. Copyright 2024 The Matrix.org Foundation C.I.C. @@ -6,15 +7,76 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { render } from "jest-matrix-react"; +import { act, render } from "jest-matrix-react"; import React from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; import SdkConfig from "../../../../../src/SdkConfig"; import SearchWarning, { WarningKind } from "../../../../../src/components/views/elements/SearchWarning"; +import EventIndexPeg from "../../../../../src/indexing/EventIndexPeg"; +import { type default as EventIndex } from "../../../../../src/indexing/EventIndex"; +import { SearchScope } from "../../../../../src/Searching"; + +const SEARCHED_ROOM = "!searched:example.org"; +const OTHER_ROOM = "!other:example.org"; +const PARTIAL_WARNING = "Results may be incomplete because your search index is still being built."; + +/** + * A minimal fake EventIndex exposing only the surface that SearchWarning consumes: + * `crawlingRooms()` (the rooms with outstanding crawler checkpoints), `isRoomIndexed()` (whether + * the index holds any events for a room) and the `changedCheckpoint` emitter. + */ +class FakeEventIndex { + private listeners = new Map void>>(); + + /** + * @param crawling Rooms with an outstanding crawler checkpoint. + * @param indexed Rooms the index holds events for. Defaults to `undefined`, meaning + * `isRoomIndexed` resolves `undefined`, as it does when there is no index manager. + */ + public constructor( + private crawling: string[] = [], + private indexed?: string[], + ) {} + + public crawlingRooms(): { crawlingRooms: Set; totalRooms: Set } { + return { crawlingRooms: new Set(this.crawling), totalRooms: new Set(this.crawling) }; + } + + public async isRoomIndexed(roomId: string): Promise { + return this.indexed === undefined ? undefined : this.indexed.includes(roomId); + } + + public on(event: string, listener: (...args: any[]) => void): void { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()); + this.listeners.get(event)!.add(listener); + } + + public removeListener(event: string, listener: (...args: any[]) => void): void { + this.listeners.get(event)?.delete(listener); + } + + /** + * Test helper: simulate the crawler advancing (or finishing, when `crawling` is empty). + * + * The real index emits only the globally-current room, which cannot answer a per-room + * question, so `currentRoom` is passed through purely to prove consumers ignore it. + */ + public emitChangedCheckpoint(crawling: string[], currentRoom: Room | null = null): void { + this.crawling = crawling; + this.listeners.get("changedCheckpoint")?.forEach((listener) => listener(currentRoom)); + } +} describe("", () => { + afterEach(() => { + EventIndexPeg.index = null; + EventIndexPeg.error = undefined; + }); + describe("with desktop builds available", () => { beforeEach(() => { + EventIndexPeg.index = null; SdkConfig.put({ brand: "Element", desktop_builds: { @@ -42,4 +104,292 @@ describe("", () => { expect(asFragment()).toMatchSnapshot(); }); }); + + describe("with the event index present", () => { + const setIndex = (index: FakeEventIndex): void => { + EventIndexPeg.index = index as unknown as EventIndex; + }; + + /** Let the pending isRoomIndexed() lookup settle and React re-render off the back of it. */ + const settle = async (): Promise => { + await act(async () => {}); + }; + + it("warns a room-scoped search while the searched room is still being crawled", async () => { + setIndex(new FakeEventIndex([SEARCHED_ROOM], [])); + + const { queryByText, queryByRole } = render( + , + ); + await settle(); + + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + // The notice appears dynamically while the panel is open, so it must be a live region (#32253). + expect(queryByRole("status")).toBeInTheDocument(); + }); + + it("does not warn a room-scoped search when only an unrelated room is still being crawled", async () => { + setIndex(new FakeEventIndex([OTHER_ROOM], [SEARCHED_ROOM])); + + const { container } = render( + , + ); + await settle(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("warns a room-scoped search when the index holds no events for the searched room", async () => { + // Nothing is queued for the searched room, but it has never been indexed: this is how a + // room looks before its checkpoint has been seeded, and the crawl set alone cannot see + // it. The crawler is still working through another room, so indexing is still under way. + setIndex(new FakeEventIndex([OTHER_ROOM], [])); + + const { queryByText } = render( + , + ); + await settle(); + + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + }); + + it("does not warn about an unindexed room once the crawler has drained", async () => { + // The index holds nothing for the room, but the crawler has no work left, so nothing + // more is coming: the warning would never clear itself, and its claim that the index is + // "still being built" would be untrue. + setIndex(new FakeEventIndex([], [])); + + const { container } = render( + , + ); + await settle(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("keeps an earned warning up while an unrelated checkpoint change is re-checked", async () => { + // The warning here is earned asynchronously: the room is not queued, but the index + // holds nothing for it. An unrelated crawler transition must not blink it off while + // the fresh lookup is in flight. + const index = new FakeEventIndex([OTHER_ROOM], []); + setIndex(index); + + const { queryByText } = render( + , + ); + await settle(); + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + + // The crawler moves on to a third room; nothing about the searched room changed. + act(() => { + index.emitChangedCheckpoint([OTHER_ROOM, "!third:example.org"]); + }); + + // Asserted before the lookup settles: the warning must not have been dropped. + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + + await settle(); + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + }); + + it("treats an unanswerable isRoomIndexed as no evidence for a room-scoped search", async () => { + setIndex(new FakeEventIndex([OTHER_ROOM], undefined)); + + const { container } = render( + , + ); + await settle(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("does not carry a warning from a previously searched room across a scope change", async () => { + // The crawler is working through OTHER_ROOM, so an all-rooms search warns. Switching to + // a room-scoped search of a fully-indexed room must not leave that warning on screen + // while the isRoomIndexed lookup for the new room is in flight. + setIndex(new FakeEventIndex([OTHER_ROOM], [SEARCHED_ROOM])); + + const { queryByText, rerender } = render( + , + ); + await settle(); + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + + rerender( + , + ); + + // Asserted before settling: the stale answer must be gone as soon as the effect for + // the new scope has run, rather than lingering for the isRoomIndexed round-trip. + expect(queryByText(PARTIAL_WARNING)).not.toBeInTheDocument(); + }); + + it("warns an all-rooms search while any room is still being crawled", async () => { + setIndex(new FakeEventIndex([OTHER_ROOM], [])); + + const { queryByText } = render( + , + ); + await settle(); + + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + }); + + it("falls back to the global check when the searched room is not yet known", async () => { + // RoomView can render before a room alias has resolved to an id. + setIndex(new FakeEventIndex([OTHER_ROOM], [])); + + const { queryByText } = render( + , + ); + await settle(); + + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + }); + + it("renders nothing once the index has finished crawling", async () => { + setIndex(new FakeEventIndex([], [])); + + const { container } = render( + , + ); + await settle(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("does not warn for the Files kind even while crawling", async () => { + setIndex(new FakeEventIndex([SEARCHED_ROOM], [])); + + const { container } = render(); + await settle(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("clears the warning when the searched room's checkpoint drains", async () => { + const index = new FakeEventIndex([SEARCHED_ROOM], [SEARCHED_ROOM]); + setIndex(index); + + const { queryByText } = render( + , + ); + await settle(); + + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + + await act(async () => { + index.emitChangedCheckpoint([]); + }); + + expect(queryByText(PARTIAL_WARNING)).not.toBeInTheDocument(); + }); + + it("keeps warning when the crawler moves to another room but the searched room is still queued", async () => { + const index = new FakeEventIndex([SEARCHED_ROOM, OTHER_ROOM], [SEARCHED_ROOM]); + setIndex(index); + + const { queryByText } = render( + , + ); + await settle(); + + // The event payload names a different room; the searched room is still outstanding, so + // the warning must survive. This fails if the handler trusts the payload. + await act(async () => { + index.emitChangedCheckpoint([SEARCHED_ROOM], { roomId: OTHER_ROOM } as Room); + }); + + expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument(); + }); + + it("renders nothing when the room is not encrypted even while crawling", async () => { + setIndex(new FakeEventIndex([SEARCHED_ROOM], [])); + + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("with no event index (web build)", () => { + beforeEach(() => { + EventIndexPeg.index = null; + EventIndexPeg.error = undefined; + SdkConfig.put({ + brand: "Element", + desktop_builds: { + available: true, + logo: "https://logo", + url: "https://url", + }, + }); + }); + + it("still renders the desktop/enable-search warning", () => { + const { container } = render(); + + expect(container.querySelector(".mx_SearchWarning")).toBeInTheDocument(); + expect(container.textContent).toContain("to search encrypted messages"); + // It is the desktop/enable-search affordance, not the partial-index notice. + expect(container.textContent).not.toContain("your search index is still being built"); + }); + }); });