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:
Janne Mareike Koschinski 2022-06-15 16:14:05 +02:00 committed by GitHub
parent 37298d7b1b
commit 5096e7b992
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 3520 additions and 1397 deletions

View 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 cant test finding rooms on other homeservers/other protocols
// We obviously dont 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");
});
});
});

View file

@ -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;

View file

@ -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
View 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 { };

View file

@ -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";

View 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;
}
}
}
}

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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;

View file

@ -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");
}
}

View 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 }
</>;
}

View file

@ -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 }

View file

@ -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";

View 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 cant 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}
/>;
}
}

View file

@ -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 {

View file

@ -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;

View 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>;
};

View file

@ -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 && (
<>
&nbsp;·&nbsp;
<span
className="mx_SpotlightDialog_result_publicRoomTopic"
dangerouslySetInnerHTML={{ __html: linkifyAndSanitizeHtml(topic) }}
/>
</>
) }
</div>
</div>
);
}

View 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;
};

File diff suppressed because it is too large Load diff

View 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"
/>;
};

View file

@ -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;

View file

@ -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";

View 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]);
}

View 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([]);
}];
};

View 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];
};

View 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;
};

View file

@ -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;

View 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];
};

View file

@ -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,

View file

@ -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",

View file

@ -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
View 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)),
};
});
}

View 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();
});
});
});

View 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 its 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);
});
});

View 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");
});
});

View 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("");
});
});

View file

@ -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";