Redesign room search interface (#12677)
* Extract SearchInfo interface and SearchScope enum Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix in-progress and update behaviour of RoomSearchView Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove search button from legacy header Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Move search from aux panel to room summary card Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Wire up Cmd/Ctrl F for moved search field Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Use cpd space tokens Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove stale props Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix ctrl/cmd f search shortcut Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update Compound Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Revert the back button for now Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Cancel search on escape Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix missing X Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Extract SearchScope and SearchInfo into Searching Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Switch to icon button for cancel search Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * yarn.lock Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * lint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update screenshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update screenshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update screenshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update locators Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Revert screenshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update screenshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update snapshots * Discard changes to package.json * i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Snapshots Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Handle narrow viewports Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Revert copy Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
|
@ -781,10 +781,10 @@ test.describe("Timeline", () => {
|
||||||
|
|
||||||
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
|
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
|
||||||
|
|
||||||
await expect(page.locator(".mx_SearchBar")).toMatchScreenshot("search-bar-on-timeline.png");
|
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill("Message");
|
||||||
|
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
|
||||||
|
|
||||||
await page.locator(".mx_SearchBar_input").getByRole("textbox").fill("Message");
|
await expect(page.locator(".mx_RoomSearchAuxPanel")).toMatchScreenshot("search-aux-panel.png");
|
||||||
await page.locator(".mx_SearchBar_input").getByRole("textbox").press("Enter");
|
|
||||||
|
|
||||||
for (const locator of await page
|
for (const locator of await page
|
||||||
.locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight")
|
.locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight")
|
||||||
|
@ -822,8 +822,8 @@ test.describe("Timeline", () => {
|
||||||
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
|
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
|
||||||
|
|
||||||
// Search the string to display both the message and TextualEvent on search results panel
|
// Search the string to display both the message and TextualEvent on search results panel
|
||||||
await page.locator(".mx_SearchBar").getByRole("textbox").fill(stringToSearch);
|
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill(stringToSearch);
|
||||||
await page.locator(".mx_SearchBar").getByRole("textbox").press("Enter");
|
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
|
||||||
|
|
||||||
// On search results panel
|
// On search results panel
|
||||||
const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel");
|
const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel");
|
||||||
|
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 5.6 KiB |
Before Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 19 KiB |
|
@ -177,9 +177,9 @@ a:visited {
|
||||||
color: $accent-alt;
|
color: $accent-alt;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="text"],
|
||||||
input[type="search"],
|
:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="search"],
|
||||||
input[type="password"] {
|
:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="password"] {
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
font: var(--cpd-font-body-md-semibold);
|
font: var(--cpd-font-body-md-semibold);
|
||||||
font-weight: var(--cpd-font-weight-semibold);
|
font-weight: var(--cpd-font-weight-semibold);
|
||||||
|
|
|
@ -306,10 +306,10 @@
|
||||||
@import "./views/rooms/_RoomListHeader.pcss";
|
@import "./views/rooms/_RoomListHeader.pcss";
|
||||||
@import "./views/rooms/_RoomPreviewBar.pcss";
|
@import "./views/rooms/_RoomPreviewBar.pcss";
|
||||||
@import "./views/rooms/_RoomPreviewCard.pcss";
|
@import "./views/rooms/_RoomPreviewCard.pcss";
|
||||||
|
@import "./views/rooms/_RoomSearchAuxPanel.pcss";
|
||||||
@import "./views/rooms/_RoomSublist.pcss";
|
@import "./views/rooms/_RoomSublist.pcss";
|
||||||
@import "./views/rooms/_RoomTile.pcss";
|
@import "./views/rooms/_RoomTile.pcss";
|
||||||
@import "./views/rooms/_RoomUpgradeWarningBar.pcss";
|
@import "./views/rooms/_RoomUpgradeWarningBar.pcss";
|
||||||
@import "./views/rooms/_SearchBar.pcss";
|
|
||||||
@import "./views/rooms/_SendMessageComposer.pcss";
|
@import "./views/rooms/_SendMessageComposer.pcss";
|
||||||
@import "./views/rooms/_SpaceScopeHeader.pcss";
|
@import "./views/rooms/_SpaceScopeHeader.pcss";
|
||||||
@import "./views/rooms/_Stickers.pcss";
|
@import "./views/rooms/_Stickers.pcss";
|
||||||
|
|
|
@ -238,25 +238,12 @@ limitations under the License.
|
||||||
padding: 15px 12px;
|
padding: 15px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomSummaryCard_search input {
|
.mx_RoomSummaryCard_search {
|
||||||
/* Overriding very broad CSS rules */
|
flex-grow: 1;
|
||||||
border: 0 !important;
|
min-width: 0;
|
||||||
margin: 0 !important;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomSummaryCard_searchBtn {
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
background: var(--cpd-color-bg-canvas-default);
|
display: unset; /* override _common.pcss which inhibits this */
|
||||||
color: var(--cpd-color-icon-primary);
|
|
||||||
border: 1px solid var(--cpd-color-gray-400);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
padding: var(--cpd-space-2x);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--cpd-color-bg-subtle-primary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
72
res/css/views/rooms/_RoomSearchAuxPanel.pcss
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_RoomSearchAuxPanel {
|
||||||
|
/* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */
|
||||||
|
min-height: 84px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-color: var(--cpd-color-bg-canvas-default);
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px 0;
|
||||||
|
padding: var(--cpd-space-3x);
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
|
||||||
|
.mx_RoomSearchAuxPanel_summary {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: inherit; /* flex */
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
padding: var(--cpd-space-2x);
|
||||||
|
border-radius: var(--cpd-space-2x);
|
||||||
|
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||||
|
color: var(--cpd-color-icon-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomSearchAuxPanel_summary_text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-22px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_SearchWarning {
|
||||||
|
display: contents;
|
||||||
|
font-size: $font-13px;
|
||||||
|
line-height: $font-20px;
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomSearchAuxPanel_buttons {
|
||||||
|
display: inherit; /* flex */
|
||||||
|
gap: var(--cpd-space-6x);
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,83 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.mx_SearchBar {
|
|
||||||
/* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */
|
|
||||||
min-height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid $primary-hairline-color;
|
|
||||||
|
|
||||||
.mx_SearchBar_input {
|
|
||||||
--size-button-search: 37px; /* size of the search button inside `input` element */
|
|
||||||
|
|
||||||
/* border: 1px solid $input-border-color; */
|
|
||||||
/* font-size: $font-15px; */
|
|
||||||
flex: 1 1 0;
|
|
||||||
margin-left: 22px;
|
|
||||||
|
|
||||||
/* do not allow the input element to shrink below the width needed for the placeholder 'Search…'
|
|
||||||
and the search button */
|
|
||||||
min-width: calc(7em + var(--size-button-search));
|
|
||||||
|
|
||||||
input {
|
|
||||||
box-sizing: border-box; /* include padding value into width calculation */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SearchBar_searchButton {
|
|
||||||
cursor: pointer;
|
|
||||||
width: var(--size-button-search);
|
|
||||||
height: var(--size-button-search);
|
|
||||||
background-color: $accent;
|
|
||||||
mask: url("$(res)/img/feather-customised/search-input.svg");
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SearchBar_buttons {
|
|
||||||
display: inherit; /* flex */
|
|
||||||
min-width: 0; /* have the close button displayed even on a very narrow timeline */
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SearchBar_button {
|
|
||||||
border: 0;
|
|
||||||
margin: 0 0 0 22px;
|
|
||||||
padding: 5px;
|
|
||||||
font-size: $font-15px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: $primary-content;
|
|
||||||
border-bottom: 2px solid $accent;
|
|
||||||
font-weight: var(--cpd-font-weight-semibold);
|
|
||||||
word-break: break-all; /* prevent the input area and cancel button from being overlapped by BaseCard */
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SearchBar_unselected {
|
|
||||||
color: $input-darker-fg-color;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SearchBar_cancel {
|
|
||||||
background-color: $alert;
|
|
||||||
mask: url("$(res)/img/cancel.svg");
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-position: center;
|
|
||||||
mask-size: 14px;
|
|
||||||
padding: 9px;
|
|
||||||
margin: 0 12px 0 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { ChangeEvent } from "react";
|
||||||
import { Room, RoomState, RoomStateEvent, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
import { Room, RoomState, RoomStateEvent, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { throttle } from "lodash";
|
import { throttle } from "lodash";
|
||||||
|
|
||||||
|
@ -57,7 +57,8 @@ interface RoomlessProps extends BaseProps {
|
||||||
interface RoomProps extends BaseProps {
|
interface RoomProps extends BaseProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
permalinkCreator: RoomPermalinkCreator;
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
onSearchClick?: () => void;
|
onSearchChange?: (e: ChangeEvent) => void;
|
||||||
|
onSearchCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = XOR<RoomlessProps, RoomProps>;
|
type Props = XOR<RoomlessProps, RoomProps>;
|
||||||
|
@ -296,7 +297,9 @@ export default class RightPanel extends React.Component<Props, IState> {
|
||||||
onClose={this.onClose}
|
onClose={this.onClose}
|
||||||
// whenever RightPanel is passed a room it is passed a permalinkcreator
|
// whenever RightPanel is passed a room it is passed a permalinkcreator
|
||||||
permalinkCreator={this.props.permalinkCreator!}
|
permalinkCreator={this.props.permalinkCreator!}
|
||||||
onSearchClick={this.props.onSearchClick}
|
onSearchChange={this.props.onSearchChange}
|
||||||
|
onSearchCancel={this.props.onSearchCancel}
|
||||||
|
focusRoomSearch={cardState?.focusRoomSearch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ if (DEBUG) {
|
||||||
interface Props {
|
interface Props {
|
||||||
term: string;
|
term: string;
|
||||||
scope: SearchScope;
|
scope: SearchScope;
|
||||||
|
inProgress: boolean;
|
||||||
promise: Promise<ISearchResults>;
|
promise: Promise<ISearchResults>;
|
||||||
abortController?: AbortController;
|
abortController?: AbortController;
|
||||||
resizeNotifier: ResizeNotifier;
|
resizeNotifier: ResizeNotifier;
|
||||||
|
@ -58,10 +59,9 @@ interface Props {
|
||||||
// XXX: todo: merge overlapping results somehow?
|
// XXX: todo: merge overlapping results somehow?
|
||||||
// XXX: why doesn't searching on name work?
|
// XXX: why doesn't searching on name work?
|
||||||
export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
||||||
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate }: Props, ref) => {
|
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => {
|
||||||
const client = useContext(MatrixClientContext);
|
const client = useContext(MatrixClientContext);
|
||||||
const roomContext = useContext(RoomContext);
|
const roomContext = useContext(RoomContext);
|
||||||
const [inProgress, setInProgress] = useState(true);
|
|
||||||
const [highlights, setHighlights] = useState<string[] | null>(null);
|
const [highlights, setHighlights] = useState<string[] | null>(null);
|
||||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||||
const aborted = useRef(false);
|
const aborted = useRef(false);
|
||||||
|
@ -78,73 +78,71 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
||||||
|
|
||||||
const handleSearchResult = useCallback(
|
const handleSearchResult = useCallback(
|
||||||
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
|
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
|
||||||
setInProgress(true);
|
onUpdate(true, null);
|
||||||
|
|
||||||
return searchPromise
|
return searchPromise.then(
|
||||||
.then(
|
async (results): Promise<boolean> => {
|
||||||
async (results): Promise<boolean> => {
|
debuglog("search complete");
|
||||||
debuglog("search complete");
|
if (aborted.current) {
|
||||||
if (aborted.current) {
|
logger.error("Discarding stale search results");
|
||||||
logger.error("Discarding stale search results");
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// postgres on synapse returns us precise details of the strings
|
// postgres on synapse returns us precise details of the strings
|
||||||
// which actually got matched for highlighting.
|
// which actually got matched for highlighting.
|
||||||
//
|
//
|
||||||
// In either case, we want to highlight the literal search term
|
// In either case, we want to highlight the literal search term
|
||||||
// whether it was used by the search engine or not.
|
// whether it was used by the search engine or not.
|
||||||
|
|
||||||
let highlights = results.highlights;
|
let highlights = results.highlights;
|
||||||
if (!highlights.includes(term)) {
|
if (!highlights.includes(term)) {
|
||||||
highlights = highlights.concat(term);
|
highlights = highlights.concat(term);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For overlapping highlights,
|
// For overlapping highlights,
|
||||||
// favour longer (more specific) terms first
|
// favour longer (more specific) terms first
|
||||||
highlights = highlights.sort(function (a, b) {
|
highlights = highlights.sort(function (a, b) {
|
||||||
return b.length - a.length;
|
return b.length - a.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const result of results.results) {
|
for (const result of results.results) {
|
||||||
for (const event of result.context.getTimeline()) {
|
for (const event of result.context.getTimeline()) {
|
||||||
const bundledRelationship =
|
const bundledRelationship =
|
||||||
event.getServerAggregatedRelation<IThreadBundledRelationship>(
|
event.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||||
THREAD_RELATION_TYPE.name,
|
THREAD_RELATION_TYPE.name,
|
||||||
);
|
);
|
||||||
if (!bundledRelationship || event.getThread()) continue;
|
if (!bundledRelationship || event.getThread()) continue;
|
||||||
const room = client.getRoom(event.getRoomId());
|
const room = client.getRoom(event.getRoomId());
|
||||||
const thread = room?.findThreadForEvent(event);
|
const thread = room?.findThreadForEvent(event);
|
||||||
if (thread) {
|
if (thread) {
|
||||||
event.setThread(thread);
|
event.setThread(thread);
|
||||||
} else {
|
} else {
|
||||||
room?.createThread(event.getId()!, event, [], true);
|
room?.createThread(event.getId()!, event, [], true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setHighlights(highlights);
|
setHighlights(highlights);
|
||||||
setResults({ ...results }); // copy to force a refresh
|
setResults({ ...results }); // copy to force a refresh
|
||||||
|
onUpdate(false, results);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (aborted.current) {
|
||||||
|
logger.error("Discarding stale search results");
|
||||||
return false;
|
return false;
|
||||||
},
|
}
|
||||||
(error) => {
|
logger.error("Search failed", error);
|
||||||
if (aborted.current) {
|
Modal.createDialog(ErrorDialog, {
|
||||||
logger.error("Discarding stale search results");
|
title: _t("error_dialog|search_failed|title"),
|
||||||
return false;
|
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
|
||||||
}
|
});
|
||||||
logger.error("Search failed", error);
|
onUpdate(false, null);
|
||||||
Modal.createDialog(ErrorDialog, {
|
return false;
|
||||||
title: _t("error_dialog|search_failed|title"),
|
},
|
||||||
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
|
);
|
||||||
});
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.finally(() => {
|
|
||||||
setInProgress(false);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[client, term],
|
[client, term, onUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mount & unmount effect
|
// Mount & unmount effect
|
||||||
|
|
|
@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from "react";
|
import React, { ChangeEvent, createRef, ReactElement, ReactNode, RefObject, useContext } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
IRecommendedVersion,
|
IRecommendedVersion,
|
||||||
|
@ -41,7 +41,7 @@ import {
|
||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import { throttle } from "lodash";
|
import { debounce, throttle } from "lodash";
|
||||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
|
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
|
||||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||||
|
|
||||||
|
@ -70,7 +70,6 @@ import TimelinePanel from "./TimelinePanel";
|
||||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||||
import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
|
import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
|
||||||
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
||||||
import SearchBar 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 LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
|
import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
|
||||||
|
@ -133,6 +132,7 @@ import { CancelAskToJoinPayload } from "../../dispatcher/payloads/CancelAskToJoi
|
||||||
import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload";
|
import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload";
|
||||||
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||||
import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
|
import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
|
||||||
|
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||||
|
@ -1196,9 +1196,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Action.FocusMessageSearch:
|
|
||||||
this.onSearchClick();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "local_room_event":
|
case "local_room_event":
|
||||||
this.onLocalRoomEvent(payload.roomId);
|
this.onLocalRoomEvent(payload.roomId);
|
||||||
|
@ -1725,13 +1722,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private onSearch = (term: string, scope: SearchScope): void => {
|
private onSearch = (term: string, scope = SearchScope.Room): void => {
|
||||||
const roomId = scope === SearchScope.Room ? this.getRoomId() : undefined;
|
const roomId = scope === SearchScope.Room ? this.getRoomId() : undefined;
|
||||||
debuglog("sending search request");
|
debuglog("sending search request");
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const promise = eventSearch(this.context.client!, term, roomId, abortController.signal);
|
const promise = eventSearch(this.context.client!, term, roomId, abortController.signal);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
|
timelineRenderingType: TimelineRenderingType.Search,
|
||||||
search: {
|
search: {
|
||||||
// make sure that we don't end up showing results from
|
// make sure that we don't end up showing results from
|
||||||
// an aborted search by keeping a unique id.
|
// an aborted search by keeping a unique id.
|
||||||
|
@ -1745,6 +1743,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onSearchScopeChange = (scope: SearchScope): void => {
|
||||||
|
this.onSearch(this.state.search?.term ?? "", scope);
|
||||||
|
};
|
||||||
|
|
||||||
private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => {
|
private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => {
|
||||||
this.setState({
|
this.setState({
|
||||||
search: {
|
search: {
|
||||||
|
@ -1839,15 +1841,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private onSearchClick = (): void => {
|
private onSearchClick = (): void => {
|
||||||
if (this.state.timelineRenderingType === TimelineRenderingType.Search) {
|
dis.fire(Action.FocusMessageSearch);
|
||||||
this.onCancelSearchClick();
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
timelineRenderingType: TimelineRenderingType.Search,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onSearchChange = debounce((e: ChangeEvent): void => {
|
||||||
|
const term = (e.target as HTMLInputElement).value;
|
||||||
|
this.onSearch(term);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
private onCancelSearchClick = (): Promise<void> => {
|
private onCancelSearchClick = (): Promise<void> => {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
this.setState(
|
this.setState(
|
||||||
|
@ -2328,10 +2329,10 @@ 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 = (
|
aux = (
|
||||||
<SearchBar
|
<RoomSearchAuxPanel
|
||||||
searchInProgress={this.state.search?.inProgress}
|
searchInfo={this.state.search}
|
||||||
onCancelClick={this.onCancelSearchClick}
|
onCancelClick={this.onCancelSearchClick}
|
||||||
onSearch={this.onSearch}
|
onSearchScopeChange={this.onSearchScopeChange}
|
||||||
isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)}
|
isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -2438,6 +2439,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
scope={this.state.search.scope}
|
scope={this.state.search.scope}
|
||||||
promise={this.state.search.promise}
|
promise={this.state.search.promise}
|
||||||
abortController={this.state.search.abortController}
|
abortController={this.state.search.abortController}
|
||||||
|
inProgress={!!this.state.search.inProgress}
|
||||||
resizeNotifier={this.props.resizeNotifier}
|
resizeNotifier={this.props.resizeNotifier}
|
||||||
className={this.messagePanelClassNames}
|
className={this.messagePanelClassNames}
|
||||||
onUpdate={this.onSearchUpdate}
|
onUpdate={this.onSearchUpdate}
|
||||||
|
@ -2507,7 +2509,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
resizeNotifier={this.props.resizeNotifier}
|
resizeNotifier={this.props.resizeNotifier}
|
||||||
permalinkCreator={this.permalinkCreator}
|
permalinkCreator={this.permalinkCreator}
|
||||||
e2eStatus={this.state.e2eStatus}
|
e2eStatus={this.state.e2eStatus}
|
||||||
onSearchClick={this.onSearchClick}
|
onSearchChange={this.onSearchChange}
|
||||||
|
onSearchCancel={this.onCancelSearchClick}
|
||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,6 @@ export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resize
|
||||||
<LegacyRoomHeader
|
<LegacyRoomHeader
|
||||||
room={context.room}
|
room={context.room}
|
||||||
inRoom={true}
|
inRoom={true}
|
||||||
onSearchClick={null}
|
|
||||||
onInviteClick={null}
|
onInviteClick={null}
|
||||||
onForgetClick={null}
|
onForgetClick={null}
|
||||||
e2eStatus={E2EStatus.Normal}
|
e2eStatus={E2EStatus.Normal}
|
||||||
|
|
|
@ -33,9 +33,10 @@ export enum WarningKind {
|
||||||
interface IProps {
|
interface IProps {
|
||||||
isRoomEncrypted?: boolean;
|
isRoomEncrypted?: boolean;
|
||||||
kind: WarningKind;
|
kind: WarningKind;
|
||||||
|
showLogo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.Element {
|
export default function SearchWarning({ isRoomEncrypted, kind, showLogo = true }: IProps): JSX.Element {
|
||||||
if (!isRoomEncrypted) return <></>;
|
if (!isRoomEncrypted) return <></>;
|
||||||
if (EventIndexPeg.get()) return <></>;
|
if (EventIndexPeg.get()) return <></>;
|
||||||
|
|
||||||
|
@ -121,7 +122,7 @@ export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.El
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_SearchWarning">
|
<div className="mx_SearchWarning">
|
||||||
{logo}
|
{showLogo ? logo : null}
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -14,11 +14,19 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { SyntheticEvent, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import React, {
|
||||||
|
ChangeEvent,
|
||||||
|
SyntheticEvent,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Tooltip,
|
|
||||||
Separator,
|
Separator,
|
||||||
ToggleMenuItem,
|
ToggleMenuItem,
|
||||||
Text,
|
Text,
|
||||||
|
@ -26,8 +34,9 @@ import {
|
||||||
Heading,
|
Heading,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
Search,
|
||||||
|
Form,
|
||||||
} from "@vector-im/compound-web";
|
} from "@vector-im/compound-web";
|
||||||
import { Icon as SearchIcon } from "@vector-im/compound-design-tokens/icons/search.svg";
|
|
||||||
import { Icon as FavouriteIcon } from "@vector-im/compound-design-tokens/icons/favourite.svg";
|
import { Icon as FavouriteIcon } from "@vector-im/compound-design-tokens/icons/favourite.svg";
|
||||||
import { Icon as UserAddIcon } from "@vector-im/compound-design-tokens/icons/user-add.svg";
|
import { Icon as UserAddIcon } from "@vector-im/compound-design-tokens/icons/user-add.svg";
|
||||||
import { Icon as UserProfileSolidIcon } from "@vector-im/compound-design-tokens/icons/user-profile-solid.svg";
|
import { Icon as UserProfileSolidIcon } from "@vector-im/compound-design-tokens/icons/user-profile-solid.svg";
|
||||||
|
@ -63,7 +72,7 @@ import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||||
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
|
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
|
||||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import { UIComponent, UIFeature } from "../../../settings/UIFeature";
|
import { UIComponent, UIFeature } from "../../../settings/UIFeature";
|
||||||
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
||||||
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
|
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
|
||||||
|
@ -89,12 +98,18 @@ import { useTopic } from "../../../hooks/room/useTopic";
|
||||||
import { Linkify, topicToHtml } from "../../../HtmlUtils";
|
import { Linkify, topicToHtml } from "../../../HtmlUtils";
|
||||||
import { Box } from "../../utils/Box";
|
import { Box } from "../../utils/Box";
|
||||||
import { onRoomTopicLinkClick } from "../elements/RoomTopic";
|
import { onRoomTopicLinkClick } from "../elements/RoomTopic";
|
||||||
|
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||||
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import { Key } from "../../../Keyboard";
|
||||||
|
import { useTransition } from "../../../hooks/useTransition";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
permalinkCreator: RoomPermalinkCreator;
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
onClose(): void;
|
onClose(): void;
|
||||||
onSearchClick?: () => void;
|
onSearchChange?: (e: ChangeEvent) => void;
|
||||||
|
onSearchCancel?: () => void;
|
||||||
|
focusRoomSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IAppsSectionProps {
|
interface IAppsSectionProps {
|
||||||
|
@ -364,7 +379,14 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RoomSummaryCard: React.FC<IProps> = ({ room, permalinkCreator, onClose, onSearchClick }) => {
|
const RoomSummaryCard: React.FC<IProps> = ({
|
||||||
|
room,
|
||||||
|
permalinkCreator,
|
||||||
|
onClose,
|
||||||
|
onSearchChange,
|
||||||
|
onSearchCancel,
|
||||||
|
focusRoomSearch,
|
||||||
|
}) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
|
|
||||||
const onShareRoomClick = (): void => {
|
const onShareRoomClick = (): void => {
|
||||||
|
@ -419,6 +441,26 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, permalinkCreator, onClose, on
|
||||||
}
|
}
|
||||||
}, [room, directRoomsList]);
|
}, [room, directRoomsList]);
|
||||||
|
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useDispatcher(defaultDispatcher, (payload) => {
|
||||||
|
if (payload.action === Action.FocusMessageSearch) {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Clear the search field when the user leaves the search view
|
||||||
|
useTransition(
|
||||||
|
(prevTimelineRenderingType) => {
|
||||||
|
if (
|
||||||
|
prevTimelineRenderingType === TimelineRenderingType.Search &&
|
||||||
|
roomContext.timelineRenderingType !== TimelineRenderingType.Search &&
|
||||||
|
searchInputRef.current
|
||||||
|
) {
|
||||||
|
searchInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[roomContext.timelineRenderingType],
|
||||||
|
);
|
||||||
|
|
||||||
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
|
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
|
||||||
const header = (
|
const header = (
|
||||||
<header className="mx_RoomSummaryCard_container">
|
<header className="mx_RoomSummaryCard_container">
|
||||||
|
@ -498,18 +540,24 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, permalinkCreator, onClose, on
|
||||||
align="center"
|
align="center"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
>
|
>
|
||||||
<Tooltip label={_t("action|search")} placement="right">
|
{onSearchChange && (
|
||||||
<button
|
<Form.Root className="mx_RoomSummaryCard_search" onSubmit={(e) => e.preventDefault()}>
|
||||||
className="mx_RoomSummaryCard_searchBtn"
|
<Search
|
||||||
data-testid="summary-search"
|
placeholder={_t("room|search|placeholder")}
|
||||||
onClick={() => {
|
name="room_message_search"
|
||||||
onSearchClick?.();
|
onChange={onSearchChange}
|
||||||
}}
|
className="mx_no_textinput"
|
||||||
aria-label={_t("action|search")}
|
ref={searchInputRef}
|
||||||
>
|
autoFocus={focusRoomSearch}
|
||||||
<SearchIcon width="100%" height="100%" />
|
onKeyDown={(e) => {
|
||||||
</button>
|
if (searchInputRef.current && e.key === Key.ESCAPE) {
|
||||||
</Tooltip>
|
searchInputRef.current.value = "";
|
||||||
|
onSearchCancel?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Root>
|
||||||
|
)}
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
data-testid="base-card-close-button"
|
data-testid="base-card-close-button"
|
||||||
className="mx_BaseCard_close"
|
className="mx_BaseCard_close"
|
||||||
|
|
79
src/components/views/rooms/RoomSearchAuxPanel.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 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 { Icon as SearchIcon } from "@vector-im/compound-design-tokens/icons/search.svg";
|
||||||
|
import { Icon as CloseIcon } from "@vector-im/compound-design-tokens/icons/close.svg";
|
||||||
|
import { IconButton, Link } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import { PosthogScreenTracker } from "../../../PosthogTrackers";
|
||||||
|
import SearchWarning, { WarningKind } from "../elements/SearchWarning";
|
||||||
|
import { SearchInfo, SearchScope } from "../../../Searching";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchInfo?: SearchInfo;
|
||||||
|
isRoomEncrypted: boolean;
|
||||||
|
onSearchScopeChange(scope: SearchScope): void;
|
||||||
|
onCancelClick(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSearchScopeChange, onCancelClick }) => {
|
||||||
|
const scope = searchInfo?.scope ?? SearchScope.Room;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PosthogScreenTracker screenName="RoomSearch" />
|
||||||
|
<div className="mx_RoomSearchAuxPanel">
|
||||||
|
<div className="mx_RoomSearchAuxPanel_summary">
|
||||||
|
<SearchIcon width="24px" height="24px" />
|
||||||
|
<div className="mx_RoomSearchAuxPanel_summary_text">
|
||||||
|
{searchInfo
|
||||||
|
? _t(
|
||||||
|
"room|search|summary",
|
||||||
|
{ count: searchInfo.count ?? 0 },
|
||||||
|
{ query: () => <b>{searchInfo.term}</b> },
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
<SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx_RoomSearchAuxPanel_buttons">
|
||||||
|
<Link
|
||||||
|
onClick={() =>
|
||||||
|
onSearchScopeChange(scope === SearchScope.Room ? SearchScope.All : SearchScope.Room)
|
||||||
|
}
|
||||||
|
kind="primary"
|
||||||
|
>
|
||||||
|
{scope === SearchScope.All
|
||||||
|
? _t("room|search|this_room_button")
|
||||||
|
: _t("room|search|all_rooms_button")}
|
||||||
|
</Link>
|
||||||
|
<IconButton
|
||||||
|
onClick={onCancelClick}
|
||||||
|
destructive
|
||||||
|
tooltip={_t("action|cancel")}
|
||||||
|
aria-label={_t("action|cancel")}
|
||||||
|
>
|
||||||
|
<CloseIcon width="20px" height="20px" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoomSearchAuxPanel;
|
|
@ -1,143 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
|
||||||
Copyright 2020 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, { createRef, RefObject } from "react";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import { PosthogScreenTracker } from "../../../PosthogTrackers";
|
|
||||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
|
||||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
|
||||||
import SearchWarning, { WarningKind } from "../elements/SearchWarning";
|
|
||||||
import { SearchScope } from "../../../Searching";
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
onCancelClick: () => void;
|
|
||||||
onSearch: (query: string, scope: SearchScope) => void;
|
|
||||||
searchInProgress?: boolean;
|
|
||||||
isRoomEncrypted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IState {
|
|
||||||
scope: SearchScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class SearchBar extends React.Component<IProps, IState> {
|
|
||||||
private searchTerm: RefObject<HTMLInputElement> = createRef();
|
|
||||||
|
|
||||||
public constructor(props: IProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
scope: SearchScope.Room,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private onThisRoomClick = (): void => {
|
|
||||||
this.setState({ scope: SearchScope.Room }, () => this.searchIfQuery());
|
|
||||||
};
|
|
||||||
|
|
||||||
private onAllRoomsClick = (): void => {
|
|
||||||
this.setState({ scope: SearchScope.All }, () => this.searchIfQuery());
|
|
||||||
};
|
|
||||||
|
|
||||||
private onSearchChange = (e: React.KeyboardEvent): void => {
|
|
||||||
const action = getKeyBindingsManager().getAccessibilityAction(e);
|
|
||||||
switch (action) {
|
|
||||||
case KeyBindingAction.Enter:
|
|
||||||
this.onSearch();
|
|
||||||
break;
|
|
||||||
case KeyBindingAction.Escape:
|
|
||||||
this.props.onCancelClick();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private searchIfQuery(): void {
|
|
||||||
if (this.searchTerm.current?.value) {
|
|
||||||
this.onSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onSearch = (): void => {
|
|
||||||
if (!this.searchTerm.current?.value.trim()) return;
|
|
||||||
this.props.onSearch(this.searchTerm.current.value, this.state.scope);
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
const searchButtonClasses = classNames("mx_SearchBar_searchButton", {
|
|
||||||
mx_SearchBar_searching: this.props.searchInProgress,
|
|
||||||
});
|
|
||||||
const thisRoomClasses = classNames("mx_SearchBar_button", {
|
|
||||||
mx_SearchBar_unselected: this.state.scope !== SearchScope.Room,
|
|
||||||
});
|
|
||||||
const allRoomsClasses = classNames("mx_SearchBar_button", {
|
|
||||||
mx_SearchBar_unselected: this.state.scope !== SearchScope.All,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PosthogScreenTracker screenName="RoomSearch" />
|
|
||||||
<div className="mx_SearchBar">
|
|
||||||
<div className="mx_SearchBar_buttons" role="radiogroup">
|
|
||||||
<AccessibleButton
|
|
||||||
className={thisRoomClasses}
|
|
||||||
onClick={this.onThisRoomClick}
|
|
||||||
aria-checked={this.state.scope === SearchScope.Room}
|
|
||||||
role="radio"
|
|
||||||
>
|
|
||||||
{_t("room|search|this_room")}
|
|
||||||
</AccessibleButton>
|
|
||||||
<AccessibleButton
|
|
||||||
className={allRoomsClasses}
|
|
||||||
onClick={this.onAllRoomsClick}
|
|
||||||
aria-checked={this.state.scope === SearchScope.All}
|
|
||||||
role="radio"
|
|
||||||
>
|
|
||||||
{_t("room|search|all_rooms")}
|
|
||||||
</AccessibleButton>
|
|
||||||
</div>
|
|
||||||
<div className="mx_SearchBar_input mx_textinput">
|
|
||||||
<input
|
|
||||||
ref={this.searchTerm}
|
|
||||||
type="text"
|
|
||||||
autoFocus={true}
|
|
||||||
placeholder={_t("room|search|field_placeholder")}
|
|
||||||
aria-label={
|
|
||||||
this.state.scope === SearchScope.Room
|
|
||||||
? _t("room|search|this_room_button")
|
|
||||||
: _t("room|search|all_rooms_button")
|
|
||||||
}
|
|
||||||
onKeyDown={this.onSearchChange}
|
|
||||||
/>
|
|
||||||
<AccessibleButton
|
|
||||||
className={searchButtonClasses}
|
|
||||||
onClick={this.onSearch}
|
|
||||||
aria-label={_t("action|search")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_SearchBar_cancel"
|
|
||||||
onClick={this.props.onCancelClick}
|
|
||||||
aria-label={_t("action|cancel")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SearchWarning isRoomEncrypted={this.props.isRoomEncrypted} kind={WarningKind.Search} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
35
src/hooks/useTransition.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Based on https://stackoverflow.com/a/61680184
|
||||||
|
|
||||||
|
import { DependencyList, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export const useTransition = <D extends DependencyList>(callback: (...params: D) => void, deps: D): void => {
|
||||||
|
const func = useRef<(...params: D) => void>(callback);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
func.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
const args = useRef<D | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (args.current !== null) func.current(...args.current);
|
||||||
|
args.current = deps;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
};
|
|
@ -2030,14 +2030,16 @@
|
||||||
"rejecting": "Rejecting invite…",
|
"rejecting": "Rejecting invite…",
|
||||||
"rejoin_button": "Re-join",
|
"rejoin_button": "Re-join",
|
||||||
"search": {
|
"search": {
|
||||||
"all_rooms": "All Rooms",
|
|
||||||
"all_rooms_button": "Search all rooms",
|
"all_rooms_button": "Search all rooms",
|
||||||
"field_placeholder": "Search…",
|
"placeholder": "Search messages…",
|
||||||
"result_count": {
|
"result_count": {
|
||||||
"one": "(~%(count)s result)",
|
"one": "(~%(count)s result)",
|
||||||
"other": "(~%(count)s results)"
|
"other": "(~%(count)s results)"
|
||||||
},
|
},
|
||||||
"this_room": "This Room",
|
"summary": {
|
||||||
|
"one": "1 result found for “<query/>”",
|
||||||
|
"other": "%(count)s results found for “<query/>”"
|
||||||
|
},
|
||||||
"this_room_button": "Search this room"
|
"this_room_button": "Search this room"
|
||||||
},
|
},
|
||||||
"show_labs_settings": "Show Labs settings",
|
"show_labs_settings": "Show Labs settings",
|
||||||
|
|
|
@ -77,10 +77,19 @@ export default class RightPanelStore extends ReadyWatchingStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onDispatcherAction(payload: ActionPayload): void {
|
protected onDispatcherAction(payload: ActionPayload): void {
|
||||||
if (payload.action !== Action.ActiveRoomChanged) return;
|
switch (payload.action) {
|
||||||
|
case Action.ActiveRoomChanged: {
|
||||||
|
const changePayload = <ActiveRoomChangedPayload>payload;
|
||||||
|
this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const changePayload = <ActiveRoomChangedPayload>payload;
|
case Action.FocusMessageSearch: {
|
||||||
this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId);
|
if (this.currentCard.phase !== RightPanelPhases.RoomSummary) {
|
||||||
|
this.setCard({ phase: RightPanelPhases.RoomSummary, state: { focusRoomSearch: true } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
|
|
|
@ -32,6 +32,8 @@ export interface IRightPanelCardState {
|
||||||
initialEvent?: MatrixEvent;
|
initialEvent?: MatrixEvent;
|
||||||
isInitialEventHighlighted?: boolean;
|
isInitialEventHighlighted?: boolean;
|
||||||
initialEventScrollIntoView?: boolean;
|
initialEventScrollIntoView?: boolean;
|
||||||
|
// room summary
|
||||||
|
focusRoomSearch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRightPanelCardStateStored {
|
export interface IRightPanelCardStateStored {
|
||||||
|
|
|
@ -65,6 +65,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={true}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={deferred.promise}
|
promise={deferred.promise}
|
||||||
|
@ -81,6 +82,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
render(
|
render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={Promise.resolve<ISearchResults>({
|
promise={Promise.resolve<ISearchResults>({
|
||||||
|
@ -142,6 +144,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
render(
|
render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.Room}
|
scope={SearchScope.Room}
|
||||||
promise={Promise.resolve<ISearchResults>({
|
promise={Promise.resolve<ISearchResults>({
|
||||||
|
@ -234,10 +237,30 @@ describe("<RoomSearchView/>", () => {
|
||||||
],
|
],
|
||||||
next_batch: undefined,
|
next_batch: undefined,
|
||||||
});
|
});
|
||||||
|
const onUpdate = jest.fn();
|
||||||
|
|
||||||
render(
|
const { rerender } = render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={true}
|
||||||
|
term="search term"
|
||||||
|
scope={SearchScope.All}
|
||||||
|
promise={Promise.resolve(searchResults)}
|
||||||
|
resizeNotifier={resizeNotifier}
|
||||||
|
className="someClass"
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByRole("progressbar");
|
||||||
|
await screen.findByText("Potato");
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith(false, expect.objectContaining({}));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MatrixClientContext.Provider value={client}>
|
||||||
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={Promise.resolve(searchResults)}
|
promise={Promise.resolve(searchResults)}
|
||||||
|
@ -248,8 +271,6 @@ describe("<RoomSearchView/>", () => {
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByRole("progressbar");
|
|
||||||
await screen.findByText("Potato");
|
|
||||||
expect(screen.queryByRole("progressbar")).toBeFalsy();
|
expect(screen.queryByRole("progressbar")).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -259,6 +280,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={deferred.promise}
|
promise={deferred.promise}
|
||||||
|
@ -282,6 +304,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={deferred.promise}
|
promise={deferred.promise}
|
||||||
|
@ -305,6 +328,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
render(
|
render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={deferred.promise}
|
promise={deferred.promise}
|
||||||
|
@ -406,6 +430,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
render(
|
render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={Promise.resolve(searchResults)}
|
promise={Promise.resolve(searchResults)}
|
||||||
|
@ -440,6 +465,7 @@ describe("<RoomSearchView/>", () => {
|
||||||
render(
|
render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<RoomSearchView
|
<RoomSearchView
|
||||||
|
inProgress={false}
|
||||||
term="search term"
|
term="search term"
|
||||||
scope={SearchScope.All}
|
scope={SearchScope.All}
|
||||||
promise={Promise.resolve<ISearchResults>({
|
promise={Promise.resolve<ISearchResults>({
|
||||||
|
|
51
test/components/views/elements/SearchWarning-test.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 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 { render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import SdkConfig from "../../../../src/SdkConfig";
|
||||||
|
import SearchWarning, { WarningKind } from "../../../../src/components/views/elements/SearchWarning";
|
||||||
|
|
||||||
|
describe("<SearchWarning />", () => {
|
||||||
|
describe("with desktop builds available", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
SdkConfig.put({
|
||||||
|
brand: "Element",
|
||||||
|
desktop_builds: {
|
||||||
|
available: true,
|
||||||
|
logo: "https://logo",
|
||||||
|
url: "https://url",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with a logo by default", () => {
|
||||||
|
const { asFragment, queryByRole } = render(
|
||||||
|
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} />,
|
||||||
|
);
|
||||||
|
expect(queryByRole("img")).toBeInTheDocument();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without a logo when showLogo=false", () => {
|
||||||
|
const { asFragment, queryByRole } = render(
|
||||||
|
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} showLogo={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryByRole("img")).not.toBeInTheDocument();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`<SearchWarning /> with desktop builds available renders with a logo by default 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="mx_SearchWarning"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src="https://logo"
|
||||||
|
width="32px"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
Use the
|
||||||
|
<a
|
||||||
|
href="https://url"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Desktop app
|
||||||
|
</a>
|
||||||
|
to search encrypted messages
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<SearchWarning /> with desktop builds available renders without a logo when showLogo=false 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="mx_SearchWarning"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
Use the
|
||||||
|
<a
|
||||||
|
href="https://url"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Desktop app
|
||||||
|
</a>
|
||||||
|
to search encrypted messages
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
|
@ -15,10 +15,11 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, fireEvent, screen } from "@testing-library/react";
|
import { render, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { EventType, MatrixEvent, Room, MatrixClient, JoinRule } from "matrix-js-sdk/src/matrix";
|
import { EventType, MatrixEvent, Room, MatrixClient, JoinRule } from "matrix-js-sdk/src/matrix";
|
||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||||
import { mocked, MockedObject } from "jest-mock";
|
import { mocked, MockedObject } from "jest-mock";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||||
import RoomSummaryCard from "../../../../src/components/views/right_panel/RoomSummaryCard";
|
import RoomSummaryCard from "../../../../src/components/views/right_panel/RoomSummaryCard";
|
||||||
|
@ -37,6 +38,8 @@ import { _t } from "../../../../src/languageHandler";
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||||
import { tagRoom } from "../../../../src/utils/room/tagRoom";
|
import { tagRoom } from "../../../../src/utils/room/tagRoom";
|
||||||
import { DefaultTagID } from "../../../../src/stores/room-list/models";
|
import { DefaultTagID } from "../../../../src/stores/room-list/models";
|
||||||
|
import { Action } from "../../../../src/dispatcher/actions";
|
||||||
|
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
|
||||||
|
|
||||||
jest.mock("../../../../src/utils/room/tagRoom");
|
jest.mock("../../../../src/utils/room/tagRoom");
|
||||||
|
|
||||||
|
@ -141,15 +144,82 @@ describe("<RoomSummaryCard />", () => {
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the search", async () => {
|
describe("search", () => {
|
||||||
const onSearchClick = jest.fn();
|
it("has the search field", async () => {
|
||||||
const { getByLabelText } = getComponent({
|
const onSearchChange = jest.fn();
|
||||||
onSearchClick,
|
const { getByPlaceholderText } = getComponent({
|
||||||
|
onSearchChange,
|
||||||
|
});
|
||||||
|
expect(getByPlaceholderText("Search messages…")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchBtn = getByLabelText(_t("action|search"));
|
it("should focus the search field if Action.FocusMessageSearch is fired", async () => {
|
||||||
fireEvent.click(searchBtn);
|
const onSearchChange = jest.fn();
|
||||||
expect(onSearchClick).toHaveBeenCalled();
|
const { getByPlaceholderText } = getComponent({
|
||||||
|
onSearchChange,
|
||||||
|
});
|
||||||
|
expect(getByPlaceholderText("Search messages…")).not.toHaveFocus();
|
||||||
|
defaultDispatcher.fire(Action.FocusMessageSearch);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByPlaceholderText("Search messages…")).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should focus the search field if focusRoomSearch=true", () => {
|
||||||
|
const onSearchChange = jest.fn();
|
||||||
|
const { getByPlaceholderText } = getComponent({
|
||||||
|
onSearchChange,
|
||||||
|
focusRoomSearch: true,
|
||||||
|
});
|
||||||
|
expect(getByPlaceholderText("Search messages…")).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cancel search on escape", () => {
|
||||||
|
const onSearchChange = jest.fn();
|
||||||
|
const onSearchCancel = jest.fn();
|
||||||
|
const { getByPlaceholderText } = getComponent({
|
||||||
|
onSearchChange,
|
||||||
|
onSearchCancel,
|
||||||
|
focusRoomSearch: true,
|
||||||
|
});
|
||||||
|
expect(getByPlaceholderText("Search messages…")).toHaveFocus();
|
||||||
|
fireEvent.keyDown(getByPlaceholderText("Search messages…"), { key: "Escape" });
|
||||||
|
expect(onSearchCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should empty search field when the timeline rendering type changes away", async () => {
|
||||||
|
const onSearchChange = jest.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Search } as any}>
|
||||||
|
<RoomSummaryCard
|
||||||
|
room={room}
|
||||||
|
permalinkCreator={new RoomPermalinkCreator(room)}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
focusRoomSearch={true}
|
||||||
|
/>
|
||||||
|
</RoomContext.Provider>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText("Search messages…"), "test");
|
||||||
|
expect(screen.getByPlaceholderText("Search messages…")).toHaveValue("test");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
|
||||||
|
<RoomSummaryCard
|
||||||
|
room={room}
|
||||||
|
permalinkCreator={new RoomPermalinkCreator(room)}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
/>
|
||||||
|
</RoomContext.Provider>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByPlaceholderText("Search messages…")).toHaveValue("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens room file panel on button click", () => {
|
it("opens room file panel on button click", () => {
|
||||||
|
|
|
@ -13,16 +13,6 @@ exports[`<RoomSummaryCard /> has button to edit topic when expanded 1`] = `
|
||||||
class="mx_Flex mx_RoomSummaryCard_header"
|
class="mx_Flex mx_RoomSummaryCard_header"
|
||||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);"
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);"
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
aria-label="Search"
|
|
||||||
class="mx_RoomSummaryCard_searchBtn"
|
|
||||||
data-testid="summary-search"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
height="100%"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="mx_AccessibleButton mx_BaseCard_close"
|
class="mx_AccessibleButton mx_BaseCard_close"
|
||||||
|
@ -443,16 +433,6 @@ exports[`<RoomSummaryCard /> renders the room summary 1`] = `
|
||||||
class="mx_Flex mx_RoomSummaryCard_header"
|
class="mx_Flex mx_RoomSummaryCard_header"
|
||||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);"
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);"
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
aria-label="Search"
|
|
||||||
class="mx_RoomSummaryCard_searchBtn"
|
|
||||||
data-testid="summary-search"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
height="100%"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="mx_AccessibleButton mx_BaseCard_close"
|
class="mx_AccessibleButton mx_BaseCard_close"
|
||||||
|
@ -846,16 +826,6 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
|
||||||
class="mx_Flex mx_RoomSummaryCard_header"
|
class="mx_Flex mx_RoomSummaryCard_header"
|
||||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);"
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);"
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
aria-label="Search"
|
|
||||||
class="mx_RoomSummaryCard_searchBtn"
|
|
||||||
data-testid="summary-search"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
height="100%"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="mx_AccessibleButton mx_BaseCard_close"
|
class="mx_AccessibleButton mx_BaseCard_close"
|
||||||
|
|
95
test/components/views/rooms/RoomSearchAuxPanel-test.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
/*
|
||||||
|
Copyright 2024 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 { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import RoomSearchAuxPanel from "../../../../src/components/views/rooms/RoomSearchAuxPanel";
|
||||||
|
import { SearchScope } from "../../../../src/Searching";
|
||||||
|
|
||||||
|
describe("RoomSearchAuxPanel", () => {
|
||||||
|
it("should render the count of results", () => {
|
||||||
|
render(
|
||||||
|
<RoomSearchAuxPanel
|
||||||
|
searchInfo={{
|
||||||
|
searchId: 1234,
|
||||||
|
count: 5,
|
||||||
|
term: "abcd",
|
||||||
|
scope: SearchScope.Room,
|
||||||
|
promise: new Promise(() => {}),
|
||||||
|
}}
|
||||||
|
isRoomEncrypted={false}
|
||||||
|
onSearchScopeChange={jest.fn()}
|
||||||
|
onCancelClick={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("5 results found for", { exact: false })).toHaveTextContent(
|
||||||
|
"5 results found for “abcd”",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow the user to toggle to all rooms search", async () => {
|
||||||
|
const onSearchScopeChange = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoomSearchAuxPanel
|
||||||
|
isRoomEncrypted={false}
|
||||||
|
onSearchScopeChange={onSearchScopeChange}
|
||||||
|
onCancelClick={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.getByText("Search all rooms").click();
|
||||||
|
expect(onSearchScopeChange).toHaveBeenCalledWith(SearchScope.All);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow the user to toggle back to room-specific search", async () => {
|
||||||
|
const onSearchScopeChange = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoomSearchAuxPanel
|
||||||
|
searchInfo={{
|
||||||
|
searchId: 1234,
|
||||||
|
term: "abcd",
|
||||||
|
scope: SearchScope.All,
|
||||||
|
promise: new Promise(() => {}),
|
||||||
|
}}
|
||||||
|
isRoomEncrypted={false}
|
||||||
|
onSearchScopeChange={onSearchScopeChange}
|
||||||
|
onCancelClick={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.getByText("Search this room").click();
|
||||||
|
expect(onSearchScopeChange).toHaveBeenCalledWith(SearchScope.Room);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow the user to cancel a search", async () => {
|
||||||
|
const onCancelClick = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoomSearchAuxPanel
|
||||||
|
isRoomEncrypted={false}
|
||||||
|
onSearchScopeChange={jest.fn()}
|
||||||
|
onCancelClick={onCancelClick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
screen.getByLabelText("Cancel").click();
|
||||||
|
expect(onCancelClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,98 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright 2022 Emmanuel Ezeka <eec.studies@gmail.com>
|
|
||||||
|
|
||||||
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 { fireEvent, render } from "@testing-library/react";
|
|
||||||
|
|
||||||
import SearchBar from "../../../../src/components/views/rooms/SearchBar";
|
|
||||||
import { KeyBindingAction } from "../../../../src/accessibility/KeyboardShortcuts";
|
|
||||||
import { SearchScope } from "../../../../src/Searching";
|
|
||||||
|
|
||||||
let mockCurrentEvent = KeyBindingAction.Enter;
|
|
||||||
|
|
||||||
const searchProps = {
|
|
||||||
onCancelClick: jest.fn(),
|
|
||||||
onSearch: jest.fn(),
|
|
||||||
searchInProgress: false,
|
|
||||||
isRoomEncrypted: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock("../../../../src/KeyBindingsManager", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
getKeyBindingsManager: jest.fn(() => ({ getAccessibilityAction: jest.fn(() => mockCurrentEvent) })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("SearchBar", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
searchProps.onCancelClick.mockClear();
|
|
||||||
searchProps.onSearch.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("must not search when input value is empty", () => {
|
|
||||||
const { container } = render(<SearchBar {...searchProps} />);
|
|
||||||
const roomButtons = container.querySelectorAll(".mx_SearchBar_button");
|
|
||||||
const searchButton = container.querySelectorAll(".mx_SearchBar_searchButton");
|
|
||||||
|
|
||||||
expect(roomButtons.length).toEqual(2);
|
|
||||||
|
|
||||||
fireEvent.click(searchButton[0]);
|
|
||||||
fireEvent.click(roomButtons[0]);
|
|
||||||
fireEvent.click(roomButtons[1]);
|
|
||||||
|
|
||||||
expect(searchProps.onSearch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("must trigger onSearch when value is not empty", () => {
|
|
||||||
const { container } = render(<SearchBar {...searchProps} />);
|
|
||||||
const searchValue = "abcd";
|
|
||||||
|
|
||||||
const roomButtons = container.querySelectorAll(".mx_SearchBar_button");
|
|
||||||
const searchButton = container.querySelectorAll(".mx_SearchBar_searchButton");
|
|
||||||
const input = container.querySelector<HTMLInputElement>(".mx_SearchBar_input input");
|
|
||||||
input!.value = searchValue;
|
|
||||||
|
|
||||||
expect(roomButtons.length).toEqual(2);
|
|
||||||
|
|
||||||
fireEvent.click(searchButton[0]);
|
|
||||||
|
|
||||||
expect(searchProps.onSearch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(searchProps.onSearch).toHaveBeenNthCalledWith(1, searchValue, SearchScope.Room);
|
|
||||||
|
|
||||||
fireEvent.click(roomButtons[0]);
|
|
||||||
|
|
||||||
expect(searchProps.onSearch).toHaveBeenCalledTimes(2);
|
|
||||||
expect(searchProps.onSearch).toHaveBeenNthCalledWith(2, searchValue, SearchScope.Room);
|
|
||||||
|
|
||||||
fireEvent.click(roomButtons[1]);
|
|
||||||
|
|
||||||
expect(searchProps.onSearch).toHaveBeenCalledTimes(3);
|
|
||||||
expect(searchProps.onSearch).toHaveBeenNthCalledWith(3, searchValue, SearchScope.All);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancel button and esc key should trigger onCancelClick", async () => {
|
|
||||||
mockCurrentEvent = KeyBindingAction.Escape;
|
|
||||||
const { container } = render(<SearchBar {...searchProps} />);
|
|
||||||
const cancelButton = container.querySelector(".mx_SearchBar_cancel");
|
|
||||||
const input = container.querySelector(".mx_SearchBar_input input");
|
|
||||||
fireEvent.click(cancelButton!);
|
|
||||||
expect(searchProps.onCancelClick).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
fireEvent.focus(input!);
|
|
||||||
fireEvent.keyDown(input!, { key: "Escape", code: "Escape", charCode: 27 });
|
|
||||||
|
|
||||||
expect(searchProps.onCancelClick).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|