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
pull/28788/head^2
Michael Telatynski 2022-11-18 16:40:22 +00:00 committed by GitHub
parent cd46c89699
commit d626f71fdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 690 additions and 294 deletions

View File

@ -35,6 +35,7 @@ const SEARCH_LIMIT = 10;
async function serverSideSearch( async function serverSideSearch(
term: string, term: string,
roomId: string = undefined, roomId: string = undefined,
abortSignal?: AbortSignal,
): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> { ): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -59,19 +60,24 @@ async function serverSideSearch(
}, },
}; };
const response = await client.search({ body: body }); const response = await client.search({ body: body }, abortSignal);
return { response, query: body }; return { response, query: body };
} }
async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise<ISearchResults> { async function serverSideSearchProcess(
term: string,
roomId: string = undefined,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const result = await serverSideSearch(term, roomId); const result = await serverSideSearch(term, roomId, abortSignal);
// The js-sdk method backPaginateRoomEventsSearch() uses _query internally // The js-sdk method backPaginateRoomEventsSearch() uses _query internally
// so we're reusing the concept here since we want to delegate the // so we're reusing the concept here since we want to delegate the
// pagination back to backPaginateRoomEventsSearch() in some cases. // pagination back to backPaginateRoomEventsSearch() in some cases.
const searchResults: ISearchResults = { const searchResults: ISearchResults = {
abortSignal,
_query: result.query, _query: result.query,
results: [], results: [],
highlights: [], highlights: [],
@ -90,12 +96,12 @@ function compareEvents(a: ISearchResult, b: ISearchResult): number {
return 0; return 0;
} }
async function combinedSearch(searchTerm: string): Promise<ISearchResults> { async function combinedSearch(searchTerm: string, abortSignal?: AbortSignal): Promise<ISearchResults> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
// Create two promises, one for the local search, one for the // Create two promises, one for the local search, one for the
// server-side search. // server-side search.
const serverSidePromise = serverSideSearch(searchTerm); const serverSidePromise = serverSideSearch(searchTerm, undefined, abortSignal);
const localPromise = localSearch(searchTerm); const localPromise = localSearch(searchTerm);
// Wait for both promises to resolve. // Wait for both promises to resolve.
@ -575,7 +581,11 @@ async function combinedPagination(searchResult: ISeshatSearchResults): Promise<I
return result; return result;
} }
function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISearchResults> { function eventIndexSearch(
term: string,
roomId: string = undefined,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
let searchPromise: Promise<ISearchResults>; let searchPromise: Promise<ISearchResults>;
if (roomId !== undefined) { if (roomId !== undefined) {
@ -586,12 +596,12 @@ function eventIndexSearch(term: string, roomId: string = undefined): Promise<ISe
} else { } else {
// The search is for a single non-encrypted room, use the // The search is for a single non-encrypted room, use the
// server-side search. // server-side search.
searchPromise = serverSideSearchProcess(term, roomId); searchPromise = serverSideSearchProcess(term, roomId, abortSignal);
} }
} else { } else {
// Search across all rooms, combine a server side search and a // Search across all rooms, combine a server side search and a
// local search. // local search.
searchPromise = combinedSearch(term); searchPromise = combinedSearch(term, abortSignal);
} }
return searchPromise; return searchPromise;
@ -633,9 +643,16 @@ export function searchPagination(searchResult: ISearchResults): Promise<ISearchR
else return eventIndexSearchPagination(searchResult); else return eventIndexSearchPagination(searchResult);
} }
export default function eventSearch(term: string, roomId: string = undefined): Promise<ISearchResults> { export default function eventSearch(
term: string,
roomId: string = undefined,
abortSignal?: AbortSignal,
): Promise<ISearchResults> {
const eventIndex = EventIndexPeg.get(); const eventIndex = EventIndexPeg.get();
if (eventIndex === null) return serverSideSearchProcess(term, roomId); if (eventIndex === null) {
else return eventIndexSearch(term, roomId); return serverSideSearchProcess(term, roomId, abortSignal);
} else {
return eventIndexSearch(term, roomId, abortSignal);
}
} }

View File

@ -0,0 +1,258 @@
/*
Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, RefObject, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import { IThreadBundledRelationship } from 'matrix-js-sdk/src/models/event';
import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread';
import { logger } from "matrix-js-sdk/src/logger";
import ScrollPanel from "./ScrollPanel";
import { SearchScope } from "../views/rooms/SearchBar";
import Spinner from "../views/elements/Spinner";
import { _t } from "../../languageHandler";
import { haveRendererForEvent } from "../../events/EventTileFactory";
import SearchResultTile from "../views/rooms/SearchResultTile";
import { searchPagination } from "../../Searching";
import Modal from "../../Modal";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import ResizeNotifier from "../../utils/ResizeNotifier";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import RoomContext from "../../contexts/RoomContext";
const DEBUG = false;
let debuglog = function(msg: string) {};
/* istanbul ignore next */
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
debuglog = logger.log.bind(console);
}
interface Props {
term: string;
scope: SearchScope;
promise: Promise<ISearchResults>;
abortController?: AbortController;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
className: string;
onUpdate(inProgress: boolean, results: ISearchResults | null): void;
}
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
export const RoomSearchView = forwardRef<ScrollPanel, Props>(({
term,
scope,
promise,
abortController,
resizeNotifier,
permalinkCreator,
className,
onUpdate,
}: Props, ref: RefObject<ScrollPanel>) => {
const client = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const [inProgress, setInProgress] = useState(true);
const [highlights, setHighlights] = useState<string[] | null>(null);
const [results, setResults] = useState<ISearchResults | null>(null);
const aborted = useRef(false);
const handleSearchResult = useCallback((searchPromise: Promise<ISearchResults>): Promise<boolean> => {
setInProgress(true);
return searchPromise.then(async (results) => {
debuglog("search complete");
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
// postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting.
//
// In either case, we want to highlight the literal search term
// whether it was used by the search engine or not.
let highlights = results.highlights;
if (!highlights.includes(term)) {
highlights = highlights.concat(term);
}
// For overlapping highlights,
// favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) {
return b.length - a.length;
});
if (client.supportsExperimentalThreads()) {
// Process all thread roots returned in this batch of search results
// XXX: This won't work for results coming from Seshat which won't include the bundled relationship
for (const result of results.results) {
for (const event of result.context.getTimeline()) {
const bundledRelationship = event
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
if (!bundledRelationship || event.getThread()) continue;
const room = client.getRoom(event.getRoomId());
const thread = room.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
} else {
room.createThread(event.getId(), event, [], true);
}
}
}
}
setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh
}, (error) => {
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
logger.error("Search failed", error);
Modal.createDialog(ErrorDialog, {
title: _t("Search failed"),
description: error?.message
?? _t("Server may be unavailable, overloaded, or search timed out :("),
});
return false;
}).finally(() => {
setInProgress(false);
});
}, [client, term]);
// Mount & unmount effect
useEffect(() => {
aborted.current = false;
handleSearchResult(promise);
return () => {
aborted.current = true;
abortController?.abort();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// show searching spinner
if (results?.count === undefined) {
return (
<div
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
data-testid="messagePanelSearchSpinner"
/>
);
}
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
if (!backwards) {
return false;
}
if (!results.next_batch) {
debuglog("no more search results");
return false;
}
debuglog("requesting more search results");
const searchPromise = searchPagination(results);
return handleSearchResult(searchPromise);
};
const ret: JSX.Element[] = [];
if (inProgress) {
ret.push(<li key="search-spinner">
<Spinner />
</li>);
}
if (!results.next_batch) {
if (!results?.results?.length) {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
</li>);
} else {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No more results") }</h2>
</li>);
}
}
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onHeightChanged = () => {
const scrollPanel = ref.current;
scrollPanel?.checkScroll();
};
let lastRoomId: string;
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
const result = results.results[i];
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId();
const room = client.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// As per the spec, an all rooms search can create this condition,
// it happens with Seshat but not Synapse.
// It will make the result count not match the displayed count.
logger.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveRendererForEvent(mxEv, roomContext.showHiddenEvents)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (scope === SearchScope.All) {
if (roomId !== lastRoomId) {
ret.push(<li key={mxEv.getId() + "-room"}>
<h2>{ _t("Room") }: { room.name }</h2>
</li>);
lastRoomId = roomId;
}
}
const resultLink = "#/room/"+roomId+"/"+mxEv.getId();
ret.push(<SearchResultTile
key={mxEv.getId()}
searchResult={result}
searchHighlights={highlights}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
onHeightChanged={onHeightChanged}
/>);
}
return (
<ScrollPanel
ref={ref}
className={"mx_RoomView_searchResultsPanel " + className}
onFillRequest={onSearchResultsFillRequest}
resizeNotifier={resizeNotifier}
>
<li className="mx_RoomView_scrollheader" />
{ ret }
</ScrollPanel>
);
});

View File

@ -17,14 +17,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// TODO: This component is enormous! There's several things which could stand-alone:
// - Search results component
import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from 'react'; import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import { EventType } from 'matrix-js-sdk/src/@types/event'; import { EventType } from 'matrix-js-sdk/src/@types/event';
@ -37,6 +33,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client";
import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread';
import { HistoryVisibility } from 'matrix-js-sdk/src/@types/partials'; import { HistoryVisibility } from 'matrix-js-sdk/src/@types/partials';
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import shouldHideEvent from '../../shouldHideEvent'; import shouldHideEvent from '../../shouldHideEvent';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
@ -47,7 +44,6 @@ import Modal from '../../Modal';
import { LegacyCallHandlerEvent } from '../../LegacyCallHandler'; import { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import dis, { defaultDispatcher } from '../../dispatcher/dispatcher'; import dis, { defaultDispatcher } from '../../dispatcher/dispatcher';
import * as Rooms from '../../Rooms'; import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
import MainSplit from './MainSplit'; import MainSplit from './MainSplit';
import RightPanel from './RightPanel'; import RightPanel from './RightPanel';
import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore';
@ -67,8 +63,7 @@ import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
import SearchBar, { SearchScope } from "../views/rooms/SearchBar"; import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader, { ISearchInfo } from "../views/rooms/RoomHeader";
import { XOR } from "../../@types/common";
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay"; import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils'; import { containsEmoji } from '../../effects/utils';
@ -84,8 +79,6 @@ import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom"; import { IOpts } from "../../createRoom";
import EditorStateTransfer from "../../utils/EditorStateTransfer"; import EditorStateTransfer from "../../utils/EditorStateTransfer";
import ErrorDialog from '../views/dialogs/ErrorDialog'; import ErrorDialog from '../views/dialogs/ErrorDialog';
import SearchResultTile from '../views/rooms/SearchResultTile';
import Spinner from "../views/elements/Spinner";
import UploadBar from './UploadBar'; import UploadBar from './UploadBar';
import RoomStatusBar from "./RoomStatusBar"; import RoomStatusBar from "./RoomStatusBar";
import MessageComposer from '../views/rooms/MessageComposer'; import MessageComposer from '../views/rooms/MessageComposer';
@ -103,7 +96,6 @@ import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyn
import FileDropTarget from './FileDropTarget'; import FileDropTarget from './FileDropTarget';
import Measured from '../views/elements/Measured'; import Measured from '../views/elements/Measured';
import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPayload'; import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPayload';
import { haveRendererForEvent } from "../../events/EventTileFactory";
import { LocalRoom, LocalRoomState } from '../../models/LocalRoom'; import { LocalRoom, LocalRoomState } from '../../models/LocalRoom';
import { createRoomFromLocalRoom } from '../../utils/direct-messages'; import { createRoomFromLocalRoom } from '../../utils/direct-messages';
import NewRoomIntro from '../views/rooms/NewRoomIntro'; import NewRoomIntro from '../views/rooms/NewRoomIntro';
@ -117,12 +109,15 @@ import { isVideoRoom } from '../../utils/video-rooms';
import { SDKContext } from '../../contexts/SDKContext'; import { SDKContext } from '../../contexts/SDKContext';
import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { Call } from "../../models/Call"; import { Call } from "../../models/Call";
import { RoomSearchView } from './RoomSearchView';
import eventSearch from "../../Searching";
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
const BROWSER_SUPPORTS_SANDBOX = 'sandbox' in document.createElement('iframe'); const BROWSER_SUPPORTS_SANDBOX = 'sandbox' in document.createElement('iframe');
/* istanbul ignore next */
if (DEBUG) { if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
debuglog = logger.log.bind(console); debuglog = logger.log.bind(console);
@ -168,11 +163,7 @@ export interface IRoomState {
initialEventScrollIntoView?: boolean; initialEventScrollIntoView?: boolean;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
numUnreadMessages: number; numUnreadMessages: number;
searchTerm?: string; search?: ISearchInfo;
searchScope?: SearchScope;
searchResults?: XOR<{}, ISearchResults>;
searchHighlights?: string[];
searchInProgress?: boolean;
callState?: CallState; callState?: CallState;
activeCall: Call | null; activeCall: Call | null;
canPeek: boolean; canPeek: boolean;
@ -368,7 +359,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private unmounted = false; private unmounted = false;
private permalinkCreators: Record<string, RoomPermalinkCreator> = {}; private permalinkCreators: Record<string, RoomPermalinkCreator> = {};
private searchId: number;
private roomView = createRef<HTMLElement>(); private roomView = createRef<HTMLElement>();
private searchResultsPanel = createRef<ScrollPanel>(); private searchResultsPanel = createRef<ScrollPanel>();
@ -389,7 +379,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
shouldPeek: true, shouldPeek: true,
membersLoaded: !llMembers, membersLoaded: !llMembers,
numUnreadMessages: 0, numUnreadMessages: 0,
searchResults: null,
callState: null, callState: null,
activeCall: null, activeCall: null,
canPeek: false, canPeek: false,
@ -1025,7 +1014,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
break; break;
case 'reply_to_event': case 'reply_to_event':
if (!this.unmounted && if (!this.unmounted &&
this.state.searchResults && this.state.search &&
payload.event?.getRoomId() === this.state.roomId && payload.event?.getRoomId() === this.state.roomId &&
payload.context === TimelineRenderingType.Search payload.context === TimelineRenderingType.Search
) { ) {
@ -1140,10 +1129,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (ev.getSender() !== this.context.client.credentials.userId) { if (ev.getSender() !== this.context.client.credentials.userId) {
// update unread count when scrolled up // update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { if (!this.state.search && this.state.atEndOfLiveTimeline) {
// no change // no change
} else if (!shouldHideEvent(ev, this.state)) { } else if (!shouldHideEvent(ev, this.state)) {
this.setState((state, props) => { this.setState((state) => {
return { numUnreadMessages: state.numUnreadMessages + 1 }; return { numUnreadMessages: state.numUnreadMessages + 1 };
}); });
} }
@ -1408,21 +1397,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} }
} }
private onSearchResultsFillRequest = (backwards: boolean): Promise<boolean> => {
if (!backwards) {
return Promise.resolve(false);
}
if (this.state.searchResults.next_batch) {
debuglog("requesting more search results");
const searchPromise = searchPagination(this.state.searchResults as ISearchResults);
return this.handleSearchResult(searchPromise);
} else {
debuglog("no more search results");
return Promise.resolve(false);
}
};
private onInviteClick = () => { private onInviteClick = () => {
// open the room inviter // open the room inviter
dis.dispatch({ dis.dispatch({
@ -1506,187 +1480,34 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} }
private onSearch = (term: string, scope: SearchScope) => { private onSearch = (term: string, scope: SearchScope) => {
this.setState({ const roomId = scope === SearchScope.Room ? this.state.room.roomId : undefined;
searchTerm: term,
searchScope: scope,
searchResults: {},
searchHighlights: [],
});
// if we already have a search panel, we need to tell it to forget
// about its scroll state.
if (this.searchResultsPanel.current) {
this.searchResultsPanel.current.resetScrollState();
}
// make sure that we don't end up showing results from
// an aborted search by keeping a unique id.
//
// todo: should cancel any previous search requests.
this.searchId = new Date().getTime();
let roomId;
if (scope === SearchScope.Room) roomId = this.state.room.roomId;
debuglog("sending search request"); debuglog("sending search request");
const searchPromise = eventSearch(term, roomId); const abortController = new AbortController();
this.handleSearchResult(searchPromise); const promise = eventSearch(term, roomId, abortController.signal);
this.setState({
search: {
// make sure that we don't end up showing results from
// an aborted search by keeping a unique id.
searchId: new Date().getTime(),
roomId,
term,
scope,
promise,
abortController,
},
});
}; };
private handleSearchResult(searchPromise: Promise<ISearchResults>): Promise<boolean> { private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => {
// keep a record of the current search id, so that if the search terms
// change before we get a response, we can ignore the results.
const localSearchId = this.searchId;
this.setState({ this.setState({
searchInProgress: true, search: {
...this.state.search,
count: searchResults?.count,
inProgress,
},
}); });
};
return searchPromise.then(async (results) => {
debuglog("search complete");
if (this.unmounted ||
this.state.timelineRenderingType !== TimelineRenderingType.Search ||
this.searchId != localSearchId
) {
logger.error("Discarding stale search results");
return false;
}
// postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting.
//
// In either case, we want to highlight the literal search term
// whether it was used by the search engine or not.
let highlights = results.highlights;
if (highlights.indexOf(this.state.searchTerm) < 0) {
highlights = highlights.concat(this.state.searchTerm);
}
// For overlapping highlights,
// favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) {
return b.length - a.length;
});
if (this.context.client.supportsExperimentalThreads()) {
// Process all thread roots returned in this batch of search results
// XXX: This won't work for results coming from Seshat which won't include the bundled relationship
for (const result of results.results) {
for (const event of result.context.getTimeline()) {
const bundledRelationship = event
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
if (!bundledRelationship || event.getThread()) continue;
const room = this.context.client.getRoom(event.getRoomId());
const thread = room.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
} else {
room.createThread(event.getId(), event, [], true);
}
}
}
}
this.setState({
searchHighlights: highlights,
searchResults: results,
});
}, (error) => {
logger.error("Search failed", error);
Modal.createDialog(ErrorDialog, {
title: _t("Search failed"),
description: ((error && error.message) ? error.message :
_t("Server may be unavailable, overloaded, or search timed out :(")),
});
return false;
}).finally(() => {
this.setState({
searchInProgress: false,
});
});
}
private getSearchResultTiles() {
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
const ret = [];
if (this.state.searchInProgress) {
ret.push(<li key="search-spinner">
<Spinner />
</li>);
}
if (!this.state.searchResults.next_batch) {
if (!this.state.searchResults?.results?.length) {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No results") }</h2>
</li>,
);
} else {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{ _t("No more results") }</h2>
</li>,
);
}
}
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onHeightChanged = () => {
const scrollPanel = this.searchResultsPanel.current;
if (scrollPanel) {
scrollPanel.checkScroll();
}
};
let lastRoomId;
for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) {
const result = this.state.searchResults.results[i];
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId();
const room = this.context.client.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// As per the spec, an all rooms search can create this condition,
// it happens with Seshat but not Synapse.
// It will make the result count not match the displayed count.
logger.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveRendererForEvent(mxEv, this.state.showHiddenEvents)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (this.state.searchScope === 'All') {
if (roomId !== lastRoomId) {
ret.push(<li key={mxEv.getId() + "-room"}>
<h2>{ _t("Room") }: { room.name }</h2>
</li>);
lastRoomId = roomId;
}
}
const resultLink = "#/room/"+roomId+"/"+mxEv.getId();
ret.push(<SearchResultTile
key={mxEv.getId()}
searchResult={result}
searchHighlights={this.state.searchHighlights}
resultLink={resultLink}
permalinkCreator={this.permalinkCreator}
onHeightChanged={onHeightChanged}
/>);
}
return ret;
}
private onAppsClick = () => { private onAppsClick = () => {
dis.dispatch({ dis.dispatch({
@ -1780,7 +1601,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
this.setState({ this.setState({
timelineRenderingType: TimelineRenderingType.Room, timelineRenderingType: TimelineRenderingType.Room,
searchResults: null, search: null,
}, resolve); }, resolve);
}); });
}; };
@ -1890,9 +1711,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
panel = this.messagePanel; panel = this.messagePanel;
} }
if (panel) { panel?.handleScrollKey(ev);
panel.handleScrollKey(ev);
}
}; };
/** /**
@ -2116,16 +1935,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} }
} }
const scrollheaderClasses = classNames({
mx_RoomView_scrollheader: true,
});
let statusBar; let statusBar;
let isStatusAreaExpanded = true; let isStatusAreaExpanded = true;
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
statusBar = <UploadBar room={this.state.room} />; statusBar = <UploadBar room={this.state.room} />;
} else if (!this.state.searchResults) { } else if (!this.state.search) {
isStatusAreaExpanded = this.state.statusBarVisible; isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
@ -2162,7 +1977,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
let previewBar; let previewBar;
if (this.state.timelineRenderingType === TimelineRenderingType.Search) { if (this.state.timelineRenderingType === TimelineRenderingType.Search) {
aux = <SearchBar aux = <SearchBar
searchInProgress={this.state.searchInProgress} searchInProgress={this.state.search?.inProgress}
onCancelClick={this.onCancelSearchClick} onCancelClick={this.onCancelSearchClick}
onSearch={this.onSearch} onSearch={this.onSearch}
isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)} isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)}
@ -2235,10 +2050,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
</AuxPanel> </AuxPanel>
); );
let messageComposer; let searchInfo; let messageComposer;
const showComposer = ( const showComposer = (
// joined and not showing search results // joined and not showing search results
myMembership === 'join' && !this.state.searchResults myMembership === 'join' && !this.state.search
); );
if (showComposer) { if (showComposer) {
messageComposer = messageComposer =
@ -2251,40 +2066,24 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
/>; />;
} }
// TODO: Why aren't we storing the term/scope/count in this format
// in this.state if this is what RoomHeader desires?
if (this.state.searchResults) {
searchInfo = {
searchTerm: this.state.searchTerm,
searchScope: this.state.searchScope,
searchCount: this.state.searchResults.count,
};
}
// if we have search results, we keep the messagepanel (so that it preserves its // if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it. // scroll state), but hide it.
let searchResultsPanel; let searchResultsPanel;
let hideMessagePanel = false; let hideMessagePanel = false;
if (this.state.searchResults) { if (this.state.search) {
// show searching spinner searchResultsPanel = <RoomSearchView
if (this.state.searchResults.count === undefined) { key={this.state.search.searchId}
searchResultsPanel = ( ref={this.searchResultsPanel}
<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" /> term={this.state.search.term}
); scope={this.state.search.scope}
} else { promise={this.state.search.promise}
searchResultsPanel = ( abortController={this.state.search.abortController}
<ScrollPanel resizeNotifier={this.props.resizeNotifier}
ref={this.searchResultsPanel} permalinkCreator={this.permalinkCreator}
className={"mx_RoomView_searchResultsPanel " + this.messagePanelClassNames} className={this.messagePanelClassNames}
onFillRequest={this.onSearchResultsFillRequest} onUpdate={this.onSearchUpdate}
resizeNotifier={this.props.resizeNotifier} />;
>
<li className={scrollheaderClasses} />
{ this.getSearchResultTiles() }
</ScrollPanel>
);
}
hideMessagePanel = true; hideMessagePanel = true;
} }
@ -2321,14 +2120,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
let topUnreadMessagesBar = null; let topUnreadMessagesBar = null;
// Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense // Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense
if (this.state.showTopUnreadMessagesBar && !this.state.searchResults) { if (this.state.showTopUnreadMessagesBar && !this.state.search) {
topUnreadMessagesBar = ( topUnreadMessagesBar = (
<TopUnreadMessagesBar onScrollUpClick={this.jumpToReadMarker} onCloseClick={this.forgetReadMarker} /> <TopUnreadMessagesBar onScrollUpClick={this.jumpToReadMarker} onCloseClick={this.forgetReadMarker} />
); );
} }
let jumpToBottom; let jumpToBottom;
// Do not show JumpToBottomButton if we have search results showing, it makes no sense // Do not show JumpToBottomButton if we have search results showing, it makes no sense
if (this.state.atEndOfLiveTimeline === false && !this.state.searchResults) { if (this.state.atEndOfLiveTimeline === false && !this.state.search) {
jumpToBottom = (<JumpToBottomButton jumpToBottom = (<JumpToBottomButton
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0} highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
@ -2455,7 +2254,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<ErrorBoundary> <ErrorBoundary>
<RoomHeader <RoomHeader
room={this.state.room} room={this.state.room}
searchInfo={searchInfo} searchInfo={this.state.search}
oobData={this.props.oobData} oobData={this.props.oobData}
inRoom={myMembership === 'join'} inRoom={myMembership === 'join'}
onSearchClick={onSearchClick} onSearchClick={onSearchClick}

View File

@ -376,7 +376,7 @@ export default class ScrollPanel extends React.Component<IProps> {
const itemlist = this.itemlist.current; const itemlist = this.itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild as HTMLElement; const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
const contentTop = firstTile && firstTile.offsetTop; const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = []; const fillPromises: Promise<void>[] = [];
// if scrollTop gets to 1 screen from the top of the first tile, // if scrollTop gets to 1 screen from the top of the first tile,
// try backward filling // try backward filling

View File

@ -457,9 +457,13 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
componentWillUnmount() { componentWillUnmount() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
client.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); if (client) {
client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); client.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged);
client.removeListener(RoomEvent.Receipt, this.onRoomReceipt); client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
client.removeListener(RoomEvent.Receipt, this.onRoomReceipt);
const room = client.getRoom(this.props.mxEvent.getRoomId());
room?.off(ThreadEvent.New, this.onNewThread);
}
this.isListeningForReceipts = false; this.isListeningForReceipts = false;
this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted);
if (this.props.showReactions) { if (this.props.showReactions) {
@ -468,12 +472,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
if (SettingsStore.getValue("feature_thread")) { if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.off(ThreadEvent.Update, this.updateThread); this.props.mxEvent.off(ThreadEvent.Update, this.updateThread);
} }
this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
room?.off(ThreadEvent.New, this.onNewThread);
if (this.threadState) {
this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}
} }
componentDidUpdate(prevProps: Readonly<EventTileProps>) { componentDidUpdate(prevProps: Readonly<EventTileProps>) {

View File

@ -20,6 +20,7 @@ import classNames from 'classnames';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
@ -393,9 +394,15 @@ const CallLayoutSelector: FC<CallLayoutSelectorProps> = ({ call }) => {
}; };
export interface ISearchInfo { export interface ISearchInfo {
searchTerm: string; searchId: number;
searchScope: SearchScope; roomId?: string;
searchCount: number; term: string;
scope: SearchScope;
promise: Promise<ISearchResults>;
abortController?: AbortController;
inProgress?: boolean;
count?: number;
} }
export interface IProps { export interface IProps {
@ -408,7 +415,7 @@ export interface IProps {
onAppsClick: (() => void) | null; onAppsClick: (() => void) | null;
e2eStatus: E2EStatus; e2eStatus: E2EStatus;
appsShown: boolean; appsShown: boolean;
searchInfo: ISearchInfo; searchInfo?: ISearchInfo;
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>; excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
showButtons?: boolean; showButtons?: boolean;
enableRoomOptionsMenu?: boolean; enableRoomOptionsMenu?: boolean;
@ -692,11 +699,9 @@ export default class RoomHeader extends React.Component<IProps, IState> {
// don't display the search count until the search completes and // don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount. // gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo && if (typeof this.props.searchInfo?.count === "number") {
this.props.searchInfo.searchCount !== undefined &&
this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp; searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;
{ _t("(~%(count)s results)", { count: this.props.searchInfo.searchCount }) } { _t("(~%(count)s results)", { count: this.props.searchInfo.count }) }
</div>; </div>;
} }

View File

@ -3322,6 +3322,9 @@
"Find a room…": "Find a room…", "Find a room…": "Find a room…",
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
"Search failed": "Search failed",
"Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(",
"No more results": "No more results",
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.", "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has been blocked by its administrator. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has been blocked by its administrator. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please <a>contact your service administrator</a> to continue using the service.",
@ -3335,9 +3338,6 @@
"We're creating a room with %(names)s": "We're creating a room with %(names)s", "We're creating a room with %(names)s": "We're creating a room with %(names)s",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
"Search failed": "Search failed",
"Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(",
"No more results": "No more results",
"Failed to reject invite": "Failed to reject invite", "Failed to reject invite": "Failed to reject invite",
"You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
"You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",

View File

@ -0,0 +1,313 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { mocked } from "jest-mock";
import { render, screen } from "@testing-library/react";
import { Room } from "matrix-js-sdk/src/models/room";
import { ISearchResults } from "matrix-js-sdk/src/@types/search";
import { defer } from "matrix-js-sdk/src/utils";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import { IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { RoomSearchView } from "../../../src/components/structures/RoomSearchView";
import { SearchScope } from "../../../src/components/views/rooms/SearchBar";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
import { stubClient } from "../../test-utils";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { searchPagination } from "../../../src/Searching";
jest.mock("../../../src/Searching", () => ({
searchPagination: jest.fn(),
}));
describe("<SearchRoomView/>", () => {
const eventMapper = (obj: Partial<IEvent>) => new MatrixEvent(obj);
const resizeNotifier = new ResizeNotifier();
let client: MatrixClient;
let room: Room;
let permalinkCreator: RoomPermalinkCreator;
beforeEach(async () => {
stubClient();
client = MatrixClientPeg.get();
client.supportsExperimentalThreads = jest.fn().mockReturnValue(true);
room = new Room("!room:server", client, client.getUserId());
mocked(client.getRoom).mockReturnValue(room);
permalinkCreator = new RoomPermalinkCreator(room, room.roomId);
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(100);
});
afterEach(async () => {
jest.restoreAllMocks();
});
it("should show a spinner before the promise resolves", async () => {
const deferred = defer<ISearchResults>();
render((
<RoomSearchView
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
permalinkCreator={permalinkCreator}
className="someClass"
onUpdate={jest.fn()}
/>
));
await screen.findByTestId("messagePanelSearchSpinner");
});
it("should render results when the promise resolves", async () => {
render((
<MatrixClientContext.Provider value={client}>
<RoomSearchView
term="search term"
scope={SearchScope.All}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson({
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [{
room_id: room.roomId,
event_id: "$1",
sender: client.getUserId(),
origin_server_ts: 1,
content: { body: "Before", msgtype: "m.text" },
type: EventType.RoomMessage,
}],
events_after: [{
room_id: room.roomId,
event_id: "$3",
sender: client.getUserId(),
origin_server_ts: 1,
content: { body: "After", msgtype: "m.text" },
type: EventType.RoomMessage,
}],
},
}, eventMapper),
],
highlights: [],
count: 1,
})}
resizeNotifier={resizeNotifier}
permalinkCreator={permalinkCreator}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>
));
await screen.findByText("Before");
await screen.findByText("Foo Test Bar");
await screen.findByText("After");
});
it("should highlight words correctly", async () => {
render((
<MatrixClientContext.Provider value={client}>
<RoomSearchView
term="search term"
scope={SearchScope.Room}
promise={Promise.resolve<ISearchResults>({
results: [
SearchResult.fromJson({
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
}, eventMapper),
],
highlights: ["test"],
count: 1,
})}
resizeNotifier={resizeNotifier}
permalinkCreator={permalinkCreator}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>
));
const text = await screen.findByText("Test");
expect(text).toHaveClass("mx_EventTile_searchHighlight");
});
it("should show spinner above results when backpaginating", async () => {
const searchResults: ISearchResults = {
results: [
SearchResult.fromJson({
rank: 1,
result: {
room_id: room.roomId,
event_id: "$2",
sender: client.getUserId(),
origin_server_ts: 1,
content: { body: "Foo Test Bar", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
}, eventMapper),
],
highlights: ["test"],
next_batch: "next_batch",
count: 2,
};
mocked(searchPagination).mockResolvedValue({
...searchResults,
results: [
...searchResults.results,
SearchResult.fromJson({
rank: 1,
result: {
room_id: room.roomId,
event_id: "$4",
sender: client.getUserId(),
origin_server_ts: 4,
content: { body: "Potato", msgtype: "m.text" },
type: EventType.RoomMessage,
},
context: {
profile_info: {},
events_before: [],
events_after: [],
},
}, eventMapper),
],
next_batch: undefined,
});
render((
<MatrixClientContext.Provider value={client}>
<RoomSearchView
term="search term"
scope={SearchScope.All}
promise={Promise.resolve(searchResults)}
resizeNotifier={resizeNotifier}
permalinkCreator={permalinkCreator}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>
));
await screen.findByRole("progressbar");
await screen.findByText("Potato");
expect(screen.queryByRole("progressbar")).toBeFalsy();
});
it("should handle resolutions after unmounting sanely", async () => {
const deferred = defer<ISearchResults>();
const { unmount } = render((
<MatrixClientContext.Provider value={client}>
<RoomSearchView
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
permalinkCreator={permalinkCreator}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>
));
unmount();
deferred.resolve({
results: [],
highlights: [],
});
});
it("should handle rejections after unmounting sanely", async () => {
const deferred = defer<ISearchResults>();
const { unmount } = render((
<MatrixClientContext.Provider value={client}>
<RoomSearchView
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
permalinkCreator={permalinkCreator}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>
));
unmount();
deferred.reject({
results: [],
highlights: [],
});
});
it("should show modal if error is encountered", async () => {
const deferred = defer<ISearchResults>();
render((
<MatrixClientContext.Provider value={client}>
<RoomSearchView
term="search term"
scope={SearchScope.All}
promise={deferred.promise}
resizeNotifier={resizeNotifier}
permalinkCreator={permalinkCreator}
className="someClass"
onUpdate={jest.fn()}
/>
</MatrixClientContext.Provider>
));
deferred.reject(new Error("Some error"));
await screen.findByText("Search failed");
await screen.findByText("Some error");
});
});

View File

@ -26,6 +26,7 @@ import { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { ClientWidgetApi, Widget } from "matrix-widget-api"; import { ClientWidgetApi, Widget } from "matrix-widget-api";
import EventEmitter from "events"; import EventEmitter from "events";
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
@ -253,9 +254,11 @@ function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial<IRoom
e2eStatus: E2EStatus.Normal, e2eStatus: E2EStatus.Normal,
appsShown: true, appsShown: true,
searchInfo: { searchInfo: {
searchTerm: "", searchId: Math.random(),
searchScope: SearchScope.Room, promise: new Promise<ISearchResults>(() => {}),
searchCount: 0, term: "",
scope: SearchScope.Room,
count: 0,
}, },
viewingCall: false, viewingCall: false,
activeCall: null, activeCall: null,
@ -472,9 +475,11 @@ describe("RoomHeader (React Testing Library)", () => {
e2eStatus={E2EStatus.Normal} e2eStatus={E2EStatus.Normal}
appsShown={true} appsShown={true}
searchInfo={{ searchInfo={{
searchTerm: "", searchId: Math.random(),
searchScope: SearchScope.Room, promise: new Promise<ISearchResults>(() => {}),
searchCount: 0, term: "",
scope: SearchScope.Room,
count: 0,
}} }}
viewingCall={false} viewingCall={false}
activeCall={null} activeCall={null}