Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit f150780

Browse files
t3chguyAmy Walker
authored andcommitted
Extract Search handling from RoomView into its own Component (#9574)
* Extract Search handling from RoomView into its own Component * Iterate * Fix types * Add tests * Increase coverage * Simplify test * Improve coverage
1 parent bc388aa commit f150780

File tree

9 files changed

+689
-293
lines changed

9 files changed

+689
-293
lines changed

src/Searching.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const SEARCH_LIMIT = 10;
3535
async function serverSideSearch(
3636
term: string,
3737
roomId: string = undefined,
38+
abortSignal?: AbortSignal,
3839
): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
3940
const client = MatrixClientPeg.get();
4041

@@ -59,19 +60,24 @@ async function serverSideSearch(
5960
},
6061
};
6162

62-
const response = await client.search({ body: body });
63+
const response = await client.search({ body: body }, abortSignal);
6364

6465
return { response, query: body };
6566
}
6667

67-
async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise<ISearchResults> {
68+
async function serverSideSearchProcess(
69+
term: string,
70+
roomId: string = undefined,
71+
abortSignal?: AbortSignal,
72+
): Promise<ISearchResults> {
6873
const client = MatrixClientPeg.get();
69-
const result = await serverSideSearch(term, roomId);
74+
const result = await serverSideSearch(term, roomId, abortSignal);
7075

7176
// The js-sdk method backPaginateRoomEventsSearch() uses _query internally
7277
// so we're reusing the concept here since we want to delegate the
7378
// pagination back to backPaginateRoomEventsSearch() in some cases.
7479
const searchResults: ISearchResults = {
80+
abortSignal,
7581
_query: result.query,
7682
results: [],
7783
highlights: [],
@@ -90,12 +96,12 @@ function compareEvents(a: ISearchResult, b: ISearchResult): number {
9096
return 0;
9197
}
9298

93-
async function combinedSearch(searchTerm: string): Promise<ISearchResults> {
99+
async function combinedSearch(searchTerm: string, abortSignal?: AbortSignal): Promise<ISearchResults> {
94100
const client = MatrixClientPeg.get();
95101

96102
// Create two promises, one for the local search, one for the
97103
// server-side search.
98-
const serverSidePromise = serverSideSearch(searchTerm);
104+
const serverSidePromise = serverSideSearch(searchTerm, undefined, abortSignal);
99105
const localPromise = localSearch(searchTerm);
100106

101107
// Wait for both promises to resolve.
@@ -575,7 +581,11 @@ async function combinedPagination(searchResult: ISeshatSearchResults): Promise<I
575581
return result;
576582
}
577583

578-
function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
584+
function eventIndexSearch(
585+
term: string,
586+
roomId: string = undefined,
587+
abortSignal?: AbortSignal,
588+
): Promise<ISearchResults> {
579589
let searchPromise: Promise<ISearchResults>;
580590

581591
if (roomId !== undefined) {
@@ -586,12 +596,12 @@ function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISe
586596
} else {
587597
// The search is for a single non-encrypted room, use the
588598
// server-side search.
589-
searchPromise = serverSideSearchProcess(term, roomId);
599+
searchPromise = serverSideSearchProcess(term, roomId, abortSignal);
590600
}
591601
} else {
592602
// Search across all rooms, combine a server side search and a
593603
// local search.
594-
searchPromise = combinedSearch(term);
604+
searchPromise = combinedSearch(term, abortSignal);
595605
}
596606

597607
return searchPromise;
@@ -633,9 +643,16 @@ export function searchPagination(searchResult: ISearchResults): Promise<ISearchR
633643
else return eventIndexSearchPagination(searchResult);
634644
}
635645

636-
export default function eventSearch(term: string, roomId: string = undefined): Promise<ISearchResults> {
646+
export default function eventSearch(
647+
term: string,
648+
roomId: string = undefined,
649+
abortSignal?: AbortSignal,
650+
): Promise<ISearchResults> {
637651
const eventIndex = EventIndexPeg.get();
638652

639-
if (eventIndex === null) return serverSideSearchProcess(term, roomId);
640-
else return eventIndexSearch(term, roomId);
653+
if (eventIndex === null) {
654+
return serverSideSearchProcess(term, roomId, abortSignal);
655+
} else {
656+
return eventIndexSearch(term, roomId, abortSignal);
657+
}
641658
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/*
2+
Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { forwardRef, RefObject, useCallback, useContext, useEffect, useRef, useState } from 'react';
18+
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
19+
import { IThreadBundledRelationship } from 'matrix-js-sdk/src/models/event';
20+
import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread';
21+
import { logger } from "matrix-js-sdk/src/logger";
22+
23+
import ScrollPanel from "./ScrollPanel";
24+
import { SearchScope } from "../views/rooms/SearchBar";
25+
import Spinner from "../views/elements/Spinner";
26+
import { _t } from "../../languageHandler";
27+
import { haveRendererForEvent } from "../../events/EventTileFactory";
28+
import SearchResultTile from "../views/rooms/SearchResultTile";
29+
import { searchPagination } from "../../Searching";
30+
import Modal from "../../Modal";
31+
import ErrorDialog from "../views/dialogs/ErrorDialog";
32+
import ResizeNotifier from "../../utils/ResizeNotifier";
33+
import MatrixClientContext from "../../contexts/MatrixClientContext";
34+
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
35+
import RoomContext from "../../contexts/RoomContext";
36+
37+
const DEBUG = false;
38+
let debuglog = function(msg: string) {};
39+
40+
/* istanbul ignore next */
41+
if (DEBUG) {
42+
// using bind means that we get to keep useful line numbers in the console
43+
debuglog = logger.log.bind(console);
44+
}
45+
46+
interface Props {
47+
term: string;
48+
scope: SearchScope;
49+
promise: Promise<ISearchResults>;
50+
abortController?: AbortController;
51+
resizeNotifier: ResizeNotifier;
52+
permalinkCreator: RoomPermalinkCreator;
53+
className: string;
54+
onUpdate(inProgress: boolean, results: ISearchResults | null): void;
55+
}
56+
57+
// XXX: todo: merge overlapping results somehow?
58+
// XXX: why doesn't searching on name work?
59+
export const RoomSearchView = forwardRef<ScrollPanel, Props>(({
60+
term,
61+
scope,
62+
promise,
63+
abortController,
64+
resizeNotifier,
65+
permalinkCreator,
66+
className,
67+
onUpdate,
68+
}: Props, ref: RefObject<ScrollPanel>) => {
69+
const client = useContext(MatrixClientContext);
70+
const roomContext = useContext(RoomContext);
71+
const [inProgress, setInProgress] = useState(true);
72+
const [highlights, setHighlights] = useState<string[] | null>(null);
73+
const [results, setResults] = useState<ISearchResults | null>(null);
74+
const aborted = useRef(false);
75+
76+
const handleSearchResult = useCallback((searchPromise: Promise<ISearchResults>): Promise<boolean> => {
77+
setInProgress(true);
78+
79+
return searchPromise.then(async (results) => {
80+
debuglog("search complete");
81+
if (aborted.current) {
82+
logger.error("Discarding stale search results");
83+
return false;
84+
}
85+
86+
// postgres on synapse returns us precise details of the strings
87+
// which actually got matched for highlighting.
88+
//
89+
// In either case, we want to highlight the literal search term
90+
// whether it was used by the search engine or not.
91+
92+
let highlights = results.highlights;
93+
if (!highlights.includes(term)) {
94+
highlights = highlights.concat(term);
95+
}
96+
97+
// For overlapping highlights,
98+
// favour longer (more specific) terms first
99+
highlights = highlights.sort(function(a, b) {
100+
return b.length - a.length;
101+
});
102+
103+
if (client.supportsExperimentalThreads()) {
104+
// Process all thread roots returned in this batch of search results
105+
// XXX: This won't work for results coming from Seshat which won't include the bundled relationship
106+
for (const result of results.results) {
107+
for (const event of result.context.getTimeline()) {
108+
const bundledRelationship = event
109+
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
110+
if (!bundledRelationship || event.getThread()) continue;
111+
const room = client.getRoom(event.getRoomId());
112+
const thread = room.findThreadForEvent(event);
113+
if (thread) {
114+
event.setThread(thread);
115+
} else {
116+
room.createThread(event.getId(), event, [], true);
117+
}
118+
}
119+
}
120+
}
121+
122+
setHighlights(highlights);
123+
setResults({ ...results }); // copy to force a refresh
124+
}, (error) => {
125+
if (aborted.current) {
126+
logger.error("Discarding stale search results");
127+
return false;
128+
}
129+
logger.error("Search failed", error);
130+
Modal.createDialog(ErrorDialog, {
131+
title: _t("Search failed"),
132+
description: error?.message
133+
?? _t("Server may be unavailable, overloaded, or search timed out :("),
134+
});
135+
return false;
136+
}).finally(() => {
137+
setInProgress(false);
138+
});
139+
}, [client, term]);
140+
141+
// Mount & unmount effect
142+
useEffect(() => {
143+
aborted.current = false;
144+
handleSearchResult(promise);
145+
return () => {
146+
aborted.current = true;
147+
abortController?.abort();
148+
};
149+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
150+
151+
// show searching spinner
152+
if (results?.count === undefined) {
153+
return (
154+
<div
155+
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
156+
data-testid="messagePanelSearchSpinner"
157+
/>
158+
);
159+
}
160+
161+
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
162+
if (!backwards) {
163+
return false;
164+
}
165+
166+
if (!results.next_batch) {
167+
debuglog("no more search results");
168+
return false;
169+
}
170+
171+
debuglog("requesting more search results");
172+
const searchPromise = searchPagination(results);
173+
return handleSearchResult(searchPromise);
174+
};
175+
176+
const ret: JSX.Element[] = [];
177+
178+
if (inProgress) {
179+
ret.push(<li key="search-spinner">
180+
<Spinner />
181+
</li>);
182+
}
183+
184+
if (!results.next_batch) {
185+
if (!results?.results?.length) {
186+
ret.push(<li key="search-top-marker">
187+
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
188+
</li>);
189+
} else {
190+
ret.push(<li key="search-top-marker">
191+
<h2 className="mx_RoomView_topMarker">{ _t("No more results") }</h2>
192+
</li>);
193+
}
194+
}
195+
196+
// once dynamic content in the search results load, make the scrollPanel check
197+
// the scroll offsets.
198+
const onHeightChanged = () => {
199+
const scrollPanel = ref.current;
200+
scrollPanel?.checkScroll();
201+
};
202+
203+
let lastRoomId: string;
204+
205+
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
206+
const result = results.results[i];
207+
208+
const mxEv = result.context.getEvent();
209+
const roomId = mxEv.getRoomId();
210+
const room = client.getRoom(roomId);
211+
if (!room) {
212+
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
213+
// As per the spec, an all rooms search can create this condition,
214+
// it happens with Seshat but not Synapse.
215+
// It will make the result count not match the displayed count.
216+
logger.log("Hiding search result from an unknown room", roomId);
217+
continue;
218+
}
219+
220+
if (!haveRendererForEvent(mxEv, roomContext.showHiddenEvents)) {
221+
// XXX: can this ever happen? It will make the result count
222+
// not match the displayed count.
223+
continue;
224+
}
225+
226+
if (scope === SearchScope.All) {
227+
if (roomId !== lastRoomId) {
228+
ret.push(<li key={mxEv.getId() + "-room"}>
229+
<h2>{ _t("Room") }: { room.name }</h2>
230+
</li>);
231+
lastRoomId = roomId;
232+
}
233+
}
234+
235+
const resultLink = "#/room/"+roomId+"/"+mxEv.getId();
236+
237+
ret.push(<SearchResultTile
238+
key={mxEv.getId()}
239+
searchResult={result}
240+
searchHighlights={highlights}
241+
resultLink={resultLink}
242+
permalinkCreator={permalinkCreator}
243+
onHeightChanged={onHeightChanged}
244+
/>);
245+
}
246+
247+
return (
248+
<ScrollPanel
249+
ref={ref}
250+
className={"mx_RoomView_searchResultsPanel " + className}
251+
onFillRequest={onSearchResultsFillRequest}
252+
resizeNotifier={resizeNotifier}
253+
>
254+
<li className="mx_RoomView_scrollheader" />
255+
{ ret }
256+
</ScrollPanel>
257+
);
258+
});

0 commit comments

Comments
 (0)