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