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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 125 additions & 3 deletions apps/web/src/components/views/elements/SearchWarning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
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";
Expand All @@ -26,11 +28,131 @@
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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least based on the jsdoc of this method it doesn't sound like it will be all uncrawled rooms, just rooms which are currently being crawled, no? Maybe combining the check with isRoomIndexed would be better. I.e room is not in crawlingRooms && isRoomIndexed(room)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

though then also worth updating the callback name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the first half, though, crawlingRooms() is broader than its jsdoc suggests, and I think the doc is what misled you. It adds every queued checkpoint, not just the one in flight:

this.crawlerCheckpoints.forEach((checkpoint) => {
    crawlingRooms.add(checkpoint.roomId);
});
if (this.currentCheckpoint !== null) {
    crawlingRooms.add(this.currentCheckpoint.roomId);
}

crawlerCheckpoints is the pending queue, so a room sitting fifth in line is already in the set — it does cover "yet to be indexed", just not under that name. The inline doc on the return type says "The rooms that we are currently crawling", which reads as current-only. I've reworded it to "the rooms with an outstanding crawler checkpoint: the one being crawled right now, and those still queued behind it" — one line in EventIndex.ts, since it caused the misread and would cause the next one. Happy to drop it if you'd rather keep this PR to two files.

Your suggestion still closes a real gap, so I've taken it. What the crawl set genuinely can't see is a room with no checkpoint at all — before addInitialCheckpoints() seeds them, such a room is absent from the set and looks identical to "done". That's the issue's own repro ("log into a new desktop session"), which the previous version would have sat silent through. So room-scoped is now has(roomId) || !isRoomIndexed(roomId), i.e. your not in crawlingRooms && isRoomIndexed(room) inverted.

One wrinkle I hit implementing it, which changed the shape slightly. isRoomIndexed has no change notification: changedCheckpoint is the only event EventIndex emits, and it fires solely on checkpoint transitions inside crawlerFunc — adding events to the index emits nothing, and an idle crawler is silent (crawlerCheckpoints.shift()undefinedidle = true; continue, no emit). So a warning raised purely from !isRoomIndexed once the crawler has drained would never be re-evaluated and would stick for the session.

So I only ask the isRoomIndexed question while the crawler still has work outstanding:

if (relevant || !anyOutstanding || scope !== SearchScope.Room || roomId === undefined) return;
const indexed = await index.isRoomIndexed(roomId);

That keeps the case you're pointing at (during the initial crawl, my room isn't seeded yet → warn) and drops the stuck one (crawler idle → nothing more is coming, and claiming the index is "still being built" would be untrue). It also self-clears: the crawler emits when it nulls currentCheckpoint on drain, so we get a final event to re-evaluate on. There's a test pinning it — removing the !anyOutstanding guard turns it red.

Three smaller notes:

  • isRoomIndexed is presence-only, so it can't be a drop-in &&. Its contract is "the index contains events for the given room" — ≥1 event, not complete — so it's a floor ("definitely not started"), not a completeness check. I've documented it as that rather than implying more.
  • It returns boolean | undefined (indexManager?.isRoomIndexed(...)); undefined means there's no manager to ask, which isn't evidence either way. So it warns on === false, not !indexed, with a test for it.
  • All-rooms stays on the checkpoint set — per-room isRoomIndexed across every encrypted room would be an IPC round-trip each, per changedCheckpoint.

Renamed as you suggested: useIsCrawlInProgressuseIsIndexIncomplete. The old name described the old, narrower question. The checkpoint half stays synchronous and seeds the initial state, so the common "actively crawling" case renders without a flash; only the never-indexed refinement awaits, and the state is re-seeded from the checkpoint set on every scope/room change so a previous room's answer can't sit on screen while the lookup is in flight (also tested). A generation counter drops any response the scope has moved on from.

// 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<boolean>(() => 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<void> => {
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 {

Check warning on line 136 in apps/web/src/components/views/elements/SearchWarning.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9hZ4fizudzWchVit_s&open=AZ9hZ4fizudzWchVit_s&pullRequest=34001
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 (
<div className="mx_SearchWarning" role="status">

Check warning on line 149 in apps/web/src/components/views/elements/SearchWarning.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ8IH8QgDyical_VRz3c&open=AZ8IH8QgDyical_VRz3c&pullRequest=34001
<span>{_t("seshat|warning_kind_search_partial")}</span>
</div>
);
}
return <></>;
}

if (EventIndexPeg.error) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSe
) : (
<InlineSpinner />
)}
<SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} />
<SearchWarning
kind={WarningKind.Search}
isRoomEncrypted={isRoomEncrypted}
showLogo={false}
scope={scope}
roomId={searchInfo?.roomId}
/>
</div>
</div>
<div className="mx_RoomSearchAuxPanel_buttons">
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a>Desktop app</a> 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 <a>Desktop app</a> to search encrypted messages"
"warning_kind_search_app": "Use the <a>Desktop app</a> to search encrypted messages",
"warning_kind_search_partial": "Results may be incomplete because your search index is still being built."
},
"setting": {
"help_about": {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/indexing/EventIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;

/** All the encrypted rooms known by the MatrixClient. */
Expand Down
Loading
Loading