Integrate searching public rooms and people into the new search experience (#8707)
* Implement searching for public rooms and users in new search experience * Implement loading indicator for spotlight results * Moved spotlight dialog into own subfolder * Extract search result avatar into separate component * Build generic new dropdown menu component * Build new network menu based on new network dropdown component * Switch roomdirectory to use new network dropdown * Replace old networkdropdown with new networkdropdown * Added component for public room result details * Extract hooks and subcomponents from SpotlightDialog * Create new hook to get profile info based for an mxid * Add hook to automatically re-request search results * Add hook to prevent out-of-order search results * Extract member sort algorithm from InviteDialog * Keep sorting for non-room results stable * Sort people suggestions using sort algorithm from InviteDialog * Add copy/copied tooltip for invite link option in spotlight * Clamp length of topic for public room results * Add unit test for useDebouncedSearch * Add unit test for useProfileInfo * Create cypress test cases for spotlight dialog * Add test for useLatestResult to prevent out-of-order results
This commit is contained in:
parent
37298d7b1b
commit
5096e7b992
38 changed files with 3520 additions and 1397 deletions
302
cypress/integration/12-spotlight/spotlight.spec.ts
Normal file
302
cypress/integration/12-spotlight/spotlight.spec.ts
Normal file
|
@ -0,0 +1,302 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import { MatrixClient } from "../../global";
|
||||
import { SynapseInstance } from "../../plugins/synapsedocker";
|
||||
import Chainable = Cypress.Chainable;
|
||||
import Loggable = Cypress.Loggable;
|
||||
import Timeoutable = Cypress.Timeoutable;
|
||||
import Withinable = Cypress.Withinable;
|
||||
import Shadow = Cypress.Shadow;
|
||||
|
||||
export enum Filter {
|
||||
People = "people",
|
||||
PublicRooms = "public_rooms"
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/**
|
||||
* Opens the spotlight dialog
|
||||
*/
|
||||
openSpotlightDialog(
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
|
||||
): Chainable<JQuery<HTMLElement>>;
|
||||
spotlightDialog(
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
|
||||
): Chainable<JQuery<HTMLElement>>;
|
||||
spotlightFilter(
|
||||
filter: Filter | null,
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
|
||||
): Chainable<JQuery<HTMLElement>>;
|
||||
spotlightSearch(
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
|
||||
): Chainable<JQuery<HTMLElement>>;
|
||||
spotlightResults(
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
|
||||
): Chainable<JQuery<HTMLElement>>;
|
||||
roomHeaderName(
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>
|
||||
): Chainable<JQuery<HTMLElement>>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add("openSpotlightDialog", (
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
|
||||
): Chainable<JQuery<HTMLElement>> => {
|
||||
cy.get('.mx_RoomSearch_spotlightTrigger', options).click({ force: true });
|
||||
return cy.spotlightDialog(options);
|
||||
});
|
||||
|
||||
Cypress.Commands.add("spotlightDialog", (
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
|
||||
): Chainable<JQuery<HTMLElement>> => {
|
||||
return cy.get('[role=dialog][aria-label="Search Dialog"]', options);
|
||||
});
|
||||
|
||||
Cypress.Commands.add("spotlightFilter", (
|
||||
filter: Filter | null,
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
|
||||
): Chainable<JQuery<HTMLElement>> => {
|
||||
let selector: string;
|
||||
switch (filter) {
|
||||
case Filter.People:
|
||||
selector = "#mx_SpotlightDialog_button_startChat";
|
||||
break;
|
||||
case Filter.PublicRooms:
|
||||
selector = "#mx_SpotlightDialog_button_explorePublicRooms";
|
||||
break;
|
||||
default:
|
||||
selector = ".mx_SpotlightDialog_filter";
|
||||
break;
|
||||
}
|
||||
return cy.get(selector, options).click();
|
||||
});
|
||||
|
||||
Cypress.Commands.add("spotlightSearch", (
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
|
||||
): Chainable<JQuery<HTMLElement>> => {
|
||||
return cy.get(".mx_SpotlightDialog_searchBox input", options);
|
||||
});
|
||||
|
||||
Cypress.Commands.add("spotlightResults", (
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
|
||||
): Chainable<JQuery<HTMLElement>> => {
|
||||
return cy.get(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", options);
|
||||
});
|
||||
|
||||
Cypress.Commands.add("roomHeaderName", (
|
||||
options?: Partial<Loggable & Timeoutable & Withinable & Shadow>,
|
||||
): Chainable<JQuery<HTMLElement>> => {
|
||||
return cy.get(".mx_RoomHeader_nametext", options);
|
||||
});
|
||||
|
||||
describe("Spotlight", () => {
|
||||
let synapse: SynapseInstance;
|
||||
|
||||
const bot1Name = "BotBob";
|
||||
let bot1: MatrixClient;
|
||||
|
||||
const bot2Name = "ByteBot";
|
||||
let bot2: MatrixClient;
|
||||
|
||||
const room1Name = "247";
|
||||
let room1Id: string;
|
||||
|
||||
const room2Name = "Lounge";
|
||||
let room2Id: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cy.enableLabsFeature("feature_spotlight");
|
||||
cy.startSynapse("default").then(data => {
|
||||
synapse = data;
|
||||
cy.initTestUser(synapse, "Jim").then(() =>
|
||||
cy.getBot(synapse, bot1Name).then(_bot1 => {
|
||||
bot1 = _bot1;
|
||||
}),
|
||||
).then(() =>
|
||||
cy.getBot(synapse, bot2Name).then(_bot2 => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
bot2 = _bot2;
|
||||
}),
|
||||
).then(() =>
|
||||
cy.window({ log: false }).then(({ matrixcs: { Visibility } }) => {
|
||||
cy.createRoom({ name: room1Name, visibility: Visibility.Public }).then(_room1Id => {
|
||||
room1Id = _room1Id;
|
||||
cy.inviteUser(room1Id, bot1.getUserId());
|
||||
cy.visit("/#/room/" + room1Id);
|
||||
});
|
||||
bot2.createRoom({ name: room2Name, visibility: Visibility.Public })
|
||||
.then(({ room_id: _room2Id }) => {
|
||||
room2Id = _room2Id;
|
||||
bot2.invite(room2Id, bot1.getUserId());
|
||||
});
|
||||
}),
|
||||
).then(() =>
|
||||
cy.get('.mx_RoomSublist_skeletonUI').should('not.exist'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cy.stopSynapse(synapse);
|
||||
});
|
||||
|
||||
it("should be able to add and remove filters via keyboard", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightSearch().type("{downArrow}");
|
||||
cy.get("#mx_SpotlightDialog_button_explorePublicRooms").should("have.attr", "aria-selected", "true");
|
||||
cy.spotlightSearch().type("{enter}");
|
||||
cy.get(".mx_SpotlightDialog_filter").should("contain", "Public rooms");
|
||||
cy.spotlightSearch().type("{backspace}");
|
||||
cy.get(".mx_SpotlightDialog_filter").should("not.exist");
|
||||
|
||||
cy.spotlightSearch().type("{downArrow}");
|
||||
cy.spotlightSearch().type("{downArrow}");
|
||||
cy.get("#mx_SpotlightDialog_button_startChat").should("have.attr", "aria-selected", "true");
|
||||
cy.spotlightSearch().type("{enter}");
|
||||
cy.get(".mx_SpotlightDialog_filter").should("contain", "People");
|
||||
cy.spotlightSearch().type("{backspace}");
|
||||
cy.get(".mx_SpotlightDialog_filter").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
it("should find joined rooms", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightSearch().clear().type(room1Name);
|
||||
cy.spotlightResults().should("have.length", 1);
|
||||
cy.spotlightResults().eq(0).should("contain", room1Name);
|
||||
cy.spotlightResults().eq(0).click();
|
||||
cy.url().should("contain", room1Id);
|
||||
}).then(() => {
|
||||
cy.roomHeaderName().should("contain", room1Name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should find known public rooms", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightFilter(Filter.PublicRooms);
|
||||
cy.spotlightSearch().clear().type(room1Name);
|
||||
cy.spotlightResults().should("have.length", 1);
|
||||
cy.spotlightResults().eq(0).should("contain", room1Name);
|
||||
cy.spotlightResults().eq(0).click();
|
||||
cy.url().should("contain", room1Id);
|
||||
}).then(() => {
|
||||
cy.roomHeaderName().should("contain", room1Name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should find unknown public rooms", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightFilter(Filter.PublicRooms);
|
||||
cy.spotlightSearch().clear().type(room2Name);
|
||||
cy.spotlightResults().should("have.length", 1);
|
||||
cy.spotlightResults().eq(0).should("contain", room2Name);
|
||||
cy.spotlightResults().eq(0).click();
|
||||
cy.url().should("contain", room2Id);
|
||||
}).then(() => {
|
||||
cy.get(".mx_RoomPreviewBar_actions .mx_AccessibleButton").click();
|
||||
cy.roomHeaderName().should("contain", room2Name);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: We currently can’t test finding rooms on other homeservers/other protocols
|
||||
// We obviously don’t have federation or bridges in cypress tests
|
||||
/*
|
||||
const room3Name = "Matrix HQ";
|
||||
const room3Id = "#matrix:matrix.org";
|
||||
|
||||
it("should find unknown public rooms on other homeservers", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightFilter(Filter.PublicRooms);
|
||||
cy.spotlightSearch().clear().type(room3Name);
|
||||
cy.get("[aria-haspopup=true][role=button]").click();
|
||||
}).then(() => {
|
||||
cy.contains(".mx_GenericDropdownMenu_Option--header", "matrix.org")
|
||||
.next("[role=menuitemradio]")
|
||||
.click();
|
||||
cy.wait(3_600_000);
|
||||
}).then(() => cy.spotlightDialog().within(() => {
|
||||
cy.spotlightResults().should("have.length", 1);
|
||||
cy.spotlightResults().eq(0).should("contain", room3Name);
|
||||
cy.spotlightResults().eq(0).should("contain", room3Id);
|
||||
}));
|
||||
});
|
||||
*/
|
||||
it("should find known people", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightFilter(Filter.People);
|
||||
cy.spotlightSearch().clear().type(bot1Name);
|
||||
cy.spotlightResults().should("have.length", 1);
|
||||
cy.spotlightResults().eq(0).should("contain", bot1Name);
|
||||
cy.spotlightResults().eq(0).click();
|
||||
}).then(() => {
|
||||
cy.roomHeaderName().should("contain", bot1Name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should find unknown people", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightFilter(Filter.People);
|
||||
cy.spotlightSearch().clear().type(bot2Name);
|
||||
cy.spotlightResults().should("have.length", 1);
|
||||
cy.spotlightResults().eq(0).should("contain", bot2Name);
|
||||
cy.spotlightResults().eq(0).click();
|
||||
}).then(() => {
|
||||
cy.roomHeaderName().should("contain", bot2Name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow opening group chat dialog", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightFilter(Filter.People);
|
||||
cy.spotlightSearch().clear().type(bot2Name);
|
||||
cy.spotlightResults().should("have.length", 1);
|
||||
cy.spotlightResults().eq(0).should("contain", bot2Name);
|
||||
cy.get(".mx_SpotlightDialog_startGroupChat").should("contain", "Start a group chat");
|
||||
cy.get(".mx_SpotlightDialog_startGroupChat").click();
|
||||
}).then(() => {
|
||||
cy.get('[role=dialog]').should("contain", "Direct Messages");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to navigate results via keyboard", () => {
|
||||
cy.openSpotlightDialog().within(() => {
|
||||
cy.spotlightFilter(Filter.People);
|
||||
cy.spotlightSearch().clear().type("b");
|
||||
cy.spotlightResults().should("have.length", 2);
|
||||
cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "true");
|
||||
cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "false");
|
||||
cy.spotlightSearch().type("{downArrow}");
|
||||
cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "false");
|
||||
cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "true");
|
||||
cy.spotlightSearch().type("{downArrow}");
|
||||
cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "false");
|
||||
cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "false");
|
||||
cy.spotlightSearch().type("{upArrow}");
|
||||
cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "false");
|
||||
cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "true");
|
||||
cy.spotlightSearch().type("{upArrow}");
|
||||
cy.spotlightResults().eq(0).should("have.attr", "aria-selected", "true");
|
||||
cy.spotlightResults().eq(1).should("have.attr", "aria-selected", "false");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -28,9 +28,10 @@ describe("Threads", () => {
|
|||
let synapse: SynapseInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Default threads to ON for this spec
|
||||
cy.enableLabsFeature("feature_thread");
|
||||
cy.window().then(win => {
|
||||
win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests
|
||||
win.localStorage.setItem("mx_labs_feature_feature_thread", "true"); // Default threads to ON for this spec
|
||||
});
|
||||
cy.startSynapse("default").then(data => {
|
||||
synapse = data;
|
||||
|
|
|
@ -22,6 +22,7 @@ import "cypress-real-events";
|
|||
import "./performance";
|
||||
import "./synapse";
|
||||
import "./login";
|
||||
import "./labs";
|
||||
import "./client";
|
||||
import "./settings";
|
||||
import "./bot";
|
||||
|
|
42
cypress/support/labs.ts
Normal file
42
cypress/support/labs.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
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 Chainable = Cypress.Chainable;
|
||||
|
||||
/// <reference types="cypress" />
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/**
|
||||
* Enables a labs feature for an element session.
|
||||
* Has to be called before the session is initialized
|
||||
* @param feature labsFeature to enable (e.g. "feature_spotlight")
|
||||
*/
|
||||
enableLabsFeature(feature: string): Chainable<null>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add("enableLabsFeature", (feature: string): Chainable<null> => {
|
||||
return cy.window({ log: false }).then(win => {
|
||||
win.localStorage.setItem(`mx_labs_feature_${feature}`, "true");
|
||||
}).then(() => null);
|
||||
});
|
||||
|
||||
// Needed to make this file a module
|
||||
export { };
|
|
@ -34,6 +34,7 @@
|
|||
@import "./structures/_FileDropTarget.scss";
|
||||
@import "./structures/_FilePanel.scss";
|
||||
@import "./structures/_GenericErrorPage.scss";
|
||||
@import "./structures/_GenericDropdownMenu.scss";
|
||||
@import "./structures/_HeaderButtons.scss";
|
||||
@import "./structures/_HomePage.scss";
|
||||
@import "./structures/_LeftPanel.scss";
|
||||
|
|
123
res/css/structures/_GenericDropdownMenu.scss
Normal file
123
res/css/structures/_GenericDropdownMenu.scss
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.mx_GenericDropdownMenu_button {
|
||||
padding: 3px 4px 3px 8px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
user-select: none;
|
||||
font-size: $font-12px;
|
||||
color: $secondary-content;
|
||||
}
|
||||
|
||||
.mx_GenericDropdownMenu_button:hover,
|
||||
.mx_GenericDropdownMenu_button[aria-expanded=true] {
|
||||
background: $quinary-content;
|
||||
}
|
||||
|
||||
.mx_GenericDropdownMenu_button::before {
|
||||
content: "";
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: currentColor;
|
||||
mask-image: url("$(res)/img/feather-customised/chevron-down.svg");
|
||||
mask-size: 100%;
|
||||
mask-repeat: no-repeat;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.mx_ContextualMenu_wrapper.mx_GenericDropdownMenu_wrapper {
|
||||
.mx_ContextualMenu {
|
||||
position: initial;
|
||||
|
||||
font-size: $font-12px;
|
||||
color: $secondary-content;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
|
||||
border: 1px solid $quinary-content;
|
||||
box-shadow: 0 1px 3px rgba(23, 25, 28, 0.05);
|
||||
}
|
||||
|
||||
.mx_ContextualMenu_chevron_top {
|
||||
left: auto;
|
||||
right: 22px;
|
||||
border-bottom-color: $quinary-content;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
border: inherit;
|
||||
border-bottom-color: $menu-bg-color;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_GenericDropdownMenu_divider {
|
||||
display: block;
|
||||
height: 0;
|
||||
margin-left: 4px;
|
||||
margin-right: 19px;
|
||||
border-top: 1px solid $system;
|
||||
}
|
||||
|
||||
.mx_GenericDropdownMenu_Option {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 20px 10px 30px;
|
||||
position: relative;
|
||||
|
||||
> .mx_GenericDropdownMenu_Option--label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
margin: 0;
|
||||
|
||||
span:first-child {
|
||||
color: $primary-content;
|
||||
font-weight: $font-semi-bold;
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_GenericDropdownMenu_Option--header > .mx_GenericDropdownMenu_Option--label span:first-child {
|
||||
font-size: $font-15px;
|
||||
}
|
||||
|
||||
&.mx_GenericDropdownMenu_Option--item {
|
||||
&:hover {
|
||||
background-color: $event-selected-color;
|
||||
}
|
||||
|
||||
&[aria-checked="true"]::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -20px;
|
||||
margin-right: 8px;
|
||||
mask-image: url("$(res)/img/feather-customised/check.svg");
|
||||
mask-size: 100%;
|
||||
mask-repeat: no-repeat;
|
||||
background-color: $primary-content;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -54,8 +54,9 @@ limitations under the License.
|
|||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.mx_RoomDirectory_listheader .mx_NetworkDropdown {
|
||||
flex: 0 0 200px;
|
||||
.mx_RoomDirectory_listheader .mx_GenericDropdownMenu_button {
|
||||
margin: 0 9px 0 auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.mx_RoomDirectory_tableWrapper {
|
||||
|
|
|
@ -160,14 +160,14 @@ limitations under the License.
|
|||
padding-right: 8px;
|
||||
color: #ffffff; // this is fine without a var because it's for both themes
|
||||
|
||||
.mx_InviteDialog_userTile_avatar {
|
||||
.mx_SearchResultAvatar {
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
left: -5px;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
img.mx_InviteDialog_userTile_avatar {
|
||||
img.mx_SearchResultAvatar {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
|
@ -175,7 +175,7 @@ limitations under the License.
|
|||
vertical-align: top;
|
||||
}
|
||||
|
||||
.mx_InviteDialog_userTile_threepidAvatar {
|
||||
.mx_SearchResultAvatar_threepidAvatar {
|
||||
background-color: #ffffff; // this is fine without a var because it's for both themes
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,6 +61,69 @@ limitations under the License.
|
|||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $system;
|
||||
|
||||
> .mx_SpotlightDialog_filter {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
margin-right: 8px;
|
||||
background-color: $quinary-content;
|
||||
vertical-align: middle;
|
||||
color: $primary-content;
|
||||
position: relative;
|
||||
padding: 4px 8px 4px 37px;
|
||||
|
||||
&::before {
|
||||
background-color: $secondary-content;
|
||||
content: "";
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&.mx_SpotlightDialog_filterPeople::before {
|
||||
mask-image: url('$(res)/img/element-icons/room/members.svg');
|
||||
}
|
||||
|
||||
&.mx_SpotlightDialog_filterPublicRooms::before {
|
||||
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_filter--close {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: $system;
|
||||
border-radius: 8px;
|
||||
margin-left: 8px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
color: $secondary-content;
|
||||
|
||||
&::before {
|
||||
background-color: $secondary-content;
|
||||
content: "";
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
mask-image: url("$(res)/img/cancel-small.svg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> input {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
|
@ -73,20 +136,37 @@ limitations under the License.
|
|||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
}
|
||||
|
||||
> .mx_Spinner {
|
||||
flex-grow: 0;
|
||||
width: unset;
|
||||
height: unset;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
#mx_SpotlightDialog_content {
|
||||
margin: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
|
||||
.mx_SpotlightDialog_section {
|
||||
> h4 {
|
||||
> h4, > .mx_SpotlightDialog_sectionHeader > h4 {
|
||||
font-weight: $font-semi-bold;
|
||||
font-size: $font-12px;
|
||||
line-height: $font-15px;
|
||||
color: $secondary-content;
|
||||
margin-top: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> h4 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
@ -103,7 +183,7 @@ limitations under the License.
|
|||
margin-right: 1px; // occlude the 1px visible of the very next tile to prevent it looking broken
|
||||
}
|
||||
|
||||
.mx_AccessibleButton {
|
||||
.mx_SpotlightDialog_option {
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
color: $primary-content;
|
||||
|
@ -122,7 +202,7 @@ limitations under the License.
|
|||
margin: 0 9px 4px; // maintain centering
|
||||
}
|
||||
|
||||
& + .mx_AccessibleButton {
|
||||
& + .mx_SpotlightDialog_option {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
|
@ -134,8 +214,9 @@ limitations under the License.
|
|||
|
||||
.mx_SpotlightDialog_results,
|
||||
.mx_SpotlightDialog_recentSearches,
|
||||
.mx_SpotlightDialog_otherSearches {
|
||||
.mx_AccessibleButton {
|
||||
.mx_SpotlightDialog_otherSearches,
|
||||
.mx_SpotlightDialog_hiddenResults {
|
||||
.mx_SpotlightDialog_option {
|
||||
padding: 6px 4px;
|
||||
border-radius: 8px;
|
||||
font-size: $font-15px;
|
||||
|
@ -148,6 +229,20 @@ limitations under the License.
|
|||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&.mx_SpotlightDialog_result_multiline {
|
||||
align-items: start;
|
||||
|
||||
.mx_AccessibleButton {
|
||||
padding: 4px 20px;
|
||||
margin: 2px 4px;
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_enterPrompt {
|
||||
margin-top: 9px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .mx_SpotlightDialog_metaspaceResult,
|
||||
> .mx_DecoratedRoomAvatar,
|
||||
> .mx_BaseAvatar {
|
||||
|
@ -161,6 +256,44 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_result_publicRoomDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
|
||||
.mx_SpotlightDialog_result_publicRoomHeader {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
line-height: $font-24px;
|
||||
margin-right: 8px;
|
||||
|
||||
.mx_SpotlightDialog_result_publicRoomName {
|
||||
color: $primary-content;
|
||||
font-size: $font-15px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mx_SpotlightDialog_result_publicRoomAlias {
|
||||
color: $tertiary-content;
|
||||
font-size: $font-12px;
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.mx_SpotlightDialog_result_publicRoomDescription {
|
||||
display: -webkit-box;
|
||||
color: $secondary-content;
|
||||
font-size: $font-12px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
line-height: $font-20px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_NotificationBadge {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
@ -175,10 +308,43 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_inviteLink,
|
||||
.mx_SpotlightDialog_createRoom {
|
||||
margin-top: 8px;
|
||||
|
||||
.mx_AccessibleButton {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 3px 8px 3px 28px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
left: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: $accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_inviteLink .mx_AccessibleButton::before {
|
||||
mask-image: url("$(res)/img/element-icons/link.svg");
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_createRoom .mx_AccessibleButton::before {
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/hash.svg");
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_otherSearches {
|
||||
.mx_SpotlightDialog_startChat,
|
||||
.mx_SpotlightDialog_joinRoomAlias,
|
||||
.mx_SpotlightDialog_explorePublicRooms {
|
||||
.mx_SpotlightDialog_explorePublicRooms,
|
||||
.mx_SpotlightDialog_startGroupChat {
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
|
||||
|
@ -209,6 +375,10 @@ limitations under the License.
|
|||
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_startGroupChat::before {
|
||||
mask-image: url('$(res)/img/element-icons/group-members.svg');
|
||||
}
|
||||
|
||||
.mx_SpotlightDialog_otherSearches_messageSearchText {
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
|
@ -14,151 +14,40 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_NetworkDropdown {
|
||||
height: 32px;
|
||||
position: relative;
|
||||
width: max-content;
|
||||
padding-right: 32px;
|
||||
margin-left: auto;
|
||||
margin-right: 9px;
|
||||
margin-top: 12px;
|
||||
|
||||
.mx_AccessibleButton {
|
||||
width: max-content;
|
||||
}
|
||||
.mx_NetworkDropdown_wrapper .mx_ContextualMenu {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_menu {
|
||||
min-width: 204px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $dialog-close-fg-color;
|
||||
background-color: $background;
|
||||
max-height: calc(100vh - 20px); // allow 10px padding on both top and bottom
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_menu_network {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_server {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid $input-darker-fg-color;
|
||||
|
||||
.mx_NetworkDropdown_server_title {
|
||||
padding: 0 10px;
|
||||
.mx_NetworkDropdown_addServer {
|
||||
font-weight: normal;
|
||||
font-size: $font-15px;
|
||||
font-weight: 600;
|
||||
line-height: $font-20px;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
|
||||
// remove server button
|
||||
.mx_AccessibleButton {
|
||||
position: absolute;
|
||||
display: inline;
|
||||
right: 10px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-top: 2px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-image: url('$(res)/img/feather-customised/x.svg');
|
||||
background-color: $alert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_server_subtitle {
|
||||
padding: 0 10px;
|
||||
font-size: $font-10px;
|
||||
line-height: $font-14px;
|
||||
margin-top: -4px;
|
||||
margin-bottom: 4px;
|
||||
color: $muted-fg-color;
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_server_network {
|
||||
font-size: $font-12px;
|
||||
line-height: $font-16px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&[aria-checked=true]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
right: 10px;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-image: url('$(res)/img/feather-customised/check.svg');
|
||||
background-color: $accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_server_add,
|
||||
.mx_NetworkDropdown_server_network {
|
||||
&:hover {
|
||||
background-color: $header-panel-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_server_add {
|
||||
padding: 16px 10px 16px 32px;
|
||||
.mx_NetworkDropdown_removeServer {
|
||||
position: relative;
|
||||
border-radius: 0 0 4px 4px;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: $system;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
color: $secondary-content;
|
||||
margin-left: auto;
|
||||
|
||||
&::before {
|
||||
background-color: $secondary-content;
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 7px;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-image: url('$(res)/img/feather-customised/plus.svg');
|
||||
background-color: $muted-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_handle {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
right: -27.5px; // - (width: 26 + spacing to align with X above: 1.5)
|
||||
top: -3px;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-image: url('$(res)/img/feather-customised/chevron-down-thin.svg');
|
||||
background-color: $primary-content;
|
||||
}
|
||||
|
||||
.mx_NetworkDropdown_handle_server {
|
||||
color: $muted-fg-color;
|
||||
font-size: $font-12px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
mask-image: url("$(res)/img/cancel-small.svg");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
183
src/components/structures/GenericDropdownMenu.tsx
Normal file
183
src/components/structures/GenericDropdownMenu.tsx
Normal file
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import React, { FunctionComponent, Key, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
|
||||
import { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import ContextMenu, { aboveLeftOf, ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
|
||||
export type GenericDropdownMenuOption<T> = {
|
||||
key: T;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
adornment?: ReactNode;
|
||||
};
|
||||
|
||||
export type GenericDropdownMenuGroup<T> = GenericDropdownMenuOption<T> & {
|
||||
options: GenericDropdownMenuOption<T>[];
|
||||
};
|
||||
|
||||
export type GenericDropdownMenuItem<T> = GenericDropdownMenuGroup<T> | GenericDropdownMenuOption<T>;
|
||||
|
||||
export function GenericDropdownMenuOption<T extends Key>({
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
isSelected,
|
||||
adornment,
|
||||
}: GenericDropdownMenuOption<T> & {
|
||||
onClick: (ev: ButtonEvent) => void;
|
||||
isSelected: boolean;
|
||||
}): JSX.Element {
|
||||
return <MenuItemRadio
|
||||
active={isSelected}
|
||||
className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="mx_GenericDropdownMenu_Option--label">
|
||||
<span>{ label }</span>
|
||||
<span>{ description }</span>
|
||||
</div>
|
||||
{ adornment }
|
||||
</MenuItemRadio>;
|
||||
}
|
||||
|
||||
export function GenericDropdownMenuGroup<T extends Key>({
|
||||
label,
|
||||
description,
|
||||
adornment,
|
||||
children,
|
||||
}: PropsWithChildren<GenericDropdownMenuOption<T>>): JSX.Element {
|
||||
return <>
|
||||
<div className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--header">
|
||||
<div className="mx_GenericDropdownMenu_Option--label">
|
||||
<span>{ label }</span>
|
||||
<span>{ description }</span>
|
||||
</div>
|
||||
{ adornment }
|
||||
</div>
|
||||
{ children }
|
||||
</>;
|
||||
}
|
||||
|
||||
function isGenericDropdownMenuGroup<T>(
|
||||
item: GenericDropdownMenuItem<T>,
|
||||
): item is GenericDropdownMenuGroup<T> {
|
||||
return "options" in item;
|
||||
}
|
||||
|
||||
type WithKeyFunction<T> = T extends Key ? {
|
||||
toKey?: (key: T) => Key;
|
||||
} : {
|
||||
toKey: (key: T) => Key;
|
||||
};
|
||||
|
||||
type IProps<T> = WithKeyFunction<T> & {
|
||||
value: T;
|
||||
options: (readonly GenericDropdownMenuOption<T>[] | readonly GenericDropdownMenuGroup<T>[]);
|
||||
onChange: (option: T) => void;
|
||||
selectedLabel: (option: GenericDropdownMenuItem<T> | null | undefined) => ReactNode;
|
||||
onOpen?: (ev: ButtonEvent) => void;
|
||||
onClose?: (ev: ButtonEvent) => void;
|
||||
className?: string;
|
||||
AdditionalOptions?: FunctionComponent<{
|
||||
menuDisplayed: boolean;
|
||||
closeMenu: () => void;
|
||||
openMenu: () => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function GenericDropdownMenu<T>(
|
||||
{ value, onChange, options, selectedLabel, onOpen, onClose, toKey, className, AdditionalOptions }: IProps<T>,
|
||||
): JSX.Element {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
|
||||
|
||||
const selected: GenericDropdownMenuItem<T> | null = options
|
||||
.flatMap(it => isGenericDropdownMenuGroup(it) ? [it, ...it.options] : [it])
|
||||
.find(option => toKey ? toKey(option.key) === toKey(value) : option.key === value);
|
||||
let contextMenuOptions: JSX.Element;
|
||||
if (options && isGenericDropdownMenuGroup(options[0])) {
|
||||
contextMenuOptions = <>
|
||||
{ options.map(group => (
|
||||
<GenericDropdownMenuGroup
|
||||
key={toKey?.(group.key) ?? group.key}
|
||||
label={group.label}
|
||||
description={group.description}
|
||||
adornment={group.adornment}
|
||||
>
|
||||
{ group.options.map(option => (
|
||||
<GenericDropdownMenuOption
|
||||
key={toKey?.(option.key) ?? option.key}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
onChange(option.key);
|
||||
closeMenu();
|
||||
onClose?.(ev);
|
||||
}}
|
||||
adornment={option.adornment}
|
||||
isSelected={option === selected}
|
||||
/>
|
||||
)) }
|
||||
</GenericDropdownMenuGroup>
|
||||
)) }
|
||||
</>;
|
||||
} else {
|
||||
contextMenuOptions = <>
|
||||
{ options.map(option => (
|
||||
<GenericDropdownMenuOption
|
||||
key={toKey?.(option.key) ?? option.key}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
onChange(option.key);
|
||||
closeMenu();
|
||||
onClose?.(ev);
|
||||
}}
|
||||
adornment={option.adornment}
|
||||
isSelected={option === selected}
|
||||
/>
|
||||
)) }
|
||||
</>;
|
||||
}
|
||||
const contextMenu = menuDisplayed ? <ContextMenu
|
||||
onFinished={closeMenu}
|
||||
chevronFace={ChevronFace.Top}
|
||||
wrapperClassName={classNames("mx_GenericDropdownMenu_wrapper", className)}
|
||||
{...aboveLeftOf(button.current.getBoundingClientRect())}
|
||||
>
|
||||
{ contextMenuOptions }
|
||||
{ AdditionalOptions && (
|
||||
<AdditionalOptions menuDisplayed={menuDisplayed} openMenu={openMenu} closeMenu={closeMenu} />
|
||||
) }
|
||||
</ContextMenu> : null;
|
||||
return <>
|
||||
<ContextMenuButton
|
||||
className="mx_GenericDropdownMenu_button"
|
||||
inputRef={button}
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
openMenu();
|
||||
onOpen?.(ev);
|
||||
}}
|
||||
>
|
||||
{ selectedLabel(selected) }
|
||||
</ContextMenuButton>
|
||||
{ contextMenu }
|
||||
</>;
|
||||
}
|
|
@ -27,9 +27,9 @@ import Modal from "../../Modal";
|
|||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId, ALL_ROOMS, Protocols } from '../../utils/DirectoryUtils';
|
||||
import NetworkDropdown from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { IDialogProps } from "../views/dialogs/IDialogProps";
|
||||
import { IPublicRoomDirectoryConfig, NetworkDropdown } from "../views/directory/NetworkDropdown";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||
|
@ -54,16 +54,15 @@ interface IState {
|
|||
publicRooms: IPublicRoomsChunkRoom[];
|
||||
loading: boolean;
|
||||
protocolsLoading: boolean;
|
||||
error?: string;
|
||||
instanceId: string;
|
||||
roomServer: string;
|
||||
error?: string | null;
|
||||
serverConfig: IPublicRoomDirectoryConfig | null;
|
||||
filterString: string;
|
||||
}
|
||||
|
||||
export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
private unmounted = false;
|
||||
private nextBatch: string = null;
|
||||
private filterTimeout: number;
|
||||
private nextBatch: string | null = null;
|
||||
private filterTimeout: number | null;
|
||||
private protocols: Protocols;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -77,10 +76,10 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
const myHomeserver = MatrixClientPeg.getHomeserverName();
|
||||
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
|
||||
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
|
||||
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY) ?? undefined;
|
||||
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined;
|
||||
|
||||
let roomServer = myHomeserver;
|
||||
let roomServer: string | undefined = myHomeserver;
|
||||
if (
|
||||
SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) ||
|
||||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
|
||||
|
@ -88,7 +87,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
roomServer = lsRoomServer;
|
||||
}
|
||||
|
||||
let instanceId: string = null;
|
||||
let instanceId: string | undefined = undefined;
|
||||
if (roomServer === myHomeserver && (
|
||||
lsInstanceId === ALL_ROOMS ||
|
||||
Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
|
||||
|
@ -97,11 +96,11 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// Refresh the room list only if validation failed and we had to change these
|
||||
if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) {
|
||||
if (this.state.serverConfig?.instanceId !== instanceId ||
|
||||
this.state.serverConfig?.roomServer !== roomServer) {
|
||||
this.setState({
|
||||
protocolsLoading: false,
|
||||
instanceId,
|
||||
roomServer,
|
||||
serverConfig: roomServer ? { instanceId, roomServer } : null,
|
||||
});
|
||||
this.refreshRoomList();
|
||||
return;
|
||||
|
@ -127,12 +126,20 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
let serverConfig: IPublicRoomDirectoryConfig | null = null;
|
||||
const roomServer = localStorage.getItem(LAST_SERVER_KEY);
|
||||
if (roomServer) {
|
||||
serverConfig = {
|
||||
roomServer,
|
||||
instanceId: localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
|
||||
roomServer: localStorage.getItem(LAST_SERVER_KEY),
|
||||
serverConfig,
|
||||
filterString: this.props.initialText || "",
|
||||
protocolsLoading,
|
||||
};
|
||||
|
@ -166,7 +173,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
|
||||
const filterString = this.state.filterString;
|
||||
const roomServer = this.state.roomServer;
|
||||
const roomServer = this.state.serverConfig?.roomServer;
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const nextBatch = this.nextBatch;
|
||||
|
@ -174,17 +181,17 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
}
|
||||
if (this.state.instanceId === ALL_ROOMS) {
|
||||
if (this.state.serverConfig?.instanceId === ALL_ROOMS) {
|
||||
opts.include_all_networks = true;
|
||||
} else if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId as string;
|
||||
} else if (this.state.serverConfig?.instanceId) {
|
||||
opts.third_party_instance_id = this.state.serverConfig?.instanceId as string;
|
||||
}
|
||||
if (this.nextBatch) opts.since = this.nextBatch;
|
||||
if (filterString) opts.filter = { generic_search_term: filterString };
|
||||
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
|
||||
if (
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
roomServer != this.state.serverConfig?.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// if the filter or server has changed since this request was sent,
|
||||
// throw away the result (don't even clear the busy flag
|
||||
|
@ -197,7 +204,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
return false;
|
||||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.nextBatch = data.next_batch ?? null;
|
||||
this.setState((s) => ({
|
||||
...s,
|
||||
publicRooms: [...s.publicRooms, ...(data.chunk || [])],
|
||||
|
@ -207,7 +214,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}, (err) => {
|
||||
if (
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
roomServer != this.state.serverConfig?.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// as above: we don't care about errors for old requests either
|
||||
return false;
|
||||
|
@ -227,6 +234,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')
|
||||
),
|
||||
});
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -279,7 +287,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onOptionChange = (server: string, instanceId?: string) => {
|
||||
private onOptionChange = (serverConfig: IPublicRoomDirectoryConfig) => {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
|
@ -287,8 +295,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
// spend time filtering lots of rooms when we're about to
|
||||
// to clear the list anyway.
|
||||
publicRooms: [],
|
||||
roomServer: server,
|
||||
instanceId: instanceId,
|
||||
serverConfig,
|
||||
error: null,
|
||||
}, this.refreshRoomList);
|
||||
// We also refresh the room list each time even though this
|
||||
|
@ -299,9 +306,9 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
// Easiest to just blow away the state & re-fetch.
|
||||
|
||||
// We have to be careful here so that we don't set instanceId = "undefined"
|
||||
localStorage.setItem(LAST_SERVER_KEY, server);
|
||||
if (instanceId) {
|
||||
localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
|
||||
localStorage.setItem(LAST_SERVER_KEY, serverConfig.roomServer);
|
||||
if (serverConfig.instanceId) {
|
||||
localStorage.setItem(LAST_INSTANCE_KEY, serverConfig.instanceId);
|
||||
} else {
|
||||
localStorage.removeItem(LAST_INSTANCE_KEY);
|
||||
}
|
||||
|
@ -346,8 +353,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
const cli = MatrixClientPeg.get();
|
||||
try {
|
||||
joinRoomByAlias(cli, alias, {
|
||||
instanceId: this.state.instanceId,
|
||||
roomServer: this.state.roomServer,
|
||||
instanceId: this.state.serverConfig?.instanceId,
|
||||
roomServer: this.state.serverConfig?.roomServer,
|
||||
protocols: this.protocols,
|
||||
metricsTrigger: "RoomDirectory",
|
||||
});
|
||||
|
@ -380,7 +387,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
roomAlias,
|
||||
autoJoin,
|
||||
shouldPeek,
|
||||
roomServer: this.state.roomServer,
|
||||
roomServer: this.state.serverConfig?.roomServer,
|
||||
metricsTrigger: "RoomDirectory",
|
||||
});
|
||||
};
|
||||
|
@ -465,7 +472,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
let listHeader;
|
||||
if (!this.state.protocolsLoading) {
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.serverConfig?.instanceId);
|
||||
let instanceExpectedFieldType;
|
||||
if (
|
||||
protocolName &&
|
||||
|
@ -479,9 +486,9 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
let placeholder = _t('Find a room…');
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
if (!this.state.serverConfig?.instanceId || this.state.serverConfig?.instanceId === ALL_ROOMS) {
|
||||
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
|
||||
exampleRoom: "#example:" + this.state.roomServer,
|
||||
exampleRoom: "#example:" + this.state.serverConfig?.roomServer,
|
||||
});
|
||||
} else if (instanceExpectedFieldType) {
|
||||
placeholder = instanceExpectedFieldType.placeholder;
|
||||
|
@ -489,8 +496,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (getFieldsForThirdPartyLocation(
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.serverConfig?.instanceId);
|
||||
if (!instance || getFieldsForThirdPartyLocation(
|
||||
this.state.filterString,
|
||||
this.protocols[protocolName],
|
||||
instance,
|
||||
|
@ -511,14 +518,13 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
/>
|
||||
<NetworkDropdown
|
||||
protocols={this.protocols}
|
||||
onOptionChange={this.onOptionChange}
|
||||
selectedServerName={this.state.roomServer}
|
||||
selectedInstanceId={this.state.instanceId}
|
||||
config={this.state.serverConfig}
|
||||
setConfig={this.onOptionChange}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", null,
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", {},
|
||||
{ a: sub => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onCreateRoomClick}>
|
||||
{ sub }
|
||||
|
|
|
@ -31,7 +31,7 @@ import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
|
|||
import { IS_MAC, Key } from "../../Keyboard";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import Modal from "../../Modal";
|
||||
import SpotlightDialog from "../views/dialogs/SpotlightDialog";
|
||||
import SpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
|
||||
import { ALTERNATE_KEY_NAME, KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
|
||||
|
|
53
src/components/views/avatars/SearchResultAvatar.tsx
Normal file
53
src/components/views/avatars/SearchResultAvatar.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
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 { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import emailPillAvatar from "../../../../res/img/icon-email-pill-avatar.svg";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { Member, ThreepidMember } from "../../../utils/direct-messages";
|
||||
import BaseAvatar from "./BaseAvatar";
|
||||
|
||||
interface SearchResultAvatarProps {
|
||||
user: Member | RoomMember;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function SearchResultAvatar({ user, size }: SearchResultAvatarProps): JSX.Element {
|
||||
if ((user as ThreepidMember).isEmail) {
|
||||
// we can’t show a real avatar here, but we try to create the exact same markup that a real avatar would have
|
||||
// BaseAvatar makes the avatar, if it's not clickable but just for decoration, invisible to screenreaders by
|
||||
// specifically setting an empty alt text, so we do the same.
|
||||
return <img
|
||||
className="mx_SearchResultAvatar mx_SearchResultAvatar_threepidAvatar"
|
||||
alt=""
|
||||
src={emailPillAvatar}
|
||||
width={size}
|
||||
height={size}
|
||||
/>;
|
||||
} else {
|
||||
const avatarUrl = user.getMxcAvatarUrl();
|
||||
return <BaseAvatar
|
||||
className="mx_SearchResultAvatar"
|
||||
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(size) : null}
|
||||
name={user.name}
|
||||
idName={user.userId}
|
||||
width={size}
|
||||
height={size}
|
||||
/>;
|
||||
}
|
||||
}
|
|
@ -28,6 +28,7 @@ import DMRoomMap from "../../../utils/DMRoomMap";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import * as Email from "../../../email";
|
||||
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils";
|
||||
import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers";
|
||||
import { abbreviateUrl } from "../../../utils/UrlUtils";
|
||||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||
import { humanizeTime } from "../../../utils/humanize";
|
||||
|
@ -43,8 +44,9 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import { SearchResultAvatar } from "../avatars/SearchResultAvatar";
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
import { compare, selectText } from '../../../utils/strings';
|
||||
import { selectText } from '../../../utils/strings';
|
||||
import Field from '../elements/Field';
|
||||
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
|
||||
import Dialpad from '../voip/DialPad';
|
||||
|
@ -91,22 +93,7 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
|
|||
|
||||
render() {
|
||||
const avatarSize = 20;
|
||||
const avatar = (this.props.member as ThreepidMember).isEmail
|
||||
? <img
|
||||
className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar'
|
||||
src={require("../../../../res/img/icon-email-pill-avatar.svg").default}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
/>
|
||||
: <BaseAvatar
|
||||
className='mx_InviteDialog_userTile_avatar'
|
||||
url={this.props.member.getMxcAvatarUrl()
|
||||
? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
|
||||
: null}
|
||||
name={this.props.member.name}
|
||||
idName={this.props.member.userId}
|
||||
width={avatarSize}
|
||||
height={avatarSize} />;
|
||||
const avatar = <SearchResultAvatar user={this.props.member} size={avatarSize} />;
|
||||
|
||||
let closeButton;
|
||||
if (this.props.onRemove) {
|
||||
|
@ -422,121 +409,15 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
}
|
||||
|
||||
private buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
|
||||
const maxConsideredMembers = 200;
|
||||
const joinedRooms = MatrixClientPeg.get().getRooms()
|
||||
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
|
||||
const cli = MatrixClientPeg.get();
|
||||
const activityScores = buildActivityScores(cli);
|
||||
const memberScores = buildMemberScores(cli);
|
||||
const memberComparator = compareMembers(activityScores, memberScores);
|
||||
|
||||
// Generates { userId: {member, rooms[]} }
|
||||
const memberRooms = joinedRooms.reduce((members, room) => {
|
||||
// Filter out DMs (we'll handle these in the recents section)
|
||||
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
|
||||
return members; // Do nothing
|
||||
}
|
||||
|
||||
const joinedMembers = room.getJoinedMembers().filter(u => !excludedTargetIds.has(u.userId));
|
||||
for (const member of joinedMembers) {
|
||||
// Filter out user IDs that are already in the room / should be excluded
|
||||
if (excludedTargetIds.has(member.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!members[member.userId]) {
|
||||
members[member.userId] = {
|
||||
member: member,
|
||||
// Track the room size of the 'picked' member so we can use the profile of
|
||||
// the smallest room (likely a DM).
|
||||
pickedMemberRoomSize: room.getJoinedMemberCount(),
|
||||
rooms: [],
|
||||
};
|
||||
}
|
||||
|
||||
members[member.userId].rooms.push(room);
|
||||
|
||||
if (room.getJoinedMemberCount() < members[member.userId].pickedMemberRoomSize) {
|
||||
members[member.userId].member = member;
|
||||
members[member.userId].pickedMemberRoomSize = room.getJoinedMemberCount();
|
||||
}
|
||||
}
|
||||
return members;
|
||||
}, {});
|
||||
|
||||
// Generates { userId: {member, numRooms, score} }
|
||||
const memberScores = Object.values(memberRooms).reduce((scores, entry: {member: RoomMember, rooms: Room[]}) => {
|
||||
const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0);
|
||||
const maxRange = maxConsideredMembers * entry.rooms.length;
|
||||
scores[entry.member.userId] = {
|
||||
member: entry.member,
|
||||
numRooms: entry.rooms.length,
|
||||
score: Math.max(0, Math.pow(1 - (numMembersTotal / maxRange), 5)),
|
||||
};
|
||||
return scores;
|
||||
}, {});
|
||||
|
||||
// Now that we have scores for being in rooms, boost those people who have sent messages
|
||||
// recently, as a way to improve the quality of suggestions. We do this by checking every
|
||||
// room to see who has sent a message in the last few hours, and giving them a score
|
||||
// which correlates to the freshness of their message. In theory, this results in suggestions
|
||||
// which are closer to "continue this conversation" rather than "this person exists".
|
||||
const trueJoinedRooms = MatrixClientPeg.get().getRooms().filter(r => r.getMyMembership() === 'join');
|
||||
const now = (new Date()).getTime();
|
||||
const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago
|
||||
const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
|
||||
const lastSpoke = {}; // userId: timestamp
|
||||
const lastSpokeMembers = {}; // userId: room member
|
||||
for (const room of trueJoinedRooms) {
|
||||
// Skip low priority rooms and DMs
|
||||
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (Object.keys(room.tags).includes("m.lowpriority") || isDm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
|
||||
for (let i = events.length - 1; i >= Math.max(0, events.length - maxMessagesConsidered); i--) {
|
||||
const ev = events[i];
|
||||
if (excludedTargetIds.has(ev.getSender())) {
|
||||
continue;
|
||||
}
|
||||
if (ev.getTs() <= earliestAgeConsidered) {
|
||||
break; // give up: all events from here on out are too old
|
||||
}
|
||||
|
||||
if (!lastSpoke[ev.getSender()] || lastSpoke[ev.getSender()] < ev.getTs()) {
|
||||
lastSpoke[ev.getSender()] = ev.getTs();
|
||||
lastSpokeMembers[ev.getSender()] = room.getMember(ev.getSender());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const userId in lastSpoke) {
|
||||
const ts = lastSpoke[userId];
|
||||
const member = lastSpokeMembers[userId];
|
||||
if (!member) continue; // skip people we somehow don't have profiles for
|
||||
|
||||
// Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
|
||||
// boost we'll try and award at least +1.0 for making the list, with +4.0 being
|
||||
// an approximate maximum for being selected.
|
||||
const distanceFromNow = Math.abs(now - ts); // abs to account for slight future messages
|
||||
const inverseTime = (now - earliestAgeConsidered) - distanceFromNow;
|
||||
const scoreBoost = Math.max(1, inverseTime / (15 * 60 * 1000)); // 15min segments to keep scores sane
|
||||
|
||||
let record = memberScores[userId];
|
||||
if (!record) record = memberScores[userId] = { score: 0 };
|
||||
record.member = member;
|
||||
record.score += scoreBoost;
|
||||
}
|
||||
|
||||
const members = Object.values(memberScores);
|
||||
members.sort((a, b) => {
|
||||
if (a.score === b.score) {
|
||||
if (a.numRooms === b.numRooms) {
|
||||
return compare(a.member.userId, b.member.userId);
|
||||
}
|
||||
|
||||
return b.numRooms - a.numRooms;
|
||||
}
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
return members.map(m => ({ userId: m.member.userId, user: m.member }));
|
||||
return Object.values(memberScores).map(({ member }) => member)
|
||||
.filter(member => !excludedTargetIds.has(member.userId))
|
||||
.sort(memberComparator)
|
||||
.map(member => ({ userId: member.userId, user: member }));
|
||||
}
|
||||
|
||||
private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
|
||||
|
|
|
@ -1,786 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 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, {
|
||||
ChangeEvent,
|
||||
ComponentProps,
|
||||
KeyboardEvent,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { normalize } from "matrix-js-sdk/src/utils";
|
||||
import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
|
||||
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch";
|
||||
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {
|
||||
findSiblingElement,
|
||||
RovingAccessibleButton,
|
||||
RovingAccessibleTooltipButton,
|
||||
RovingTabIndexContext,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
useRovingTabIndex,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import Modal from "../../../Modal";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { RoomViewStore } from "../../../stores/RoomViewStore";
|
||||
import { showStartChatInviteDialog } from "../../../RoomInvite";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import NotificationBadge from "../rooms/NotificationBadge";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
import { UserTab } from "./UserTab";
|
||||
import BetaFeedbackDialog from "./BetaFeedbackDialog";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { getMetaSpaceName } from "../../../stores/spaces";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
||||
import { getCachedRoomIDForAlias } from "../../../RoomAliasCache";
|
||||
import { roomContextDetailsText, spaceContextDetailsText } from "../../../utils/i18n-helpers";
|
||||
import { RecentAlgorithm } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
|
||||
const MAX_RECENT_SEARCHES = 10;
|
||||
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
|
||||
const AVATAR_SIZE = 24;
|
||||
|
||||
const Option: React.FC<ComponentProps<typeof RovingAccessibleButton>> = ({ inputRef, children, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton
|
||||
{...props}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
>
|
||||
{ children }
|
||||
<div className="mx_SpotlightDialog_enterPrompt" aria-hidden>↵</div>
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
const TooltipOption: React.FC<ComponentProps<typeof RovingAccessibleTooltipButton>> = ({ inputRef, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleTooltipButton
|
||||
{...props}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
/>;
|
||||
};
|
||||
|
||||
const useRecentSearches = (): [Room[], () => void] => {
|
||||
const [rooms, setRooms] = useState(() => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const recents = SettingsStore.getValue("SpotlightSearch.recentSearches", null);
|
||||
return recents.map(r => cli.getRoom(r)).filter(Boolean);
|
||||
});
|
||||
|
||||
return [rooms, () => {
|
||||
SettingsStore.setValue("SpotlightSearch.recentSearches", null, SettingLevel.ACCOUNT, []);
|
||||
setRooms([]);
|
||||
}];
|
||||
};
|
||||
|
||||
const ResultDetails = ({ room }: { room: Room }) => {
|
||||
const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room);
|
||||
if (contextDetails) {
|
||||
return <div className="mx_SpotlightDialog_result_details">
|
||||
{ contextDetails }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
const useSpaceResults = (space?: Room, query?: string): [IHierarchyRoom[], boolean] => {
|
||||
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
|
||||
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
|
||||
|
||||
const resetHierarchy = useCallback(() => {
|
||||
setHierarchy(space ? new RoomHierarchy(space, 50) : null);
|
||||
}, [space]);
|
||||
useEffect(resetHierarchy, [resetHierarchy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!space || !hierarchy) return; // nothing to load
|
||||
|
||||
let unmounted = false;
|
||||
|
||||
(async () => {
|
||||
while (hierarchy?.canLoadMore && !unmounted && space === hierarchy.root) {
|
||||
await hierarchy.load();
|
||||
if (hierarchy.canLoadMore) hierarchy.load(); // start next load so that the loading attribute is right
|
||||
setRooms(hierarchy.rooms);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
}, [space, hierarchy]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const trimmedQuery = query.trim();
|
||||
const lcQuery = trimmedQuery.toLowerCase();
|
||||
const normalizedQuery = normalize(trimmedQuery);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
return rooms?.filter(r => {
|
||||
return r.room_type !== RoomType.Space &&
|
||||
cli.getRoom(r.room_id)?.getMyMembership() !== "join" &&
|
||||
(
|
||||
normalize(r.name || "").includes(normalizedQuery) ||
|
||||
(r.canonical_alias || "").includes(lcQuery)
|
||||
);
|
||||
});
|
||||
}, [rooms, query]);
|
||||
|
||||
return [results, hierarchy?.loading ?? false];
|
||||
};
|
||||
|
||||
function refIsForRecentlyViewed(ref: RefObject<HTMLElement>): boolean {
|
||||
return ref.current?.id.startsWith("mx_SpotlightDialog_button_recentlyViewed_");
|
||||
}
|
||||
|
||||
enum Section {
|
||||
People,
|
||||
Rooms,
|
||||
Spaces,
|
||||
}
|
||||
|
||||
interface IBaseResult {
|
||||
section: Section;
|
||||
query?: string[]; // extra fields to query match, stored as lowercase
|
||||
}
|
||||
|
||||
interface IRoomResult extends IBaseResult {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
interface IResult extends IBaseResult {
|
||||
avatar: JSX.Element;
|
||||
name: string;
|
||||
description?: string;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
type Result = IRoomResult | IResult;
|
||||
|
||||
const isRoomResult = (result: any): result is IRoomResult => !!result?.room;
|
||||
|
||||
const recentAlgorithm = new RecentAlgorithm();
|
||||
|
||||
export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => {
|
||||
useEffect(() => {
|
||||
if (!queryLength) return;
|
||||
|
||||
// send metrics after a 1s debounce
|
||||
const timeoutId = setTimeout(() => {
|
||||
PosthogAnalytics.instance.trackEvent<WebSearchEvent>({
|
||||
eventName: "WebSearch",
|
||||
viaSpotlight,
|
||||
numResults,
|
||||
queryLength,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [numResults, queryLength, viaSpotlight]);
|
||||
};
|
||||
|
||||
const SpotlightDialog: React.FC<IProps> = ({ initialText = "", onFinished }) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const rovingContext = useContext(RovingTabIndexContext);
|
||||
const [query, _setQuery] = useState(initialText);
|
||||
const [recentSearches, clearRecentSearches] = useRecentSearches();
|
||||
|
||||
const possibleResults = useMemo<Result[]>(() => [
|
||||
...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({
|
||||
section: Section.Spaces,
|
||||
avatar: (
|
||||
<div className={`mx_SpotlightDialog_metaspaceResult mx_SpotlightDialog_metaspaceResult_${spaceKey}`} />
|
||||
),
|
||||
name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome),
|
||||
onClick() {
|
||||
SpaceStore.instance.setActiveSpace(spaceKey);
|
||||
},
|
||||
})),
|
||||
...cli.getVisibleRooms().filter(room => {
|
||||
// TODO we may want to put invites in their own list
|
||||
return room.getMyMembership() === "join" || room.getMyMembership() == "invite";
|
||||
}).map(room => {
|
||||
let section: Section;
|
||||
let query: string[];
|
||||
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (otherUserId) {
|
||||
section = Section.People;
|
||||
query = [
|
||||
otherUserId.toLowerCase(),
|
||||
room.getMember(otherUserId)?.name.toLowerCase(),
|
||||
].filter(Boolean);
|
||||
} else if (room.isSpaceRoom()) {
|
||||
section = Section.Spaces;
|
||||
} else {
|
||||
section = Section.Rooms;
|
||||
}
|
||||
|
||||
return { room, section, query };
|
||||
}),
|
||||
], [cli]);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const [people, rooms, spaces] = useMemo<[Result[], Result[], Result[]] | []>(() => {
|
||||
if (!trimmedQuery) return [];
|
||||
|
||||
const lcQuery = trimmedQuery.toLowerCase();
|
||||
const normalizedQuery = normalize(trimmedQuery);
|
||||
|
||||
const results: [Result[], Result[], Result[]] = [[], [], []];
|
||||
|
||||
// Group results in their respective sections
|
||||
possibleResults.forEach(entry => {
|
||||
if (isRoomResult(entry)) {
|
||||
if (!entry.room.normalizedName.includes(normalizedQuery) &&
|
||||
!entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) &&
|
||||
!entry.query?.some(q => q.includes(lcQuery))
|
||||
) return; // bail, does not match query
|
||||
} else {
|
||||
if (!entry.name.toLowerCase().includes(lcQuery) &&
|
||||
!entry.query?.some(q => q.includes(lcQuery))
|
||||
) return; // bail, does not match query
|
||||
}
|
||||
|
||||
results[entry.section].push(entry);
|
||||
});
|
||||
|
||||
// Sort results by most recent activity
|
||||
|
||||
const myUserId = cli.getUserId();
|
||||
for (const resultArray of results) {
|
||||
resultArray.sort((a: Result, b: Result) => {
|
||||
// This is not a room result, it should appear at the bottom of
|
||||
// the list
|
||||
if (!(a as IRoomResult).room) return 1;
|
||||
if (!(b as IRoomResult).room) return -1;
|
||||
|
||||
const roomA = (a as IRoomResult).room;
|
||||
const roomB = (b as IRoomResult).room;
|
||||
|
||||
return recentAlgorithm.getLastTs(roomB, myUserId) - recentAlgorithm.getLastTs(roomA, myUserId);
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [possibleResults, trimmedQuery, cli]);
|
||||
|
||||
const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0;
|
||||
useWebSearchMetrics(numResults, query.length, true);
|
||||
|
||||
const activeSpace = SpaceStore.instance.activeSpaceRoom;
|
||||
const [spaceResults, spaceResultsLoading] = useSpaceResults(activeSpace, query);
|
||||
|
||||
const setQuery = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const newQuery = e.currentTarget.value;
|
||||
_setQuery(newQuery);
|
||||
|
||||
setImmediate(() => {
|
||||
// reset the activeRef when we change query for best usability
|
||||
const ref = rovingContext.state.refs[0];
|
||||
if (ref) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
});
|
||||
ref.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const viewRoom = (roomId: string, persist = false, viaKeyboard = false) => {
|
||||
if (persist) {
|
||||
const recents = new Set(SettingsStore.getValue("SpotlightSearch.recentSearches", null).reverse());
|
||||
// remove & add the room to put it at the end
|
||||
recents.delete(roomId);
|
||||
recents.add(roomId);
|
||||
|
||||
SettingsStore.setValue(
|
||||
"SpotlightSearch.recentSearches",
|
||||
null,
|
||||
SettingLevel.ACCOUNT,
|
||||
Array.from(recents).reverse().slice(0, MAX_RECENT_SEARCHES),
|
||||
);
|
||||
}
|
||||
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: roomId,
|
||||
metricsTrigger: "WebUnifiedSearch",
|
||||
metricsViaKeyboard: viaKeyboard,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
let content: JSX.Element;
|
||||
if (trimmedQuery) {
|
||||
const resultMapper = (result: Result): JSX.Element => {
|
||||
if (isRoomResult(result)) {
|
||||
return (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_result_${result.room.roomId}`}
|
||||
key={result.room.roomId}
|
||||
onClick={(ev) => {
|
||||
viewRoom(result.room.roomId, true, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<DecoratedRoomAvatar room={result.room} avatarSize={AVATAR_SIZE} tooltipProps={{ tabIndex: -1 }} />
|
||||
{ result.room.name }
|
||||
<NotificationBadge notification={RoomNotificationStateStore.instance.getRoomState(result.room)} />
|
||||
<ResultDetails room={result.room} />
|
||||
</Option>
|
||||
);
|
||||
}
|
||||
|
||||
// IResult case
|
||||
return (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_result_${result.name}`}
|
||||
key={result.name}
|
||||
onClick={result.onClick}
|
||||
>
|
||||
{ result.avatar }
|
||||
{ result.name }
|
||||
{ result.description }
|
||||
</Option>
|
||||
);
|
||||
};
|
||||
|
||||
let peopleSection: JSX.Element;
|
||||
if (people.length) {
|
||||
peopleSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("People") }</h4>
|
||||
<div>
|
||||
{ people.slice(0, SECTION_LIMIT).map(resultMapper) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let roomsSection: JSX.Element;
|
||||
if (rooms.length) {
|
||||
roomsSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("Rooms") }</h4>
|
||||
<div>
|
||||
{ rooms.slice(0, SECTION_LIMIT).map(resultMapper) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let spacesSection: JSX.Element;
|
||||
if (spaces.length) {
|
||||
spacesSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("Spaces you're in") }</h4>
|
||||
<div>
|
||||
{ spaces.slice(0, SECTION_LIMIT).map(resultMapper) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let spaceRoomsSection: JSX.Element;
|
||||
if (spaceResults.length) {
|
||||
spaceRoomsSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }</h4>
|
||||
<div>
|
||||
{ spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_result_${room.room_id}`}
|
||||
key={room.room_id}
|
||||
onClick={(ev) => {
|
||||
viewRoom(room.room_id, true, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<BaseAvatar
|
||||
name={room.name}
|
||||
idName={room.room_id}
|
||||
url={room.avatar_url
|
||||
? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(AVATAR_SIZE)
|
||||
: null
|
||||
}
|
||||
width={AVATAR_SIZE}
|
||||
height={AVATAR_SIZE}
|
||||
/>
|
||||
{ room.name || room.canonical_alias }
|
||||
{ room.name && room.canonical_alias && <div className="mx_SpotlightDialog_result_details">
|
||||
{ room.canonical_alias }
|
||||
</div> }
|
||||
</Option>
|
||||
)) }
|
||||
{ spaceResultsLoading && <Spinner /> }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let joinRoomSection: JSX.Element;
|
||||
if (trimmedQuery.startsWith("#") &&
|
||||
trimmedQuery.includes(":") &&
|
||||
(!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery)))
|
||||
) {
|
||||
joinRoomSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
|
||||
<div>
|
||||
<Option
|
||||
id="mx_SpotlightDialog_button_joinRoomAlias"
|
||||
className="mx_SpotlightDialog_joinRoomAlias"
|
||||
onClick={(ev) => {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_alias: trimmedQuery,
|
||||
auto_join: true,
|
||||
metricsTrigger: "WebUnifiedSearch",
|
||||
metricsViaKeyboard: ev.type !== "click",
|
||||
});
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Join %(roomAddress)s", {
|
||||
roomAddress: trimmedQuery,
|
||||
}) }
|
||||
</Option>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
{ peopleSection }
|
||||
{ roomsSection }
|
||||
{ spacesSection }
|
||||
{ spaceRoomsSection }
|
||||
{ joinRoomSection }
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
|
||||
<h4>{ _t('Use "%(query)s" to search', { query }) }</h4>
|
||||
<div>
|
||||
<Option
|
||||
id="mx_SpotlightDialog_button_explorePublicRooms"
|
||||
className="mx_SpotlightDialog_explorePublicRooms"
|
||||
onClick={() => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewRoomDirectory,
|
||||
initialText: query,
|
||||
});
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Public rooms") }
|
||||
</Option>
|
||||
<Option
|
||||
id="mx_SpotlightDialog_button_startChat"
|
||||
className="mx_SpotlightDialog_startChat"
|
||||
onClick={() => {
|
||||
showStartChatInviteDialog(query);
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("People") }
|
||||
</Option>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
|
||||
<h4>{ _t("Other searches") }</h4>
|
||||
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
|
||||
{ _t("To search messages, look for this icon at the top of a room <icon/>", {}, {
|
||||
icon: () => <div className="mx_SpotlightDialog_otherSearches_messageSearchIcon" />,
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
} else {
|
||||
let recentSearchesSection: JSX.Element;
|
||||
if (recentSearches.length) {
|
||||
recentSearchesSection = (
|
||||
<div
|
||||
className="mx_SpotlightDialog_section mx_SpotlightDialog_recentSearches"
|
||||
role="group"
|
||||
// Firefox sometimes makes this element focusable due to overflow,
|
||||
// so force it out of tab order by default.
|
||||
tabIndex={-1}
|
||||
>
|
||||
<h4>
|
||||
{ _t("Recent searches") }
|
||||
<AccessibleButton kind="link" onClick={clearRecentSearches}>
|
||||
{ _t("Clear") }
|
||||
</AccessibleButton>
|
||||
</h4>
|
||||
<div>
|
||||
{ recentSearches.map(room => (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}`}
|
||||
key={room.roomId}
|
||||
onClick={(ev) => {
|
||||
viewRoom(room.roomId, true, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<DecoratedRoomAvatar room={room} avatarSize={AVATAR_SIZE} tooltipProps={{ tabIndex: -1 }} />
|
||||
{ room.name }
|
||||
<NotificationBadge notification={RoomNotificationStateStore.instance.getRoomState(room)} />
|
||||
<ResultDetails room={room} />
|
||||
</Option>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_recentlyViewed" role="group">
|
||||
<h4>{ _t("Recently viewed") }</h4>
|
||||
<div>
|
||||
{ BreadcrumbsStore.instance.rooms
|
||||
.filter(r => r.roomId !== RoomViewStore.instance.getRoomId())
|
||||
.map(room => (
|
||||
<TooltipOption
|
||||
id={`mx_SpotlightDialog_button_recentlyViewed_${room.roomId}`}
|
||||
title={room.name}
|
||||
key={room.roomId}
|
||||
onClick={(ev) => {
|
||||
viewRoom(room.roomId, false, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<DecoratedRoomAvatar room={room} avatarSize={32} tooltipProps={{ tabIndex: -1 }} />
|
||||
{ room.name }
|
||||
</TooltipOption>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ recentSearchesSection }
|
||||
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
|
||||
<h4>{ _t("Other searches") }</h4>
|
||||
<div>
|
||||
<Option
|
||||
id="mx_SpotlightDialog_button_explorePublicRooms"
|
||||
className="mx_SpotlightDialog_explorePublicRooms"
|
||||
onClick={() => {
|
||||
defaultDispatcher.fire(Action.ViewRoomDirectory);
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Explore public rooms") }
|
||||
</Option>
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
const onDialogKeyDown = (ev: KeyboardEvent) => {
|
||||
const navigationAction = getKeyBindingsManager().getNavigationAction(ev);
|
||||
switch (navigationAction) {
|
||||
case KeyBindingAction.FilterRooms:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
break;
|
||||
}
|
||||
|
||||
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
switch (accessibilityAction) {
|
||||
case KeyBindingAction.Escape:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
let ref: RefObject<HTMLElement>;
|
||||
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
|
||||
switch (action) {
|
||||
case KeyBindingAction.ArrowUp:
|
||||
case KeyBindingAction.ArrowDown:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
if (rovingContext.state.refs.length > 0) {
|
||||
let refs = rovingContext.state.refs;
|
||||
if (!query) {
|
||||
// If the current selection is not in the recently viewed row then only include the
|
||||
// first recently viewed so that is the target when the user is switching into recently viewed.
|
||||
const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef)
|
||||
? rovingContext.state.activeRef
|
||||
: refs.find(refIsForRecentlyViewed);
|
||||
// exclude all other recently viewed items from the list so up/down arrows skip them
|
||||
refs = refs.filter(ref => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref));
|
||||
}
|
||||
|
||||
const idx = refs.indexOf(rovingContext.state.activeRef);
|
||||
ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowUp ? -1 : 1));
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
case KeyBindingAction.ArrowRight:
|
||||
// only handle these keys when we are in the recently viewed row of options
|
||||
if (!query &&
|
||||
rovingContext.state.refs.length > 0 &&
|
||||
refIsForRecentlyViewed(rovingContext.state.activeRef)
|
||||
) {
|
||||
// we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
|
||||
const idx = refs.indexOf(rovingContext.state.activeRef);
|
||||
ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowLeft ? -1 : 1));
|
||||
}
|
||||
break;
|
||||
case KeyBindingAction.Enter:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
rovingContext.state.activeRef?.current?.click();
|
||||
break;
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
});
|
||||
ref.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => {
|
||||
Modal.createDialog(BetaFeedbackDialog, {
|
||||
featureId: "feature_spotlight",
|
||||
});
|
||||
} : null;
|
||||
|
||||
const activeDescendant = rovingContext.state.activeRef?.current?.id;
|
||||
|
||||
return <>
|
||||
<div id="mx_SpotlightDialog_keyboardPrompt">
|
||||
{ _t("Use <arrows/> to scroll", {}, {
|
||||
arrows: () => <>
|
||||
<div>↓</div>
|
||||
<div>↑</div>
|
||||
{ !query && <div>←</div> }
|
||||
{ !query && <div>→</div> }
|
||||
</>,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
className="mx_SpotlightDialog"
|
||||
onFinished={onFinished}
|
||||
hasCancel={false}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
screenName="UnifiedSearch"
|
||||
aria-label={_t("Search Dialog")}
|
||||
>
|
||||
<div className="mx_SpotlightDialog_searchBox mx_textinput">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder={_t("Search")}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-owns="mx_SpotlightDialog_content"
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-label={_t("Search")}
|
||||
aria-describedby="mx_SpotlightDialog_keyboardPrompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="mx_SpotlightDialog_content"
|
||||
role="listbox"
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-describedby="mx_SpotlightDialog_keyboardPrompt"
|
||||
>
|
||||
{ content }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpotlightDialog_footer">
|
||||
<BetaPill onClick={() => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
onFinished();
|
||||
}} />
|
||||
{ openFeedback && _t("Results not as expected? Please <a>give feedback</a>.", {}, {
|
||||
a: sub => <AccessibleButton kind="link_inline" onClick={openFeedback}>
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
}) }
|
||||
{ openFeedback && <AccessibleButton
|
||||
kind="primary_outline"
|
||||
onClick={openFeedback}
|
||||
>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton> }
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</>;
|
||||
};
|
||||
|
||||
const RovingSpotlightDialog: React.FC<IProps> = (props) => {
|
||||
return <RovingTabIndexProvider>
|
||||
{ () => <SpotlightDialog {...props} /> }
|
||||
</RovingTabIndexProvider>;
|
||||
};
|
||||
|
||||
export default RovingSpotlightDialog;
|
43
src/components/views/dialogs/spotlight/Option.tsx
Normal file
43
src/components/views/dialogs/spotlight/Option.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../../accessibility/roving/RovingAccessibleButton";
|
||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
|
||||
interface OptionProps extends ComponentProps<typeof RovingAccessibleButton> {
|
||||
endAdornment?: ReactNode;
|
||||
}
|
||||
|
||||
export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment, className, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton
|
||||
{...props}
|
||||
className={classNames(className, "mx_SpotlightDialog_option")}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
>
|
||||
{ children }
|
||||
<div className="mx_SpotlightDialog_enterPrompt" aria-hidden>↵</div>
|
||||
{ endAdornment }
|
||||
</AccessibleButton>;
|
||||
};
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
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 { IPublicRoomsChunkRoom } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { linkifyAndSanitizeHtml } from "../../../../HtmlUtils";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { getDisplayAliasForRoom } from "../../../structures/RoomDirectory";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
||||
export function PublicRoomResultDetails({ room }: { room: IPublicRoomsChunkRoom }): JSX.Element {
|
||||
let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
|
||||
}
|
||||
|
||||
let topic = room.topic || '';
|
||||
// Additional truncation based on line numbers is done via CSS,
|
||||
// but to ensure that the DOM is not polluted with a huge string
|
||||
// we give it a hard limit before rendering.
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SpotlightDialog_result_publicRoomDetails">
|
||||
<div className="mx_SpotlightDialog_result_publicRoomHeader">
|
||||
<span className="mx_SpotlightDialog_result_publicRoomName">{ name }</span>
|
||||
<span className="mx_SpotlightDialog_result_publicRoomAlias">
|
||||
{ room.canonical_alias ?? room.room_id }
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx_SpotlightDialog_result_publicRoomDescription">
|
||||
<span className="mx_SpotlightDialog_result_publicRoomMemberCount">
|
||||
{ _t("%(count)s Members", {
|
||||
count: room.num_joined_members,
|
||||
}) }
|
||||
</span>
|
||||
{ topic && (
|
||||
<>
|
||||
·
|
||||
<span
|
||||
className="mx_SpotlightDialog_result_publicRoomTopic"
|
||||
dangerouslySetInnerHTML={{ __html: linkifyAndSanitizeHtml(topic) }}
|
||||
/>
|
||||
</>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
31
src/components/views/dialogs/spotlight/RoomResultDetails.tsx
Normal file
31
src/components/views/dialogs/spotlight/RoomResultDetails.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
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 { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { roomContextDetailsText, spaceContextDetailsText } from "../../../../utils/i18n-helpers";
|
||||
|
||||
export const RoomResultDetails = ({ room }: { room: Room }) => {
|
||||
const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room);
|
||||
if (contextDetails) {
|
||||
return <div className="mx_SpotlightDialog_result_details">
|
||||
{ contextDetails }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
1057
src/components/views/dialogs/spotlight/SpotlightDialog.tsx
Normal file
1057
src/components/views/dialogs/spotlight/SpotlightDialog.tsx
Normal file
File diff suppressed because it is too large
Load diff
39
src/components/views/dialogs/spotlight/TooltipOption.tsx
Normal file
39
src/components/views/dialogs/spotlight/TooltipOption.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { RovingAccessibleTooltipButton } from "../../../../accessibility/roving/RovingAccessibleTooltipButton";
|
||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
|
||||
|
||||
interface TooltipOptionProps extends ComponentProps<typeof RovingAccessibleTooltipButton> {
|
||||
endAdornment?: ReactNode;
|
||||
}
|
||||
|
||||
export const TooltipOption: React.FC<TooltipOptionProps> = ({ inputRef, className, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleTooltipButton
|
||||
{...props}
|
||||
className={classNames(className, "mx_SpotlightDialog_option")}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
/>;
|
||||
};
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2016, 2020 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
|
@ -15,41 +14,29 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { without } from "lodash";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { instanceForInstanceId, ALL_ROOMS, Protocols } from '../../../utils/DirectoryUtils';
|
||||
import ContextMenu, {
|
||||
ChevronFace,
|
||||
ContextMenuButton,
|
||||
MenuGroup,
|
||||
MenuItem,
|
||||
MenuItemRadio,
|
||||
useContextMenu,
|
||||
} from "../../structures/ContextMenu";
|
||||
import { MenuItemRadio } from "../../../accessibility/context_menu/MenuItemRadio";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import Modal from "../../../Modal";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import withValidation from "../elements/Validation";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { Protocols } from "../../../utils/DirectoryUtils";
|
||||
import { GenericDropdownMenu, GenericDropdownMenuItem } from "../../structures/GenericDropdownMenu";
|
||||
import TextInputDialog from "../dialogs/TextInputDialog";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import { compare } from "../../../utils/strings";
|
||||
import { SnakedObject } from "../../../utils/SnakedObject";
|
||||
import { IConfigOptions } from "../../../IConfigOptions";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import withValidation from "../elements/Validation";
|
||||
|
||||
const SETTING_NAME = "room_directory_servers";
|
||||
|
||||
const inPlaceOf = (elementRect: Pick<DOMRect, "right" | "top">) => ({
|
||||
right: UIStore.instance.windowWidth - elementRect.right,
|
||||
top: elementRect.top,
|
||||
chevronOffset: 0,
|
||||
chevronFace: ChevronFace.None,
|
||||
});
|
||||
export interface IPublicRoomDirectoryConfig {
|
||||
roomServer: string;
|
||||
instanceId?: string;
|
||||
}
|
||||
|
||||
const validServer = withValidation<undefined, { error?: MatrixError }>({
|
||||
deriveData: async ({ value }) => {
|
||||
|
@ -74,169 +61,126 @@ const validServer = withValidation<undefined, { error?: MatrixError }>({
|
|||
final: true,
|
||||
test: async (_, { error }) => !error,
|
||||
valid: () => _t("Looks good"),
|
||||
invalid: ({ error }) => error.errcode === "M_FORBIDDEN"
|
||||
invalid: ({ error }) => error?.errcode === "M_FORBIDDEN"
|
||||
? _t("You are not allowed to view this server's rooms list")
|
||||
: _t("Can't find this server or its room list"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
interface IProps {
|
||||
protocols: Protocols;
|
||||
selectedServerName: string;
|
||||
selectedInstanceId: string;
|
||||
onOptionChange(server: string, instanceId?: string): void;
|
||||
function useSettingsValueWithSetter<T>(
|
||||
settingName: string,
|
||||
level: SettingLevel,
|
||||
roomId: string | null = null,
|
||||
excludeDefault = false,
|
||||
): [T, (value: T) => Promise<void>] {
|
||||
const [value, setValue] = useState(SettingsStore.getValue<T>(settingName, roomId ?? undefined, excludeDefault));
|
||||
const setter = useCallback(
|
||||
async (value: T) => {
|
||||
setValue(value);
|
||||
SettingsStore.setValue(settingName, roomId, level, value);
|
||||
},
|
||||
[level, roomId, settingName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ref = SettingsStore.watchSetting(settingName, roomId, () => {
|
||||
setValue(SettingsStore.getValue<T>(settingName, roomId, excludeDefault));
|
||||
});
|
||||
// clean-up
|
||||
return () => {
|
||||
SettingsStore.unwatchSetting(ref);
|
||||
};
|
||||
}, [settingName, roomId, excludeDefault]);
|
||||
|
||||
return [value, setter];
|
||||
}
|
||||
|
||||
// This dropdown sources homeservers from three places:
|
||||
// + your currently connected homeserver
|
||||
// + homeservers in config.json["roomDirectory"]
|
||||
// + homeservers in SettingsStore["room_directory_servers"]
|
||||
// if a server exists in multiple, only keep the top-most entry.
|
||||
interface ServerList {
|
||||
allServers: string[];
|
||||
homeServer: string;
|
||||
userDefinedServers: string[];
|
||||
setUserDefinedServers: (servers: string[]) => void;
|
||||
}
|
||||
|
||||
const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
|
||||
const _userDefinedServers: string[] = useSettingValue(SETTING_NAME);
|
||||
const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
|
||||
function removeAll<T>(target: Set<T>, ...toRemove: T[]) {
|
||||
for (const value of toRemove) {
|
||||
target.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
const handlerFactory = (server, instanceId) => {
|
||||
return () => {
|
||||
onOptionChange(server, instanceId);
|
||||
closeMenu();
|
||||
};
|
||||
};
|
||||
|
||||
const setUserDefinedServers = servers => {
|
||||
_setUserDefinedServers(servers);
|
||||
SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, servers);
|
||||
};
|
||||
// keep local echo up to date with external changes
|
||||
useEffect(() => {
|
||||
_setUserDefinedServers(_userDefinedServers);
|
||||
}, [_userDefinedServers]);
|
||||
|
||||
// we either show the button or the dropdown in its place.
|
||||
let content;
|
||||
if (menuDisplayed) {
|
||||
const roomDirectory = SdkConfig.getObject("room_directory")
|
||||
?? new SnakedObject<IConfigOptions["room_directory"]>({ servers: [] });
|
||||
|
||||
const hsName = MatrixClientPeg.getHomeserverName();
|
||||
const configServers = new Set<string>(roomDirectory.get("servers"));
|
||||
function useServers(): ServerList {
|
||||
const [userDefinedServers, setUserDefinedServers] = useSettingsValueWithSetter<string[]>(
|
||||
SETTING_NAME,
|
||||
SettingLevel.ACCOUNT,
|
||||
);
|
||||
|
||||
const homeServer = MatrixClientPeg.getHomeserverName();
|
||||
const configServers = new Set<string>(
|
||||
SdkConfig.getObject("room_directory")?.get("servers") ?? [],
|
||||
);
|
||||
removeAll(configServers, homeServer);
|
||||
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
|
||||
const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
|
||||
const servers = [
|
||||
const removableServers = new Set(userDefinedServers);
|
||||
removeAll(removableServers, homeServer);
|
||||
removeAll(removableServers, ...configServers);
|
||||
|
||||
return {
|
||||
allServers: [
|
||||
// we always show our connected HS, this takes precedence over it being configured or user-defined
|
||||
hsName,
|
||||
...Array.from(configServers).filter(s => s !== hsName).sort(),
|
||||
homeServer,
|
||||
...Array.from(configServers).sort(),
|
||||
...Array.from(removableServers).sort(),
|
||||
];
|
||||
|
||||
// For our own HS, we can use the instance_ids given in the third party protocols
|
||||
// response to get the server to filter the room list by network for us.
|
||||
// We can't get thirdparty protocols for remote server yet though, so for those
|
||||
// we can only show the default room list.
|
||||
const options = servers.map(server => {
|
||||
const serverSelected = server === selectedServerName;
|
||||
const entries = [];
|
||||
|
||||
const protocolsList = server === hsName ? Object.values(protocols) : [];
|
||||
if (protocolsList.length > 0) {
|
||||
// add a fake protocol with ALL_ROOMS
|
||||
protocolsList.push({
|
||||
instances: [{
|
||||
fields: [],
|
||||
network_id: "",
|
||||
instance_id: ALL_ROOMS,
|
||||
desc: _t("All rooms"),
|
||||
}],
|
||||
location_fields: [],
|
||||
user_fields: [],
|
||||
field_types: {},
|
||||
icon: "",
|
||||
});
|
||||
}
|
||||
|
||||
protocolsList.forEach(({ instances=[] }) => {
|
||||
[...instances].sort((b, a) => {
|
||||
return compare(a.desc, b.desc);
|
||||
}).forEach(({ desc, instance_id: instanceId }) => {
|
||||
entries.push(
|
||||
<MenuItemRadio
|
||||
key={String(instanceId)}
|
||||
active={serverSelected && instanceId === selectedInstanceId}
|
||||
onClick={handlerFactory(server, instanceId)}
|
||||
label={desc}
|
||||
className="mx_NetworkDropdown_server_network"
|
||||
>
|
||||
{ desc }
|
||||
</MenuItemRadio>);
|
||||
});
|
||||
});
|
||||
|
||||
let subtitle;
|
||||
if (server === hsName) {
|
||||
subtitle = (
|
||||
<div className="mx_NetworkDropdown_server_subtitle">
|
||||
{ _t("Your server") }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let removeButton;
|
||||
if (removableServers.has(server)) {
|
||||
const onClick = async () => {
|
||||
closeMenu();
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
title: _t("Are you sure?"),
|
||||
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
|
||||
serverName: server,
|
||||
}, {
|
||||
b: serverName => <b>{ serverName }</b>,
|
||||
}),
|
||||
button: _t("Remove"),
|
||||
fixedWidth: false,
|
||||
}, "mx_NetworkDropdown_dialog");
|
||||
|
||||
const [ok] = await finished;
|
||||
if (!ok) return;
|
||||
|
||||
// delete from setting
|
||||
setUserDefinedServers(servers.filter(s => s !== server));
|
||||
|
||||
// the selected server is being removed, reset to our HS
|
||||
if (serverSelected) {
|
||||
onOptionChange(hsName, undefined);
|
||||
}
|
||||
],
|
||||
homeServer,
|
||||
userDefinedServers: Array.from(removableServers).sort(),
|
||||
setUserDefinedServers,
|
||||
};
|
||||
removeButton = <MenuItem onClick={onClick} label={_t("Remove server")} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ARIA: in actual fact the entire menu is one large radio group but for better screen reader support
|
||||
// we use group to notate server wrongly.
|
||||
return (
|
||||
<MenuGroup label={server} className="mx_NetworkDropdown_server" key={server}>
|
||||
<div className="mx_NetworkDropdown_server_title">
|
||||
{ server }
|
||||
{ removeButton }
|
||||
</div>
|
||||
{ subtitle }
|
||||
interface IProps {
|
||||
protocols: Protocols | null;
|
||||
config: IPublicRoomDirectoryConfig | null;
|
||||
setConfig: (value: IPublicRoomDirectoryConfig | null) => void;
|
||||
}
|
||||
|
||||
export const NetworkDropdown = ({ protocols, config, setConfig }: IProps) => {
|
||||
const { allServers, homeServer, userDefinedServers, setUserDefinedServers } = useServers();
|
||||
|
||||
const options: GenericDropdownMenuItem<IPublicRoomDirectoryConfig | null>[] = allServers.map(roomServer => ({
|
||||
key: { roomServer, instanceId: null },
|
||||
label: roomServer,
|
||||
description: roomServer === homeServer ? _t("Your server") : null,
|
||||
options: [
|
||||
{
|
||||
key: { roomServer, instanceId: undefined },
|
||||
label: _t("Matrix"),
|
||||
},
|
||||
...(roomServer === homeServer && protocols ? Object.values(protocols)
|
||||
.flatMap(protocol => protocol.instances)
|
||||
.map(instance => ({
|
||||
key: { roomServer, instanceId: instance.instance_id },
|
||||
label: instance.desc,
|
||||
})) : []),
|
||||
],
|
||||
...(userDefinedServers.includes(roomServer) ? ({
|
||||
adornment: (
|
||||
<AccessibleButton
|
||||
className="mx_NetworkDropdown_removeServer"
|
||||
alt={_t("Remove server “%(roomServer)s”", { roomServer })}
|
||||
onClick={() => setUserDefinedServers(without(userDefinedServers, roomServer))}
|
||||
/>
|
||||
),
|
||||
}) : {}),
|
||||
}));
|
||||
|
||||
const addNewServer = useCallback(({ closeMenu }) => (
|
||||
<>
|
||||
<span className="mx_GenericDropdownMenu_divider" />
|
||||
<MenuItemRadio
|
||||
active={serverSelected && !selectedInstanceId}
|
||||
onClick={handlerFactory(server, undefined)}
|
||||
label={_t("Matrix")}
|
||||
className="mx_NetworkDropdown_server_network"
|
||||
>
|
||||
{ _t("Matrix") }
|
||||
</MenuItemRadio>
|
||||
{ entries }
|
||||
</MenuGroup>
|
||||
);
|
||||
});
|
||||
|
||||
const onClick = async () => {
|
||||
active={false}
|
||||
className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item"
|
||||
onClick={async () => {
|
||||
closeMenu();
|
||||
const { finished } = Modal.createDialog(TextInputDialog, {
|
||||
title: _t("Add a new server"),
|
||||
|
@ -251,51 +195,36 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
|
|||
const [ok, newServer] = await finished;
|
||||
if (!ok) return;
|
||||
|
||||
if (!userDefinedServers.includes(newServer)) {
|
||||
if (!allServers.includes(newServer)) {
|
||||
setUserDefinedServers([...userDefinedServers, newServer]);
|
||||
}
|
||||
|
||||
onOptionChange(newServer); // change filter to the new server
|
||||
};
|
||||
|
||||
const buttonRect = handle.current.getBoundingClientRect();
|
||||
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu} focusLock>
|
||||
<div className="mx_NetworkDropdown_menu">
|
||||
{ options }
|
||||
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>
|
||||
{ _t("Add a new server...") }
|
||||
</MenuItem>
|
||||
</div>
|
||||
</ContextMenu>;
|
||||
} else {
|
||||
let currentValue;
|
||||
if (selectedInstanceId === ALL_ROOMS) {
|
||||
currentValue = _t("All rooms");
|
||||
} else if (selectedInstanceId) {
|
||||
const instance = instanceForInstanceId(protocols, selectedInstanceId);
|
||||
currentValue = _t("%(networkName)s rooms", {
|
||||
networkName: instance.desc,
|
||||
setConfig({
|
||||
roomServer: newServer,
|
||||
});
|
||||
} else {
|
||||
currentValue = _t("Matrix rooms");
|
||||
}
|
||||
|
||||
content = <ContextMenuButton
|
||||
className="mx_NetworkDropdown_handle"
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{ currentValue }
|
||||
</span> <span className="mx_NetworkDropdown_handle_server">
|
||||
({ selectedServerName })
|
||||
<div className="mx_GenericDropdownMenu_Option--label">
|
||||
<span className="mx_NetworkDropdown_addServer">
|
||||
{ _t("Add new server…") }
|
||||
</span>
|
||||
</ContextMenuButton>;
|
||||
}
|
||||
</div>
|
||||
</MenuItemRadio>
|
||||
</>
|
||||
), [allServers, setConfig, setUserDefinedServers, userDefinedServers]);
|
||||
|
||||
return <div className="mx_NetworkDropdown" ref={handle}>
|
||||
{ content }
|
||||
</div>;
|
||||
return (
|
||||
<GenericDropdownMenu
|
||||
className="mx_NetworkDropdown_wrapper"
|
||||
value={config}
|
||||
toKey={(config: IPublicRoomDirectoryConfig | null) =>
|
||||
config ? `${config.roomServer}-${config.instanceId}` : "null"}
|
||||
options={options}
|
||||
onChange={(option) => setConfig(option)}
|
||||
selectedLabel={option => option?.key ? _t("Show: %(instance)s rooms (%(server)s)", {
|
||||
server: option.key.roomServer,
|
||||
instance: option.key.instanceId ? option.label : "Matrix",
|
||||
}) : _t("Show: Matrix rooms")}
|
||||
AdditionalOptions={addNewServer}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkDropdown;
|
||||
|
|
|
@ -54,7 +54,7 @@ import TooltipTarget from "../elements/TooltipTarget";
|
|||
import { BetaPill } from "../beta/BetaCard";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { useWebSearchMetrics } from "../dialogs/SpotlightDialog";
|
||||
import { useWebSearchMetrics } from "../dialogs/spotlight/SpotlightDialog";
|
||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../../settings/UIFeature";
|
||||
|
||||
|
|
41
src/hooks/spotlight/useDebouncedCallback.ts
Normal file
41
src/hooks/spotlight/useDebouncedCallback.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
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 { useEffect } from "react";
|
||||
|
||||
const DEBOUNCE_TIMEOUT = 100;
|
||||
|
||||
export function useDebouncedCallback<T extends any[]>(
|
||||
enabled: boolean,
|
||||
callback: (...params: T) => void,
|
||||
params: T,
|
||||
) {
|
||||
useEffect(() => {
|
||||
let handle: number | null = null;
|
||||
const doSearch = () => {
|
||||
handle = null;
|
||||
callback(...params);
|
||||
};
|
||||
if (enabled !== false) {
|
||||
handle = setTimeout(doSearch, DEBOUNCE_TIMEOUT);
|
||||
return () => {
|
||||
if (handle) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [enabled, callback, params]);
|
||||
}
|
35
src/hooks/spotlight/useRecentSearches.ts
Normal file
35
src/hooks/spotlight/useRecentSearches.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
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 { useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
export const useRecentSearches = (): [Room[], () => void] => {
|
||||
const [rooms, setRooms] = useState(() => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const recents = SettingsStore.getValue("SpotlightSearch.recentSearches", null);
|
||||
return recents.map(r => cli.getRoom(r)).filter(Boolean);
|
||||
});
|
||||
|
||||
return [rooms, () => {
|
||||
SettingsStore.setValue("SpotlightSearch.recentSearches", null, SettingLevel.ACCOUNT, []);
|
||||
setRooms([]);
|
||||
}];
|
||||
};
|
35
src/hooks/useLatestResult.ts
Normal file
35
src/hooks/useLatestResult.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
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 { useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook to prevent a slower response to an earlier query overwriting the result to a faster response of a later query
|
||||
* @param onResultChanged
|
||||
*/
|
||||
export const useLatestResult = <T, R>(onResultChanged: (result: R) => void):
|
||||
[(query: T | null) => void, (query: T | null, result: R) => void] => {
|
||||
const ref = useRef<T | null>(null);
|
||||
const setQuery = useCallback((query: T | null) => {
|
||||
ref.current = query;
|
||||
}, []);
|
||||
const setResult = useCallback((query: T | null, result: R) => {
|
||||
if (ref.current === query) {
|
||||
onResultChanged(result);
|
||||
}
|
||||
}, [onResultChanged]);
|
||||
return [setQuery, setResult];
|
||||
};
|
70
src/hooks/useProfileInfo.ts
Normal file
70
src/hooks/useProfileInfo.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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 { useCallback, useState } from "react";
|
||||
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { useLatestResult } from "./useLatestResult";
|
||||
|
||||
export interface IProfileInfoOpts {
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface IProfileInfo {
|
||||
user_id: string;
|
||||
avatar_url?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export const useProfileInfo = () => {
|
||||
const [profile, setProfile] = useState<IProfileInfo | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [updateQuery, updateResult] = useLatestResult<string, IProfileInfo | null>(setProfile);
|
||||
|
||||
const search = useCallback(async ({ query: term }: IProfileInfoOpts): Promise<boolean> => {
|
||||
updateQuery(term);
|
||||
if (!term?.length || !term.startsWith('@') || !term.includes(':')) {
|
||||
setProfile(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await MatrixClientPeg.get().getProfileInfo(term);
|
||||
updateResult(term, {
|
||||
user_id: term,
|
||||
avatar_url: result.avatar_url,
|
||||
display_name: result.displayname,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Could not fetch profile info for params", { term }, e);
|
||||
updateResult(term, null);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [updateQuery, updateResult]);
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
loading,
|
||||
profile,
|
||||
search,
|
||||
} as const;
|
||||
};
|
|
@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
|
||||
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
|
||||
import { IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { IPublicRoomDirectoryConfig } from "../components/views/directory/NetworkDropdown";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import SdkConfig from "../SdkConfig";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { Protocols } from "../utils/DirectoryUtils";
|
||||
import { useLatestResult } from "./useLatestResult";
|
||||
|
||||
export const ALL_ROOMS = "ALL_ROOMS";
|
||||
const LAST_SERVER_KEY = "mx_last_room_directory_server";
|
||||
|
@ -37,13 +39,15 @@ let thirdParty: Protocols;
|
|||
export const usePublicRoomDirectory = () => {
|
||||
const [publicRooms, setPublicRooms] = useState<IPublicRoomsChunkRoom[]>([]);
|
||||
|
||||
const [roomServer, setRoomServer] = useState<string | null | undefined>(undefined);
|
||||
const [instanceId, setInstanceId] = useState<string | null | undefined>(undefined);
|
||||
const [config, setConfigInternal] = useState<IPublicRoomDirectoryConfig | null | undefined>(undefined);
|
||||
|
||||
const [protocols, setProtocols] = useState<Protocols | null>(null);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [updateQuery, updateResult] = useLatestResult<IRoomDirectoryOptions, IPublicRoomsChunkRoom[]>(setPublicRooms);
|
||||
|
||||
async function initProtocols() {
|
||||
if (!MatrixClientPeg.get()) {
|
||||
// We may not have a client yet when invoked from welcome page
|
||||
|
@ -57,12 +61,11 @@ export const usePublicRoomDirectory = () => {
|
|||
}
|
||||
}
|
||||
|
||||
function setConfig(server: string, instanceId?: string) {
|
||||
function setConfig(config: IPublicRoomDirectoryConfig) {
|
||||
if (!ready) {
|
||||
throw new Error("public room configuration not initialised yet");
|
||||
} else {
|
||||
setRoomServer(server);
|
||||
setInstanceId(instanceId ?? null);
|
||||
setConfigInternal(config);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,21 +73,16 @@ export const usePublicRoomDirectory = () => {
|
|||
limit = 20,
|
||||
query,
|
||||
}: IPublicRoomsOpts): Promise<boolean> => {
|
||||
if (!query?.length) {
|
||||
setPublicRooms([]);
|
||||
return true;
|
||||
}
|
||||
|
||||
const opts: IRoomDirectoryOptions = { limit };
|
||||
|
||||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
if (config?.roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = config?.roomServer;
|
||||
}
|
||||
|
||||
if (instanceId === ALL_ROOMS) {
|
||||
if (config?.instanceId === ALL_ROOMS) {
|
||||
opts.include_all_networks = true;
|
||||
} else if (instanceId) {
|
||||
opts.third_party_instance_id = instanceId;
|
||||
} else if (config?.instanceId) {
|
||||
opts.third_party_instance_id = config.instanceId;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
|
@ -93,19 +91,20 @@ export const usePublicRoomDirectory = () => {
|
|||
};
|
||||
}
|
||||
|
||||
updateQuery(opts);
|
||||
try {
|
||||
setLoading(true);
|
||||
const { chunk } = await MatrixClientPeg.get().publicRooms(opts);
|
||||
setPublicRooms(chunk);
|
||||
updateResult(opts, chunk);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Could not fetch public rooms for params", opts, e);
|
||||
setPublicRooms([]);
|
||||
updateResult(opts, []);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [roomServer, instanceId]);
|
||||
}, [config, updateQuery, updateResult]);
|
||||
|
||||
useEffect(() => {
|
||||
initProtocols();
|
||||
|
@ -118,9 +117,9 @@ export const usePublicRoomDirectory = () => {
|
|||
|
||||
const myHomeserver = MatrixClientPeg.getHomeserverName();
|
||||
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
|
||||
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
|
||||
const lsInstanceId: string | undefined = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined;
|
||||
|
||||
let roomServer = myHomeserver;
|
||||
let roomServer: string = myHomeserver;
|
||||
if (
|
||||
SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) ||
|
||||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
|
||||
|
@ -128,7 +127,7 @@ export const usePublicRoomDirectory = () => {
|
|||
roomServer = lsRoomServer;
|
||||
}
|
||||
|
||||
let instanceId: string | null = null;
|
||||
let instanceId: string | undefined = undefined;
|
||||
if (roomServer === myHomeserver && (
|
||||
lsInstanceId === ALL_ROOMS ||
|
||||
Object.values(protocols).some((p: IProtocol) => {
|
||||
|
@ -139,25 +138,24 @@ export const usePublicRoomDirectory = () => {
|
|||
}
|
||||
|
||||
setReady(true);
|
||||
setInstanceId(instanceId);
|
||||
setRoomServer(roomServer);
|
||||
setConfigInternal({ roomServer, instanceId });
|
||||
}, [protocols]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LAST_SERVER_KEY, roomServer);
|
||||
}, [roomServer]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
|
||||
}, [instanceId]);
|
||||
localStorage.setItem(LAST_SERVER_KEY, config?.roomServer);
|
||||
if (config?.instanceId) {
|
||||
localStorage.setItem(LAST_INSTANCE_KEY, config?.instanceId);
|
||||
} else {
|
||||
localStorage.removeItem(LAST_INSTANCE_KEY);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
return {
|
||||
ready,
|
||||
loading,
|
||||
publicRooms,
|
||||
protocols,
|
||||
roomServer,
|
||||
instanceId,
|
||||
config,
|
||||
search,
|
||||
setConfig,
|
||||
} as const;
|
||||
|
|
69
src/hooks/useSpaceResults.ts
Normal file
69
src/hooks/useSpaceResults.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Room, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
|
||||
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||
import { normalize } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
|
||||
export const useSpaceResults = (space?: Room, query?: string): [IHierarchyRoom[], boolean] => {
|
||||
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
|
||||
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
|
||||
|
||||
const resetHierarchy = useCallback(() => {
|
||||
setHierarchy(space ? new RoomHierarchy(space, 50) : null);
|
||||
}, [space]);
|
||||
useEffect(resetHierarchy, [resetHierarchy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!space || !hierarchy) return; // nothing to load
|
||||
|
||||
let unmounted = false;
|
||||
|
||||
(async () => {
|
||||
while (hierarchy?.canLoadMore && !unmounted && space === hierarchy.root) {
|
||||
await hierarchy.load();
|
||||
if (hierarchy.canLoadMore) hierarchy.load(); // start next load so that the loading attribute is right
|
||||
setRooms(hierarchy.rooms);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
}, [space, hierarchy]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const trimmedQuery = query.trim();
|
||||
const lcQuery = trimmedQuery.toLowerCase();
|
||||
const normalizedQuery = normalize(trimmedQuery);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
return rooms?.filter(r => {
|
||||
return r.room_type !== RoomType.Space &&
|
||||
cli.getRoom(r.room_id)?.getMyMembership() !== "join" &&
|
||||
(
|
||||
normalize(r.name || "").includes(normalizedQuery) ||
|
||||
(r.canonical_alias || "").includes(lcQuery)
|
||||
);
|
||||
});
|
||||
}, [rooms, query]);
|
||||
|
||||
return [results, hierarchy?.loading ?? false];
|
||||
};
|
|
@ -18,6 +18,7 @@ import { useCallback, useState } from "react";
|
|||
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { DirectoryMember } from "../utils/direct-messages";
|
||||
import { useLatestResult } from "./useLatestResult";
|
||||
|
||||
export interface IUserDirectoryOpts {
|
||||
limit: number;
|
||||
|
@ -29,10 +30,15 @@ export const useUserDirectory = () => {
|
|||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [updateQuery, updateResult] = useLatestResult<{ term: string, limit?: number }, DirectoryMember[]>(setUsers);
|
||||
|
||||
const search = useCallback(async ({
|
||||
limit = 20,
|
||||
query: term,
|
||||
}: IUserDirectoryOpts): Promise<boolean> => {
|
||||
const opts = { limit, term };
|
||||
updateQuery(opts);
|
||||
|
||||
if (!term?.length) {
|
||||
setUsers([]);
|
||||
return true;
|
||||
|
@ -40,20 +46,17 @@ export const useUserDirectory = () => {
|
|||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { results } = await MatrixClientPeg.get().searchUserDirectory({
|
||||
limit,
|
||||
term,
|
||||
});
|
||||
setUsers(results.map(user => new DirectoryMember(user)));
|
||||
const { results } = await MatrixClientPeg.get().searchUserDirectory(opts);
|
||||
updateResult(opts, results.map(user => new DirectoryMember(user)));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Could not fetch user in user directory for params", { limit, term }, e);
|
||||
setUsers([]);
|
||||
updateResult(opts, []);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [updateQuery, updateResult]);
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
|
|
|
@ -2383,15 +2383,14 @@
|
|||
"You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list",
|
||||
"Can't find this server or its room list": "Can't find this server or its room list",
|
||||
"Your server": "Your server",
|
||||
"Are you sure you want to remove <b>%(serverName)s</b>": "Are you sure you want to remove <b>%(serverName)s</b>",
|
||||
"Remove server": "Remove server",
|
||||
"Matrix": "Matrix",
|
||||
"Remove server “%(roomServer)s”": "Remove server “%(roomServer)s”",
|
||||
"Add a new server": "Add a new server",
|
||||
"Enter the name of a new server you want to explore.": "Enter the name of a new server you want to explore.",
|
||||
"Server name": "Server name",
|
||||
"Add a new server...": "Add a new server...",
|
||||
"%(networkName)s rooms": "%(networkName)s rooms",
|
||||
"Matrix rooms": "Matrix rooms",
|
||||
"Add new server…": "Add new server…",
|
||||
"Show: %(instance)s rooms (%(server)s)": "Show: %(instance)s rooms (%(server)s)",
|
||||
"Show: Matrix rooms": "Show: Matrix rooms",
|
||||
"Add existing space": "Add existing space",
|
||||
"Want to add a new space instead?": "Want to add a new space instead?",
|
||||
"Create a new space": "Create a new space",
|
||||
|
@ -2759,18 +2758,6 @@
|
|||
"This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.",
|
||||
"Space settings": "Space settings",
|
||||
"Settings - %(spaceName)s": "Settings - %(spaceName)s",
|
||||
"Spaces you're in": "Spaces you're in",
|
||||
"Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s",
|
||||
"Join %(roomAddress)s": "Join %(roomAddress)s",
|
||||
"Use \"%(query)s\" to search": "Use \"%(query)s\" to search",
|
||||
"Public rooms": "Public rooms",
|
||||
"Other searches": "Other searches",
|
||||
"To search messages, look for this icon at the top of a room <icon/>": "To search messages, look for this icon at the top of a room <icon/>",
|
||||
"Recent searches": "Recent searches",
|
||||
"Clear": "Clear",
|
||||
"Use <arrows/> to scroll": "Use <arrows/> to scroll",
|
||||
"Search Dialog": "Search Dialog",
|
||||
"Results not as expected? Please <a>give feedback</a>.": "Results not as expected? Please <a>give feedback</a>.",
|
||||
"To help us prevent this in future, please <a>send us logs</a>.": "To help us prevent this in future, please <a>send us logs</a>.",
|
||||
"Missing session data": "Missing session data",
|
||||
"Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.",
|
||||
|
@ -2810,6 +2797,30 @@
|
|||
"Allow this widget to verify your identity": "Allow this widget to verify your identity",
|
||||
"The widget will verify your user ID, but won't be able to perform actions for you:": "The widget will verify your user ID, but won't be able to perform actions for you:",
|
||||
"Remember this": "Remember this",
|
||||
"%(count)s Members|other": "%(count)s Members",
|
||||
"%(count)s Members|one": "%(count)s Member",
|
||||
"Public rooms": "Public rooms",
|
||||
"Use \"%(query)s\" to search": "Use \"%(query)s\" to search",
|
||||
"Search for": "Search for",
|
||||
"Spaces you're in": "Spaces you're in",
|
||||
"Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s",
|
||||
"Join %(roomAddress)s": "Join %(roomAddress)s",
|
||||
"Some results may be hidden for privacy": "Some results may be hidden for privacy",
|
||||
"If you can't see who you're looking for, send them your invite link.": "If you can't see who you're looking for, send them your invite link.",
|
||||
"Copy invite link": "Copy invite link",
|
||||
"Some results may be hidden": "Some results may be hidden",
|
||||
"If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.",
|
||||
"Create new Room": "Create new Room",
|
||||
"Other options": "Other options",
|
||||
"Start a group chat": "Start a group chat",
|
||||
"Other searches": "Other searches",
|
||||
"To search messages, look for this icon at the top of a room <icon/>": "To search messages, look for this icon at the top of a room <icon/>",
|
||||
"Recent searches": "Recent searches",
|
||||
"Clear": "Clear",
|
||||
"Use <arrows/> to scroll": "Use <arrows/> to scroll",
|
||||
"Search Dialog": "Search Dialog",
|
||||
"Remove search filter for %(filter)s": "Remove search filter for %(filter)s",
|
||||
"Results not as expected? Please <a>give feedback</a>.": "Results not as expected? Please <a>give feedback</a>.",
|
||||
"Wrong file type": "Wrong file type",
|
||||
"Looks good!": "Looks good!",
|
||||
"Wrong Security Key": "Wrong Security Key",
|
||||
|
|
|
@ -23,7 +23,7 @@ export type Protocols = Record<string, IProtocol>;
|
|||
|
||||
// Find a protocol 'instance' with a given instance_id
|
||||
// in the supplied protocols dict
|
||||
export function instanceForInstanceId(protocols: Protocols, instanceId: string): IInstance {
|
||||
export function instanceForInstanceId(protocols: Protocols, instanceId: string | null | undefined): IInstance | null {
|
||||
if (!instanceId) return null;
|
||||
for (const proto of Object.keys(protocols)) {
|
||||
if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue;
|
||||
|
@ -31,11 +31,12 @@ export function instanceForInstanceId(protocols: Protocols, instanceId: string):
|
|||
if (instance.instance_id == instanceId) return instance;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// given an instance_id, return the name of the protocol for
|
||||
// that instance ID in the supplied protocols dict
|
||||
export function protocolNameForInstanceId(protocols: Protocols, instanceId: string): string {
|
||||
export function protocolNameForInstanceId(protocols: Protocols, instanceId: string | null | undefined): string | null {
|
||||
if (!instanceId) return null;
|
||||
for (const proto of Object.keys(protocols)) {
|
||||
if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue;
|
||||
|
@ -43,4 +44,5 @@ export function protocolNameForInstanceId(protocols: Protocols, instanceId: stri
|
|||
if (instance.instance_id == instanceId) return proto;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
110
src/utils/SortMembers.ts
Normal file
110
src/utils/SortMembers.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
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 { groupBy, mapValues, maxBy, minBy, sumBy, takeRight } from "lodash";
|
||||
import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { Member } from "./direct-messages";
|
||||
import DMRoomMap from "./DMRoomMap";
|
||||
import { compare } from "./strings";
|
||||
|
||||
export const compareMembers = (
|
||||
activityScores: Record<string, IActivityScore>,
|
||||
memberScores: Record<string, IMemberScore>,
|
||||
) => (a: Member | RoomMember, b: Member | RoomMember): number => {
|
||||
const aActivityScore = activityScores[a.userId]?.score ?? 0;
|
||||
const aMemberScore = memberScores[a.userId]?.score ?? 0;
|
||||
const aScore = aActivityScore + aMemberScore;
|
||||
const aNumRooms = memberScores[a.userId]?.numRooms ?? 0;
|
||||
|
||||
const bActivityScore = activityScores[b.userId]?.score ?? 0;
|
||||
const bMemberScore = memberScores[b.userId]?.score ?? 0;
|
||||
const bScore = bActivityScore + bMemberScore;
|
||||
const bNumRooms = memberScores[b.userId]?.numRooms ?? 0;
|
||||
|
||||
if (aScore === bScore) {
|
||||
if (aNumRooms === bNumRooms) {
|
||||
return compare(a.userId, b.userId);
|
||||
}
|
||||
|
||||
return bNumRooms - aNumRooms;
|
||||
}
|
||||
return bScore - aScore;
|
||||
};
|
||||
|
||||
function joinedRooms(cli: MatrixClient): Room[] {
|
||||
return cli.getRooms()
|
||||
.filter(r => r.getMyMembership() === 'join')
|
||||
// Skip low priority rooms and DMs
|
||||
.filter(r => !DMRoomMap.shared().getUserIdForRoomId(r.roomId))
|
||||
.filter(r => !Object.keys(r.tags).includes("m.lowpriority"));
|
||||
}
|
||||
|
||||
interface IActivityScore {
|
||||
lastSpoke: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// Score people based on who have sent messages recently, as a way to improve the quality of suggestions.
|
||||
// We do this by checking every room to see who has sent a message in the last few hours, and giving them
|
||||
// a score which correlates to the freshness of their message. In theory, this results in suggestions
|
||||
// which are closer to "continue this conversation" rather than "this person exists".
|
||||
export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivityScore } {
|
||||
const now = new Date().getTime();
|
||||
const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago
|
||||
const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
|
||||
const events = joinedRooms(cli)
|
||||
.flatMap(room => takeRight(room.getLiveTimeline().getEvents(), maxMessagesConsidered))
|
||||
.filter(ev => ev.getTs() > earliestAgeConsidered);
|
||||
const senderEvents = groupBy(events, ev => ev.getSender());
|
||||
return mapValues(senderEvents, events => {
|
||||
const lastEvent = maxBy(events, ev => ev.getTs());
|
||||
const distanceFromNow = Math.abs(now - lastEvent.getTs()); // abs to account for slight future messages
|
||||
const inverseTime = (now - earliestAgeConsidered) - distanceFromNow;
|
||||
return {
|
||||
lastSpoke: lastEvent.getTs(),
|
||||
// Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
|
||||
// score we'll try and award at least 1.0 for making the list, with 4.0 being
|
||||
// an approximate maximum for being selected.
|
||||
score: Math.max(1, inverseTime / (15 * 60 * 1000)), // 15min segments to keep scores sane
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface IMemberScore {
|
||||
member: RoomMember;
|
||||
score: number;
|
||||
numRooms: number;
|
||||
}
|
||||
|
||||
export function buildMemberScores(cli: MatrixClient): { [key: string]: IMemberScore } {
|
||||
const maxConsideredMembers = 200;
|
||||
const consideredRooms = joinedRooms(cli).filter(room => room.getJoinedMemberCount() < maxConsideredMembers);
|
||||
const memberPeerEntries = consideredRooms
|
||||
.flatMap(room =>
|
||||
room.getJoinedMembers().map(member =>
|
||||
({ member, roomSize: room.getJoinedMemberCount() })));
|
||||
const userMeta = groupBy(memberPeerEntries, ({ member }) => member.userId);
|
||||
return mapValues(userMeta, roomMemberships => {
|
||||
const maximumPeers = maxConsideredMembers * roomMemberships.length;
|
||||
const totalPeers = sumBy(roomMemberships, entry => entry.roomSize);
|
||||
return {
|
||||
member: minBy(roomMemberships, entry => entry.roomSize).member,
|
||||
numRooms: roomMemberships.length,
|
||||
score: Math.max(0, Math.pow(1 - (totalPeers / maximumPeers), 5)),
|
||||
};
|
||||
});
|
||||
}
|
292
test/components/views/dialogs/SpotlightDialog-test.tsx
Normal file
292
test/components/views/dialogs/SpotlightDialog-test.tsx
Normal file
|
@ -0,0 +1,292 @@
|
|||
/*
|
||||
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 { mount } from "enzyme";
|
||||
import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import React from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog";
|
||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import { stubClient } from "../../../test-utils";
|
||||
|
||||
interface IUserChunkMember {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
interface MockClientOptions {
|
||||
userId?: string;
|
||||
homeserver?: string;
|
||||
thirdPartyProtocols?: Record<string, IProtocol>;
|
||||
rooms?: IPublicRoomsChunkRoom[];
|
||||
members?: RoomMember[];
|
||||
users?: IUserChunkMember[];
|
||||
}
|
||||
|
||||
function mockClient(
|
||||
{
|
||||
userId = "testuser",
|
||||
homeserver = "example.tld",
|
||||
thirdPartyProtocols = {},
|
||||
rooms = [],
|
||||
members = [],
|
||||
users = [],
|
||||
}: MockClientOptions = {},
|
||||
): MatrixClient {
|
||||
stubClient();
|
||||
const cli = MatrixClientPeg.get();
|
||||
MatrixClientPeg.getHomeserverName = jest.fn(() => homeserver);
|
||||
cli.getUserId = jest.fn(() => userId);
|
||||
cli.getHomeserverUrl = jest.fn(() => homeserver);
|
||||
cli.getThirdpartyProtocols = jest.fn(() => Promise.resolve(thirdPartyProtocols));
|
||||
cli.publicRooms = jest.fn((options) => {
|
||||
const searchTerm = options?.filter?.generic_search_term?.toLowerCase();
|
||||
const chunk = rooms.filter(it =>
|
||||
!searchTerm ||
|
||||
it.room_id.toLowerCase().includes(searchTerm) ||
|
||||
it.name?.toLowerCase().includes(searchTerm) ||
|
||||
sanitizeHtml(it?.topic, { allowedTags: [] }).toLowerCase().includes(searchTerm) ||
|
||||
it.canonical_alias?.toLowerCase().includes(searchTerm) ||
|
||||
it.aliases?.find(alias => alias.toLowerCase().includes(searchTerm)));
|
||||
return Promise.resolve({
|
||||
chunk,
|
||||
total_room_count_estimate: chunk.length,
|
||||
});
|
||||
});
|
||||
cli.searchUserDirectory = jest.fn(({ term, limit }) => {
|
||||
const searchTerm = term?.toLowerCase();
|
||||
const results = users.filter(it => !searchTerm ||
|
||||
it.user_id.toLowerCase().includes(searchTerm) ||
|
||||
it.display_name.toLowerCase().includes(searchTerm));
|
||||
return Promise.resolve({
|
||||
results: results.slice(0, limit ?? +Infinity),
|
||||
limited: limit && limit < results.length,
|
||||
});
|
||||
});
|
||||
cli.getProfileInfo = jest.fn(async (userId) => {
|
||||
const member = members.find(it => it.userId === userId);
|
||||
if (member) {
|
||||
return Promise.resolve({
|
||||
displayname: member.rawDisplayName,
|
||||
avatar_url: member.getMxcAvatarUrl(),
|
||||
});
|
||||
} else {
|
||||
return Promise.reject();
|
||||
}
|
||||
});
|
||||
return cli;
|
||||
}
|
||||
|
||||
describe("Spotlight Dialog", () => {
|
||||
const testPerson: IUserChunkMember = {
|
||||
user_id: "@janedoe:matrix.org",
|
||||
display_name: "Jane Doe",
|
||||
avatar_url: undefined,
|
||||
};
|
||||
|
||||
const testPublicRoom: IPublicRoomsChunkRoom = {
|
||||
room_id: "@room247:matrix.org",
|
||||
name: "Room #247",
|
||||
topic: "We hope you'll have a <b>shining</b> experience!",
|
||||
world_readable: false,
|
||||
num_joined_members: 1,
|
||||
guest_can_join: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient({ rooms: [testPublicRoom], users: [testPerson] });
|
||||
});
|
||||
|
||||
describe("should apply filters supplied via props", () => {
|
||||
it("without filter", async () => {
|
||||
const wrapper = mount(
|
||||
<SpotlightDialog
|
||||
initialFilter={null}
|
||||
onFinished={() => null} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await sleep(200);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeFalsy();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
it("with public room filter", async () => {
|
||||
const wrapper = mount(
|
||||
<SpotlightDialog
|
||||
initialFilter={Filter.PublicRooms}
|
||||
onFinished={() => null} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await sleep(200);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeTruthy();
|
||||
expect(filterChip.text()).toEqual("Public rooms");
|
||||
|
||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
||||
const options = content.find("div.mx_SpotlightDialog_option");
|
||||
expect(options.length).toBe(1);
|
||||
expect(options.first().text()).toContain(testPublicRoom.name);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
it("with people filter", async () => {
|
||||
const wrapper = mount(
|
||||
<SpotlightDialog
|
||||
initialFilter={Filter.People}
|
||||
initialText={testPerson.display_name}
|
||||
onFinished={() => null} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await sleep(200);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeTruthy();
|
||||
expect(filterChip.text()).toEqual("People");
|
||||
|
||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
||||
const options = content.find("div.mx_SpotlightDialog_option");
|
||||
expect(options.length).toBeGreaterThanOrEqual(1);
|
||||
expect(options.first().text()).toContain(testPerson.display_name);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should apply manually selected filter", () => {
|
||||
it("with public rooms", async () => {
|
||||
const wrapper = mount(
|
||||
<SpotlightDialog
|
||||
onFinished={() => null} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find("#mx_SpotlightDialog_button_explorePublicRooms").first().simulate("click");
|
||||
await act(async () => {
|
||||
await sleep(200);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeTruthy();
|
||||
expect(filterChip.text()).toEqual("Public rooms");
|
||||
|
||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
||||
const options = content.find("div.mx_SpotlightDialog_option");
|
||||
expect(options.length).toBe(1);
|
||||
expect(options.first().text()).toContain(testPublicRoom.name);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
it("with people", async () => {
|
||||
const wrapper = mount(
|
||||
<SpotlightDialog
|
||||
initialText={testPerson.display_name}
|
||||
onFinished={() => null} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find("#mx_SpotlightDialog_button_startChat").first().simulate("click");
|
||||
await act(async () => {
|
||||
await sleep(200);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeTruthy();
|
||||
expect(filterChip.text()).toEqual("People");
|
||||
|
||||
const content = wrapper.find("#mx_SpotlightDialog_content");
|
||||
const options = content.find("div.mx_SpotlightDialog_option");
|
||||
expect(options.length).toBeGreaterThanOrEqual(1);
|
||||
expect(options.first().text()).toContain(testPerson.display_name);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("should allow clearing filter manually", () => {
|
||||
it("with public room filter", async () => {
|
||||
const wrapper = mount(
|
||||
<SpotlightDialog
|
||||
initialFilter={Filter.PublicRooms}
|
||||
onFinished={() => null} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await sleep(200);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
let filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeTruthy();
|
||||
expect(filterChip.text()).toEqual("Public rooms");
|
||||
|
||||
filterChip.find("div.mx_SpotlightDialog_filter--close").simulate("click");
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeFalsy();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
it("with people filter", async () => {
|
||||
const wrapper = mount(
|
||||
<SpotlightDialog
|
||||
initialFilter={Filter.People}
|
||||
initialText={testPerson.display_name}
|
||||
onFinished={() => null} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await sleep(200);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
let filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeTruthy();
|
||||
expect(filterChip.text()).toEqual("People");
|
||||
|
||||
filterChip.find("div.mx_SpotlightDialog_filter--close").simulate("click");
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
|
||||
expect(filterChip.exists()).toBeFalsy();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
179
test/hooks/useDebouncedCallback-test.tsx
Normal file
179
test/hooks/useDebouncedCallback-test.tsx
Normal file
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
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 { mount } from "enzyme";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import React from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
import { useDebouncedCallback } from "../../src/hooks/spotlight/useDebouncedCallback";
|
||||
|
||||
function DebouncedCallbackComponent({ enabled, params, callback }) {
|
||||
useDebouncedCallback(enabled, callback, params);
|
||||
return <div>
|
||||
{ JSON.stringify(params) }
|
||||
</div>;
|
||||
}
|
||||
|
||||
describe("useDebouncedCallback", () => {
|
||||
it("should be able to handle empty parameters", async () => {
|
||||
const params = [];
|
||||
const callback = jest.fn();
|
||||
|
||||
const wrapper = mount(<DebouncedCallbackComponent callback={callback} enabled={true} params={[]} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.setProps({ enabled: true, params, callback });
|
||||
return act(() => sleep(500));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(JSON.stringify(params));
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call the callback with the parameters", async () => {
|
||||
const params = ["USER NAME"];
|
||||
const callback = jest.fn();
|
||||
|
||||
const wrapper = mount(<DebouncedCallbackComponent callback={callback} enabled={true} params={[]} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.setProps({ enabled: true, params, callback });
|
||||
return act(() => sleep(500));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(JSON.stringify(params));
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(...params);
|
||||
});
|
||||
|
||||
it("should handle multiple parameters", async () => {
|
||||
const params = [4, 8, 15, 16, 23, 42];
|
||||
const callback = jest.fn();
|
||||
|
||||
const wrapper = mount(<DebouncedCallbackComponent callback={callback} enabled={true} params={[]} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.setProps({ enabled: true, params, callback });
|
||||
return act(() => sleep(500));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(JSON.stringify(params));
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(...params);
|
||||
});
|
||||
|
||||
it("should debounce quick changes", async () => {
|
||||
const queries = [
|
||||
"U",
|
||||
"US",
|
||||
"USE",
|
||||
"USER",
|
||||
"USER ",
|
||||
"USER N",
|
||||
"USER NM",
|
||||
"USER NMA",
|
||||
"USER NM",
|
||||
"USER N",
|
||||
"USER NA",
|
||||
"USER NAM",
|
||||
"USER NAME",
|
||||
];
|
||||
const callback = jest.fn();
|
||||
|
||||
const wrapper = mount(<DebouncedCallbackComponent callback={callback} enabled={true} params={[]} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
for (const query of queries) {
|
||||
wrapper.setProps({ enabled: true, params: [query], callback });
|
||||
await sleep(50);
|
||||
}
|
||||
return act(() => sleep(500));
|
||||
});
|
||||
|
||||
const query = queries[queries.length - 1];
|
||||
expect(wrapper.text()).toContain(JSON.stringify(query));
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(query);
|
||||
});
|
||||
|
||||
it("should not debounce slow changes", async () => {
|
||||
const queries = [
|
||||
"U",
|
||||
"US",
|
||||
"USE",
|
||||
"USER",
|
||||
"USER ",
|
||||
"USER N",
|
||||
"USER NM",
|
||||
"USER NMA",
|
||||
"USER NM",
|
||||
"USER N",
|
||||
"USER NA",
|
||||
"USER NAM",
|
||||
"USER NAME",
|
||||
];
|
||||
const callback = jest.fn();
|
||||
|
||||
const wrapper = mount(<DebouncedCallbackComponent callback={callback} enabled={true} params={[]} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
for (const query of queries) {
|
||||
wrapper.setProps({ enabled: true, params: [query], callback });
|
||||
await sleep(200);
|
||||
}
|
||||
return act(() => sleep(500));
|
||||
});
|
||||
|
||||
const query = queries[queries.length - 1];
|
||||
expect(wrapper.text()).toContain(JSON.stringify(query));
|
||||
expect(callback).toHaveBeenCalledTimes(queries.length);
|
||||
expect(callback).toHaveBeenCalledWith(query);
|
||||
});
|
||||
|
||||
it("should not call the callback if it’s disabled", async () => {
|
||||
const queries = [
|
||||
"U",
|
||||
"US",
|
||||
"USE",
|
||||
"USER",
|
||||
"USER ",
|
||||
"USER N",
|
||||
"USER NM",
|
||||
"USER NMA",
|
||||
"USER NM",
|
||||
"USER N",
|
||||
"USER NA",
|
||||
"USER NAM",
|
||||
"USER NAME",
|
||||
];
|
||||
const callback = jest.fn();
|
||||
|
||||
const wrapper = mount(<DebouncedCallbackComponent callback={callback} enabled={false} params={[]} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
for (const query of queries) {
|
||||
wrapper.setProps({ enabled: false, params: [query], callback });
|
||||
await sleep(200);
|
||||
}
|
||||
return act(() => sleep(500));
|
||||
});
|
||||
|
||||
const query = queries[queries.length - 1];
|
||||
expect(wrapper.text()).toContain(JSON.stringify(query));
|
||||
expect(callback).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
91
test/hooks/useLatestResult-test.tsx
Normal file
91
test/hooks/useLatestResult-test.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
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 { mount } from "enzyme";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
import { useLatestResult } from "../../src/hooks/useLatestResult";
|
||||
|
||||
function LatestResultsComponent({ query, doRequest }) {
|
||||
const [value, setValueInternal] = useState<number>(0);
|
||||
const [updateQuery, updateResult] = useLatestResult(setValueInternal);
|
||||
useEffect(() => {
|
||||
updateQuery(query);
|
||||
doRequest(query).then(it => {
|
||||
updateResult(query, it);
|
||||
});
|
||||
}, [doRequest, query, updateQuery, updateResult]);
|
||||
|
||||
return <div>
|
||||
{ value }
|
||||
</div>;
|
||||
}
|
||||
|
||||
describe("useLatestResult", () => {
|
||||
it("should return results", async () => {
|
||||
const doRequest = async (query) => {
|
||||
await sleep(20);
|
||||
return query;
|
||||
};
|
||||
|
||||
const wrapper = mount(<LatestResultsComponent query={0} doRequest={doRequest} />);
|
||||
await act(async () => {
|
||||
await sleep(25);
|
||||
});
|
||||
expect(wrapper.text()).toContain("0");
|
||||
wrapper.setProps({ doRequest, query: 1 });
|
||||
await act(async () => {
|
||||
await sleep(15);
|
||||
});
|
||||
wrapper.setProps({ doRequest, query: 2 });
|
||||
await act(async () => {
|
||||
await sleep(15);
|
||||
});
|
||||
expect(wrapper.text()).toContain("0");
|
||||
await act(async () => {
|
||||
await sleep(15);
|
||||
});
|
||||
expect(wrapper.text()).toContain("2");
|
||||
});
|
||||
|
||||
it("should prevent out-of-order results", async () => {
|
||||
const doRequest = async (query) => {
|
||||
await sleep(query);
|
||||
return query;
|
||||
};
|
||||
|
||||
const wrapper = mount(<LatestResultsComponent query={0} doRequest={doRequest} />);
|
||||
await act(async () => {
|
||||
await sleep(5);
|
||||
});
|
||||
expect(wrapper.text()).toContain("0");
|
||||
wrapper.setProps({ doRequest, query: 50 });
|
||||
await act(async () => {
|
||||
await sleep(5);
|
||||
});
|
||||
wrapper.setProps({ doRequest, query: 1 });
|
||||
await act(async () => {
|
||||
await sleep(5);
|
||||
});
|
||||
expect(wrapper.text()).toContain("1");
|
||||
await act(async () => {
|
||||
await sleep(50);
|
||||
});
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
});
|
154
test/hooks/useProfileInfo-test.tsx
Normal file
154
test/hooks/useProfileInfo-test.tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
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 { mount } from "enzyme";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import React from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
|
||||
import { useProfileInfo } from "../../src/hooks/useProfileInfo";
|
||||
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||
import { stubClient } from "../test-utils/test-utils";
|
||||
|
||||
function ProfileInfoComponent({ onClick }) {
|
||||
const profileInfo = useProfileInfo();
|
||||
|
||||
const {
|
||||
ready,
|
||||
loading,
|
||||
profile,
|
||||
} = profileInfo;
|
||||
|
||||
return <div onClick={() => onClick(profileInfo)}>
|
||||
{ (!ready || loading) && `ready: ${ready}, loading: ${loading}` }
|
||||
{ profile && (
|
||||
`Name: ${profile.display_name}`
|
||||
) }
|
||||
</div>;
|
||||
}
|
||||
|
||||
describe("useProfileInfo", () => {
|
||||
let cli;
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
cli = MatrixClientPeg.get();
|
||||
cli.getProfileInfo = (query) => {
|
||||
return Promise.resolve({
|
||||
avatar_url: undefined,
|
||||
displayname: query,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
it("should display user profile when searching", async () => {
|
||||
const query = "@user:home.server";
|
||||
|
||||
const wrapper = mount(<ProfileInfoComponent onClick={(hook) => {
|
||||
hook.search({
|
||||
limit: 1,
|
||||
query,
|
||||
});
|
||||
}} />);
|
||||
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.simulate("click");
|
||||
return act(() => sleep(1));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(query);
|
||||
});
|
||||
|
||||
it("should work with empty queries", async () => {
|
||||
const wrapper = mount(<ProfileInfoComponent onClick={(hook) => {
|
||||
hook.search({
|
||||
limit: 1,
|
||||
query: "",
|
||||
});
|
||||
}} />);
|
||||
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.simulate("click");
|
||||
return act(() => sleep(1));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should treat invalid mxids as empty queries", async () => {
|
||||
const queries = [
|
||||
"@user",
|
||||
"user@home.server",
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
const wrapper = mount(<ProfileInfoComponent onClick={(hook) => {
|
||||
hook.search({
|
||||
limit: 1,
|
||||
query,
|
||||
});
|
||||
}} />);
|
||||
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.simulate("click");
|
||||
return act(() => sleep(1));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("should recover from a server exception", async () => {
|
||||
cli.getProfileInfo = () => { throw new Error("Oops"); };
|
||||
const query = "@user:home.server";
|
||||
|
||||
const wrapper = mount(<ProfileInfoComponent onClick={(hook) => {
|
||||
hook.search({
|
||||
limit: 1,
|
||||
query,
|
||||
});
|
||||
}} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.simulate("click");
|
||||
return act(() => sleep(1));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should be able to handle an empty result", async () => {
|
||||
cli.getProfileInfo = () => null;
|
||||
const query = "@user:home.server";
|
||||
|
||||
const wrapper = mount(<ProfileInfoComponent onClick={(hook) => {
|
||||
hook.search({
|
||||
limit: 1,
|
||||
query,
|
||||
});
|
||||
}} />);
|
||||
await act(async () => {
|
||||
await sleep(1);
|
||||
wrapper.simulate("click");
|
||||
return act(() => sleep(1));
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe("");
|
||||
});
|
||||
});
|
|
@ -96,7 +96,7 @@ describe("useUserDirectory", () => {
|
|||
expect(wrapper.text()).toBe("ready: true, loading: false");
|
||||
});
|
||||
|
||||
it("should work with empty queries", async () => {
|
||||
it("should recover from a server exception", async () => {
|
||||
cli.searchUserDirectory = () => { throw new Error("Oops"); };
|
||||
const query = "Bob";
|
||||
|
||||
|
|
Loading…
Reference in a new issue