From 5096e7b992cecefc392244feb0345c7172cee9bd Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Wed, 15 Jun 2022 16:14:05 +0200 Subject: [PATCH] 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 --- .../12-spotlight/spotlight.spec.ts | 302 +++++ cypress/integration/5-threads/threads.spec.ts | 3 +- cypress/support/index.ts | 1 + cypress/support/labs.ts | 42 + res/css/_components.scss | 1 + res/css/structures/_GenericDropdownMenu.scss | 123 ++ res/css/structures/_RoomDirectory.scss | 5 +- res/css/views/dialogs/_InviteDialog.scss | 6 +- res/css/views/dialogs/_SpotlightDialog.scss | 186 ++- res/css/views/directory/_NetworkDropdown.scss | 163 +-- .../structures/GenericDropdownMenu.tsx | 183 +++ src/components/structures/RoomDirectory.tsx | 86 +- src/components/structures/RoomSearch.tsx | 2 +- .../views/avatars/SearchResultAvatar.tsx | 53 + src/components/views/dialogs/InviteDialog.tsx | 143 +-- .../views/dialogs/SpotlightDialog.tsx | 786 ------------ .../views/dialogs/spotlight/Option.tsx | 43 + .../spotlight/PublicRoomResultDetails.tsx | 67 ++ .../dialogs/spotlight/RoomResultDetails.tsx | 31 + .../dialogs/spotlight/SpotlightDialog.tsx | 1057 +++++++++++++++++ .../views/dialogs/spotlight/TooltipOption.tsx | 39 + .../views/directory/NetworkDropdown.tsx | 383 +++--- src/components/views/rooms/RoomListHeader.tsx | 2 +- src/hooks/spotlight/useDebouncedCallback.ts | 41 + src/hooks/spotlight/useRecentSearches.ts | 35 + src/hooks/useLatestResult.ts | 35 + src/hooks/useProfileInfo.ts | 70 ++ src/hooks/usePublicRoomDirectory.ts | 64 +- src/hooks/useSpaceResults.ts | 69 ++ src/hooks/useUserDirectory.ts | 17 +- src/i18n/strings/en_EN.json | 45 +- src/utils/DirectoryUtils.ts | 6 +- src/utils/SortMembers.ts | 110 ++ .../views/dialogs/SpotlightDialog-test.tsx | 292 +++++ test/hooks/useDebouncedCallback-test.tsx | 179 +++ test/hooks/useLatestResult-test.tsx | 91 ++ test/hooks/useProfileInfo-test.tsx | 154 +++ test/hooks/useUserDirectory-test.tsx | 2 +- 38 files changed, 3520 insertions(+), 1397 deletions(-) create mode 100644 cypress/integration/12-spotlight/spotlight.spec.ts create mode 100644 cypress/support/labs.ts create mode 100644 res/css/structures/_GenericDropdownMenu.scss create mode 100644 src/components/structures/GenericDropdownMenu.tsx create mode 100644 src/components/views/avatars/SearchResultAvatar.tsx delete mode 100644 src/components/views/dialogs/SpotlightDialog.tsx create mode 100644 src/components/views/dialogs/spotlight/Option.tsx create mode 100644 src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx create mode 100644 src/components/views/dialogs/spotlight/RoomResultDetails.tsx create mode 100644 src/components/views/dialogs/spotlight/SpotlightDialog.tsx create mode 100644 src/components/views/dialogs/spotlight/TooltipOption.tsx create mode 100644 src/hooks/spotlight/useDebouncedCallback.ts create mode 100644 src/hooks/spotlight/useRecentSearches.ts create mode 100644 src/hooks/useLatestResult.ts create mode 100644 src/hooks/useProfileInfo.ts create mode 100644 src/hooks/useSpaceResults.ts create mode 100644 src/utils/SortMembers.ts create mode 100644 test/components/views/dialogs/SpotlightDialog-test.tsx create mode 100644 test/hooks/useDebouncedCallback-test.tsx create mode 100644 test/hooks/useLatestResult-test.tsx create mode 100644 test/hooks/useProfileInfo-test.tsx diff --git a/cypress/integration/12-spotlight/spotlight.spec.ts b/cypress/integration/12-spotlight/spotlight.spec.ts new file mode 100644 index 0000000000..df5cc134c4 --- /dev/null +++ b/cypress/integration/12-spotlight/spotlight.spec.ts @@ -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. +*/ + +/// + +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 + ): Chainable>; + spotlightDialog( + options?: Partial + ): Chainable>; + spotlightFilter( + filter: Filter | null, + options?: Partial + ): Chainable>; + spotlightSearch( + options?: Partial + ): Chainable>; + spotlightResults( + options?: Partial + ): Chainable>; + roomHeaderName( + options?: Partial + ): Chainable>; + } + } +} + +Cypress.Commands.add("openSpotlightDialog", ( + options?: Partial, +): Chainable> => { + cy.get('.mx_RoomSearch_spotlightTrigger', options).click({ force: true }); + return cy.spotlightDialog(options); +}); + +Cypress.Commands.add("spotlightDialog", ( + options?: Partial, +): Chainable> => { + return cy.get('[role=dialog][aria-label="Search Dialog"]', options); +}); + +Cypress.Commands.add("spotlightFilter", ( + filter: Filter | null, + options?: Partial, +): Chainable> => { + 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, +): Chainable> => { + return cy.get(".mx_SpotlightDialog_searchBox input", options); +}); + +Cypress.Commands.add("spotlightResults", ( + options?: Partial, +): Chainable> => { + return cy.get(".mx_SpotlightDialog_section.mx_SpotlightDialog_results .mx_SpotlightDialog_option", options); +}); + +Cypress.Commands.add("roomHeaderName", ( + options?: Partial, +): Chainable> => { + 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"); + }); + }); +}); diff --git a/cypress/integration/5-threads/threads.spec.ts b/cypress/integration/5-threads/threads.spec.ts index 226e63576d..64269b1457 100644 --- a/cypress/integration/5-threads/threads.spec.ts +++ b/cypress/integration/5-threads/threads.spec.ts @@ -28,9 +28,10 @@ describe("Threads", () => { let synapse: SynapseInstance; beforeEach(() => { + // Default threads to ON for this spec + cy.enableLabsFeature("feature_thread"); cy.window().then(win => { win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - win.localStorage.setItem("mx_labs_feature_feature_thread", "true"); // Default threads to ON for this spec }); cy.startSynapse("default").then(data => { synapse = data; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index b82b950e99..4a0852c64a 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -22,6 +22,7 @@ import "cypress-real-events"; import "./performance"; import "./synapse"; import "./login"; +import "./labs"; import "./client"; import "./settings"; import "./bot"; diff --git a/cypress/support/labs.ts b/cypress/support/labs.ts new file mode 100644 index 0000000000..3fff154e14 --- /dev/null +++ b/cypress/support/labs.ts @@ -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; + +/// + +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; + } + } +} + +Cypress.Commands.add("enableLabsFeature", (feature: string): Chainable => { + 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 { }; diff --git a/res/css/_components.scss b/res/css/_components.scss index 29f2ec4eb6..4fb7901cd0 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -34,6 +34,7 @@ @import "./structures/_FileDropTarget.scss"; @import "./structures/_FilePanel.scss"; @import "./structures/_GenericErrorPage.scss"; +@import "./structures/_GenericDropdownMenu.scss"; @import "./structures/_HeaderButtons.scss"; @import "./structures/_HomePage.scss"; @import "./structures/_LeftPanel.scss"; diff --git a/res/css/structures/_GenericDropdownMenu.scss b/res/css/structures/_GenericDropdownMenu.scss new file mode 100644 index 0000000000..dda8a61540 --- /dev/null +++ b/res/css/structures/_GenericDropdownMenu.scss @@ -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; + } + } + } +} diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 2b8def01d0..bcb52684d3 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -54,8 +54,9 @@ limitations under the License. flex: 1 !important; } -.mx_RoomDirectory_listheader .mx_NetworkDropdown { - flex: 0 0 200px; +.mx_RoomDirectory_listheader .mx_GenericDropdownMenu_button { + margin: 0 9px 0 auto; + width: fit-content; } .mx_RoomDirectory_tableWrapper { diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index 6c3edb896b..c70b166715 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -160,14 +160,14 @@ limitations under the License. padding-right: 8px; color: #ffffff; // this is fine without a var because it's for both themes - .mx_InviteDialog_userTile_avatar { + .mx_SearchResultAvatar { border-radius: 20px; position: relative; left: -5px; top: 2px; } - img.mx_InviteDialog_userTile_avatar { + img.mx_SearchResultAvatar { vertical-align: top; } @@ -175,7 +175,7 @@ limitations under the License. vertical-align: top; } - .mx_InviteDialog_userTile_threepidAvatar { + .mx_SearchResultAvatar_threepidAvatar { background-color: #ffffff; // this is fine without a var because it's for both themes } } diff --git a/res/css/views/dialogs/_SpotlightDialog.scss b/res/css/views/dialogs/_SpotlightDialog.scss index 9f78d905b5..2b7674a241 100644 --- a/res/css/views/dialogs/_SpotlightDialog.scss +++ b/res/css/views/dialogs/_SpotlightDialog.scss @@ -61,6 +61,69 @@ limitations under the License. padding: 12px 16px; border-bottom: 1px solid $system; + > .mx_SpotlightDialog_filter { + display: flex; + align-content: center; + align-items: center; + border-radius: 8px; + margin-right: 8px; + background-color: $quinary-content; + vertical-align: middle; + color: $primary-content; + position: relative; + padding: 4px 8px 4px 37px; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 18px; + height: 18px; + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + } + + &.mx_SpotlightDialog_filterPeople::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + &.mx_SpotlightDialog_filterPublicRooms::before { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + + .mx_SpotlightDialog_filter--close { + position: relative; + display: inline-block; + width: 16px; + height: 16px; + background: $system; + border-radius: 8px; + margin-left: 8px; + text-align: center; + line-height: 16px; + color: $secondary-content; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 8px; + height: 8px; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + mask-image: url("$(res)/img/cancel-small.svg"); + } + } + } + > input { display: block; box-sizing: border-box; @@ -73,20 +136,37 @@ limitations under the License. font-size: $font-15px; line-height: $font-24px; } + + > .mx_Spinner { + flex-grow: 0; + width: unset; + height: unset; + margin-left: 16px; + } } #mx_SpotlightDialog_content { - margin: 16px; height: 100%; overflow-y: auto; + padding: 16px; .mx_SpotlightDialog_section { - > h4 { + > h4, > .mx_SpotlightDialog_sectionHeader > h4 { font-weight: $font-semi-bold; font-size: $font-12px; line-height: $font-15px; color: $secondary-content; - margin-top: 0; + margin: 0; + } + + > h4 { + margin-bottom: 8px; + } + + .mx_SpotlightDialog_sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; margin-bottom: 8px; } @@ -103,7 +183,7 @@ limitations under the License. margin-right: 1px; // occlude the 1px visible of the very next tile to prevent it looking broken } - .mx_AccessibleButton { + .mx_SpotlightDialog_option { border-radius: 8px; padding: 4px; color: $primary-content; @@ -122,7 +202,7 @@ limitations under the License. margin: 0 9px 4px; // maintain centering } - & + .mx_AccessibleButton { + & + .mx_SpotlightDialog_option { margin-left: 16px; } @@ -134,8 +214,9 @@ limitations under the License. .mx_SpotlightDialog_results, .mx_SpotlightDialog_recentSearches, - .mx_SpotlightDialog_otherSearches { - .mx_AccessibleButton { + .mx_SpotlightDialog_otherSearches, + .mx_SpotlightDialog_hiddenResults { + .mx_SpotlightDialog_option { padding: 6px 4px; border-radius: 8px; font-size: $font-15px; @@ -148,6 +229,20 @@ limitations under the License. text-overflow: ellipsis; overflow: hidden; + &.mx_SpotlightDialog_result_multiline { + align-items: start; + + .mx_AccessibleButton { + padding: 4px 20px; + margin: 2px 4px; + } + + .mx_SpotlightDialog_enterPrompt { + margin-top: 9px; + margin-right: 8px; + } + } + > .mx_SpotlightDialog_metaspaceResult, > .mx_DecoratedRoomAvatar, > .mx_BaseAvatar { @@ -161,6 +256,44 @@ limitations under the License. } } + .mx_SpotlightDialog_result_publicRoomDetails { + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; + + .mx_SpotlightDialog_result_publicRoomHeader { + display: flex; + flex-direction: row; + line-height: $font-24px; + margin-right: 8px; + + .mx_SpotlightDialog_result_publicRoomName { + color: $primary-content; + font-size: $font-15px; + overflow: hidden; + text-overflow: ellipsis; + } + .mx_SpotlightDialog_result_publicRoomAlias { + color: $tertiary-content; + font-size: $font-12px; + margin-left: 8px; + overflow: hidden; + text-overflow: ellipsis; + } + } + .mx_SpotlightDialog_result_publicRoomDescription { + display: -webkit-box; + color: $secondary-content; + font-size: $font-12px; + white-space: normal; + word-wrap: break-word; + line-height: $font-20px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + } + .mx_NotificationBadge { margin-left: 8px; } @@ -175,10 +308,43 @@ limitations under the License. } } + .mx_SpotlightDialog_inviteLink, + .mx_SpotlightDialog_createRoom { + margin-top: 8px; + + .mx_AccessibleButton { + position: relative; + margin: 0; + padding: 3px 8px 3px 28px; + + &::before { + content: ""; + display: block; + position: absolute; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + left: 8px; + width: 16px; + height: 16px; + background: $accent; + } + } + } + + .mx_SpotlightDialog_inviteLink .mx_AccessibleButton::before { + mask-image: url("$(res)/img/element-icons/link.svg"); + } + + .mx_SpotlightDialog_createRoom .mx_AccessibleButton::before { + mask-image: url("$(res)/img/element-icons/roomlist/hash.svg"); + } + .mx_SpotlightDialog_otherSearches { .mx_SpotlightDialog_startChat, .mx_SpotlightDialog_joinRoomAlias, - .mx_SpotlightDialog_explorePublicRooms { + .mx_SpotlightDialog_explorePublicRooms, + .mx_SpotlightDialog_startGroupChat { padding-left: 32px; position: relative; @@ -209,6 +375,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } + .mx_SpotlightDialog_startGroupChat::before { + mask-image: url('$(res)/img/element-icons/group-members.svg'); + } + .mx_SpotlightDialog_otherSearches_messageSearchText { font-size: $font-15px; line-height: $font-24px; diff --git a/res/css/views/directory/_NetworkDropdown.scss b/res/css/views/directory/_NetworkDropdown.scss index eadca7326b..b5d5a8ed9e 100644 --- a/res/css/views/directory/_NetworkDropdown.scss +++ b/res/css/views/directory/_NetworkDropdown.scss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,151 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_NetworkDropdown { - height: 32px; +.mx_NetworkDropdown_wrapper .mx_ContextualMenu { + min-width: 200px; +} + +.mx_NetworkDropdown_addServer { + font-weight: normal; + font-size: $font-15px; +} + +.mx_NetworkDropdown_removeServer { position: relative; - width: max-content; - padding-right: 32px; + display: inline-block; + width: 16px; + height: 16px; + background: $system; + border-radius: 8px; + text-align: center; + line-height: 16px; + color: $secondary-content; margin-left: auto; - margin-right: 9px; - margin-top: 12px; - - .mx_AccessibleButton { - width: max-content; - } -} - -.mx_NetworkDropdown_menu { - min-width: 204px; - margin: 0; - box-sizing: border-box; - border-radius: 4px; - border: 1px solid $dialog-close-fg-color; - background-color: $background; - max-height: calc(100vh - 20px); // allow 10px padding on both top and bottom - overflow-y: auto; -} - -.mx_NetworkDropdown_menu_network { - font-weight: bold; -} - -.mx_NetworkDropdown_server { - padding: 12px 0; - border-bottom: 1px solid $input-darker-fg-color; - - .mx_NetworkDropdown_server_title { - padding: 0 10px; - font-size: $font-15px; - font-weight: 600; - line-height: $font-20px; - margin-bottom: 4px; - position: relative; - - // remove server button - .mx_AccessibleButton { - position: absolute; - display: inline; - right: 10px; - height: 16px; - width: 16px; - margin-top: 2px; - - &::after { - content: ""; - position: absolute; - width: 16px; - height: 16px; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/feather-customised/x.svg'); - background-color: $alert; - } - } - } - - .mx_NetworkDropdown_server_subtitle { - padding: 0 10px; - font-size: $font-10px; - line-height: $font-14px; - margin-top: -4px; - margin-bottom: 4px; - color: $muted-fg-color; - } - - .mx_NetworkDropdown_server_network { - font-size: $font-12px; - line-height: $font-16px; - padding: 4px 10px; - cursor: pointer; - position: relative; - - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - &[aria-checked=true]::after { - content: ""; - position: absolute; - width: 16px; - height: 16px; - right: 10px; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/feather-customised/check.svg'); - background-color: $accent; - } - } -} - -.mx_NetworkDropdown_server_add, -.mx_NetworkDropdown_server_network { - &:hover { - background-color: $header-panel-bg-color; - } -} - -.mx_NetworkDropdown_server_add { - padding: 16px 10px 16px 32px; - position: relative; - border-radius: 0 0 4px 4px; &::before { + background-color: $secondary-content; content: ""; - position: absolute; - width: 16px; - height: 16px; - left: 7px; mask-repeat: no-repeat; mask-position: center; mask-size: contain; - mask-image: url('$(res)/img/feather-customised/plus.svg'); - background-color: $muted-fg-color; - } -} - -.mx_NetworkDropdown_handle { - position: relative; - - &::after { - content: ""; + width: 8px; + height: 8px; position: absolute; - width: 26px; - height: 26px; - right: -27.5px; // - (width: 26 + spacing to align with X above: 1.5) - top: -3px; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/feather-customised/chevron-down-thin.svg'); - background-color: $primary-content; - } - - .mx_NetworkDropdown_handle_server { - color: $muted-fg-color; - font-size: $font-12px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + mask-image: url("$(res)/img/cancel-small.svg"); } } diff --git a/src/components/structures/GenericDropdownMenu.tsx b/src/components/structures/GenericDropdownMenu.tsx new file mode 100644 index 0000000000..16a615fa6a --- /dev/null +++ b/src/components/structures/GenericDropdownMenu.tsx @@ -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 = { + key: T; + label: ReactNode; + description?: ReactNode; + adornment?: ReactNode; +}; + +export type GenericDropdownMenuGroup = GenericDropdownMenuOption & { + options: GenericDropdownMenuOption[]; +}; + +export type GenericDropdownMenuItem = GenericDropdownMenuGroup | GenericDropdownMenuOption; + +export function GenericDropdownMenuOption({ + label, + description, + onClick, + isSelected, + adornment, +}: GenericDropdownMenuOption & { + onClick: (ev: ButtonEvent) => void; + isSelected: boolean; +}): JSX.Element { + return +
+ { label } + { description } +
+ { adornment } +
; +} + +export function GenericDropdownMenuGroup({ + label, + description, + adornment, + children, +}: PropsWithChildren>): JSX.Element { + return <> +
+
+ { label } + { description } +
+ { adornment } +
+ { children } + ; +} + +function isGenericDropdownMenuGroup( + item: GenericDropdownMenuItem, +): item is GenericDropdownMenuGroup { + return "options" in item; +} + +type WithKeyFunction = T extends Key ? { + toKey?: (key: T) => Key; +} : { + toKey: (key: T) => Key; +}; + +type IProps = WithKeyFunction & { + value: T; + options: (readonly GenericDropdownMenuOption[] | readonly GenericDropdownMenuGroup[]); + onChange: (option: T) => void; + selectedLabel: (option: GenericDropdownMenuItem | null | undefined) => ReactNode; + onOpen?: (ev: ButtonEvent) => void; + onClose?: (ev: ButtonEvent) => void; + className?: string; + AdditionalOptions?: FunctionComponent<{ + menuDisplayed: boolean; + closeMenu: () => void; + openMenu: () => void; + }>; +}; + +export function GenericDropdownMenu( + { value, onChange, options, selectedLabel, onOpen, onClose, toKey, className, AdditionalOptions }: IProps, +): JSX.Element { + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + + const selected: GenericDropdownMenuItem | 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 => ( + + { group.options.map(option => ( + { + onChange(option.key); + closeMenu(); + onClose?.(ev); + }} + adornment={option.adornment} + isSelected={option === selected} + /> + )) } + + )) } + ; + } else { + contextMenuOptions = <> + { options.map(option => ( + { + onChange(option.key); + closeMenu(); + onClose?.(ev); + }} + adornment={option.adornment} + isSelected={option === selected} + /> + )) } + ; + } + const contextMenu = menuDisplayed ? + { contextMenuOptions } + { AdditionalOptions && ( + + ) } + : null; + return <> + { + openMenu(); + onOpen?.(ev); + }} + > + { selectedLabel(selected) } + + { contextMenu } + ; +} diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index bd16d976ab..a1274fcee5 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -27,9 +27,9 @@ import Modal from "../../Modal"; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId, ALL_ROOMS, Protocols } from '../../utils/DirectoryUtils'; -import NetworkDropdown from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import { IDialogProps } from "../views/dialogs/IDialogProps"; +import { IPublicRoomDirectoryConfig, NetworkDropdown } from "../views/directory/NetworkDropdown"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import QuestionDialog from "../views/dialogs/QuestionDialog"; @@ -54,16 +54,15 @@ interface IState { publicRooms: IPublicRoomsChunkRoom[]; loading: boolean; protocolsLoading: boolean; - error?: string; - instanceId: string; - roomServer: string; + error?: string | null; + serverConfig: IPublicRoomDirectoryConfig | null; filterString: string; } export default class RoomDirectory extends React.Component { private unmounted = false; - private nextBatch: string = null; - private filterTimeout: number; + private nextBatch: string | null = null; + private filterTimeout: number | null; private protocols: Protocols; constructor(props) { @@ -77,10 +76,10 @@ export default class RoomDirectory extends React.Component { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { this.protocols = response; const myHomeserver = MatrixClientPeg.getHomeserverName(); - const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY); - const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY); + const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY) ?? undefined; + const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined; - let roomServer = myHomeserver; + let roomServer: string | undefined = myHomeserver; if ( SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) || SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) @@ -88,7 +87,7 @@ export default class RoomDirectory extends React.Component { roomServer = lsRoomServer; } - let instanceId: string = null; + let instanceId: string | undefined = undefined; if (roomServer === myHomeserver && ( lsInstanceId === ALL_ROOMS || Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId)) @@ -97,11 +96,11 @@ export default class RoomDirectory extends React.Component { } // Refresh the room list only if validation failed and we had to change these - if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) { + if (this.state.serverConfig?.instanceId !== instanceId || + this.state.serverConfig?.roomServer !== roomServer) { this.setState({ protocolsLoading: false, - instanceId, - roomServer, + serverConfig: roomServer ? { instanceId, roomServer } : null, }); this.refreshRoomList(); return; @@ -127,12 +126,20 @@ export default class RoomDirectory extends React.Component { }); } + let serverConfig: IPublicRoomDirectoryConfig | null = null; + const roomServer = localStorage.getItem(LAST_SERVER_KEY); + if (roomServer) { + serverConfig = { + roomServer, + instanceId: localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined, + }; + } + this.state = { publicRooms: [], loading: true, error: null, - instanceId: localStorage.getItem(LAST_INSTANCE_KEY), - roomServer: localStorage.getItem(LAST_SERVER_KEY), + serverConfig, filterString: this.props.initialText || "", protocolsLoading, }; @@ -166,7 +173,7 @@ export default class RoomDirectory extends React.Component { }); const filterString = this.state.filterString; - const roomServer = this.state.roomServer; + const roomServer = this.state.serverConfig?.roomServer; // remember the next batch token when we sent the request // too. If it's changed, appending to the list will corrupt it. const nextBatch = this.nextBatch; @@ -174,17 +181,17 @@ export default class RoomDirectory extends React.Component { if (roomServer != MatrixClientPeg.getHomeserverName()) { opts.server = roomServer; } - if (this.state.instanceId === ALL_ROOMS) { + if (this.state.serverConfig?.instanceId === ALL_ROOMS) { opts.include_all_networks = true; - } else if (this.state.instanceId) { - opts.third_party_instance_id = this.state.instanceId as string; + } else if (this.state.serverConfig?.instanceId) { + opts.third_party_instance_id = this.state.serverConfig?.instanceId as string; } if (this.nextBatch) opts.since = this.nextBatch; if (filterString) opts.filter = { generic_search_term: filterString }; return MatrixClientPeg.get().publicRooms(opts).then((data) => { if ( filterString != this.state.filterString || - roomServer != this.state.roomServer || + roomServer != this.state.serverConfig?.roomServer || nextBatch != this.nextBatch) { // if the filter or server has changed since this request was sent, // throw away the result (don't even clear the busy flag @@ -197,7 +204,7 @@ export default class RoomDirectory extends React.Component { return false; } - this.nextBatch = data.next_batch; + this.nextBatch = data.next_batch ?? null; this.setState((s) => ({ ...s, publicRooms: [...s.publicRooms, ...(data.chunk || [])], @@ -207,7 +214,7 @@ export default class RoomDirectory extends React.Component { }, (err) => { if ( filterString != this.state.filterString || - roomServer != this.state.roomServer || + roomServer != this.state.serverConfig?.roomServer || nextBatch != this.nextBatch) { // as above: we don't care about errors for old requests either return false; @@ -227,6 +234,7 @@ export default class RoomDirectory extends React.Component { (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 { }); }; - private onOptionChange = (server: string, instanceId?: string) => { + private onOptionChange = (serverConfig: IPublicRoomDirectoryConfig) => { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -287,8 +295,7 @@ export default class RoomDirectory extends React.Component { // spend time filtering lots of rooms when we're about to // to clear the list anyway. publicRooms: [], - roomServer: server, - instanceId: instanceId, + serverConfig, error: null, }, this.refreshRoomList); // We also refresh the room list each time even though this @@ -299,9 +306,9 @@ export default class RoomDirectory extends React.Component { // Easiest to just blow away the state & re-fetch. // We have to be careful here so that we don't set instanceId = "undefined" - localStorage.setItem(LAST_SERVER_KEY, server); - if (instanceId) { - localStorage.setItem(LAST_INSTANCE_KEY, instanceId); + localStorage.setItem(LAST_SERVER_KEY, serverConfig.roomServer); + if (serverConfig.instanceId) { + localStorage.setItem(LAST_INSTANCE_KEY, serverConfig.instanceId); } else { localStorage.removeItem(LAST_INSTANCE_KEY); } @@ -346,8 +353,8 @@ export default class RoomDirectory extends React.Component { const cli = MatrixClientPeg.get(); try { joinRoomByAlias(cli, alias, { - instanceId: this.state.instanceId, - roomServer: this.state.roomServer, + instanceId: this.state.serverConfig?.instanceId, + roomServer: this.state.serverConfig?.roomServer, protocols: this.protocols, metricsTrigger: "RoomDirectory", }); @@ -380,7 +387,7 @@ export default class RoomDirectory extends React.Component { roomAlias, autoJoin, shouldPeek, - roomServer: this.state.roomServer, + roomServer: this.state.serverConfig?.roomServer, metricsTrigger: "RoomDirectory", }); }; @@ -465,7 +472,7 @@ export default class RoomDirectory extends React.Component { let listHeader; if (!this.state.protocolsLoading) { - const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); + const protocolName = protocolNameForInstanceId(this.protocols, this.state.serverConfig?.instanceId); let instanceExpectedFieldType; if ( protocolName && @@ -479,9 +486,9 @@ export default class RoomDirectory extends React.Component { } let placeholder = _t('Find a room…'); - if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { + if (!this.state.serverConfig?.instanceId || this.state.serverConfig?.instanceId === ALL_ROOMS) { placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", { - exampleRoom: "#example:" + this.state.roomServer, + exampleRoom: "#example:" + this.state.serverConfig?.roomServer, }); } else if (instanceExpectedFieldType) { placeholder = instanceExpectedFieldType.placeholder; @@ -489,8 +496,8 @@ export default class RoomDirectory extends React.Component { let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType); if (protocolName) { - const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - if (getFieldsForThirdPartyLocation( + const instance = instanceForInstanceId(this.protocols, this.state.serverConfig?.instanceId); + if (!instance || getFieldsForThirdPartyLocation( this.state.filterString, this.protocols[protocolName], instance, @@ -511,14 +518,13 @@ export default class RoomDirectory extends React.Component { /> ; } const explanation = - _t("If you can't find the room you're looking for, ask for an invite or create a new room.", null, + _t("If you can't find the room you're looking for, ask for an invite or create a new room.", {}, { a: sub => ( { sub } diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index b980d1739f..8e7286a205 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -31,7 +31,7 @@ import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import { IS_MAC, Key } from "../../Keyboard"; import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; -import SpotlightDialog from "../views/dialogs/SpotlightDialog"; +import SpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { ALTERNATE_KEY_NAME, KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import ToastStore from "../../stores/ToastStore"; diff --git a/src/components/views/avatars/SearchResultAvatar.tsx b/src/components/views/avatars/SearchResultAvatar.tsx new file mode 100644 index 0000000000..d9b56e6a04 --- /dev/null +++ b/src/components/views/avatars/SearchResultAvatar.tsx @@ -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 ; + } else { + const avatarUrl = user.getMxcAvatarUrl(); + return ; + } +} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 118b9da9a0..1cc34db875 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -28,6 +28,7 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import SdkConfig from "../../../SdkConfig"; import * as Email from "../../../email"; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils"; +import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers"; import { abbreviateUrl } from "../../../utils/UrlUtils"; import IdentityAuthClient from "../../../IdentityAuthClient"; import { humanizeTime } from "../../../utils/humanize"; @@ -43,8 +44,9 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import { mediaFromMxc } from "../../../customisations/Media"; import BaseAvatar from '../avatars/BaseAvatar'; +import { SearchResultAvatar } from "../avatars/SearchResultAvatar"; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; -import { compare, selectText } from '../../../utils/strings'; +import { selectText } from '../../../utils/strings'; import Field from '../elements/Field'; import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView'; import Dialpad from '../voip/DialPad'; @@ -91,22 +93,7 @@ class DMUserTile extends React.PureComponent { render() { const avatarSize = 20; - const avatar = (this.props.member as ThreepidMember).isEmail - ? - : ; + const avatar = ; let closeButton; if (this.props.onRemove) { @@ -422,121 +409,15 @@ export default class InviteDialog extends React.PureComponent): {userId: string, user: RoomMember}[] { - const maxConsideredMembers = 200; - const joinedRooms = MatrixClientPeg.get().getRooms() - .filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers); + const cli = MatrixClientPeg.get(); + const activityScores = buildActivityScores(cli); + const memberScores = buildMemberScores(cli); + const memberComparator = compareMembers(activityScores, memberScores); - // Generates { userId: {member, rooms[]} } - const memberRooms = joinedRooms.reduce((members, room) => { - // Filter out DMs (we'll handle these in the recents section) - if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { - return members; // Do nothing - } - - const joinedMembers = room.getJoinedMembers().filter(u => !excludedTargetIds.has(u.userId)); - for (const member of joinedMembers) { - // Filter out user IDs that are already in the room / should be excluded - if (excludedTargetIds.has(member.userId)) { - continue; - } - - if (!members[member.userId]) { - members[member.userId] = { - member: member, - // Track the room size of the 'picked' member so we can use the profile of - // the smallest room (likely a DM). - pickedMemberRoomSize: room.getJoinedMemberCount(), - rooms: [], - }; - } - - members[member.userId].rooms.push(room); - - if (room.getJoinedMemberCount() < members[member.userId].pickedMemberRoomSize) { - members[member.userId].member = member; - members[member.userId].pickedMemberRoomSize = room.getJoinedMemberCount(); - } - } - return members; - }, {}); - - // Generates { userId: {member, numRooms, score} } - const memberScores = Object.values(memberRooms).reduce((scores, entry: {member: RoomMember, rooms: Room[]}) => { - const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0); - const maxRange = maxConsideredMembers * entry.rooms.length; - scores[entry.member.userId] = { - member: entry.member, - numRooms: entry.rooms.length, - score: Math.max(0, Math.pow(1 - (numMembersTotal / maxRange), 5)), - }; - return scores; - }, {}); - - // Now that we have scores for being in rooms, boost those people who have sent messages - // recently, as a way to improve the quality of suggestions. We do this by checking every - // room to see who has sent a message in the last few hours, and giving them a score - // which correlates to the freshness of their message. In theory, this results in suggestions - // which are closer to "continue this conversation" rather than "this person exists". - const trueJoinedRooms = MatrixClientPeg.get().getRooms().filter(r => r.getMyMembership() === 'join'); - const now = (new Date()).getTime(); - const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago - const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic - const lastSpoke = {}; // userId: timestamp - const lastSpokeMembers = {}; // userId: room member - for (const room of trueJoinedRooms) { - // Skip low priority rooms and DMs - const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - if (Object.keys(room.tags).includes("m.lowpriority") || isDm) { - continue; - } - - const events = room.getLiveTimeline().getEvents(); // timelines are most recent last - for (let i = events.length - 1; i >= Math.max(0, events.length - maxMessagesConsidered); i--) { - const ev = events[i]; - if (excludedTargetIds.has(ev.getSender())) { - continue; - } - if (ev.getTs() <= earliestAgeConsidered) { - break; // give up: all events from here on out are too old - } - - if (!lastSpoke[ev.getSender()] || lastSpoke[ev.getSender()] < ev.getTs()) { - lastSpoke[ev.getSender()] = ev.getTs(); - lastSpokeMembers[ev.getSender()] = room.getMember(ev.getSender()); - } - } - } - for (const userId in lastSpoke) { - const ts = lastSpoke[userId]; - const member = lastSpokeMembers[userId]; - if (!member) continue; // skip people we somehow don't have profiles for - - // Scores from being in a room give a 'good' score of about 1.0-1.5, so for our - // boost we'll try and award at least +1.0 for making the list, with +4.0 being - // an approximate maximum for being selected. - const distanceFromNow = Math.abs(now - ts); // abs to account for slight future messages - const inverseTime = (now - earliestAgeConsidered) - distanceFromNow; - const scoreBoost = Math.max(1, inverseTime / (15 * 60 * 1000)); // 15min segments to keep scores sane - - let record = memberScores[userId]; - if (!record) record = memberScores[userId] = { score: 0 }; - record.member = member; - record.score += scoreBoost; - } - - const members = Object.values(memberScores); - members.sort((a, b) => { - if (a.score === b.score) { - if (a.numRooms === b.numRooms) { - return compare(a.member.userId, b.member.userId); - } - - return b.numRooms - a.numRooms; - } - return b.score - a.score; - }); - - return members.map(m => ({ userId: m.member.userId, user: m.member })); + return Object.values(memberScores).map(({ member }) => member) + .filter(member => !excludedTargetIds.has(member.userId)) + .sort(memberComparator) + .map(member => ({ userId: member.userId, user: member })); } private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean { diff --git a/src/components/views/dialogs/SpotlightDialog.tsx b/src/components/views/dialogs/SpotlightDialog.tsx deleted file mode 100644 index c94a5097ca..0000000000 --- a/src/components/views/dialogs/SpotlightDialog.tsx +++ /dev/null @@ -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> = ({ inputRef, children, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return - { children } -
-
; -}; - -const TooltipOption: React.FC> = ({ inputRef, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return ; -}; - -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
- { contextDetails } -
; - } - - return null; -}; - -interface IProps extends IDialogProps { - initialText?: string; -} - -const useSpaceResults = (space?: Room, query?: string): [IHierarchyRoom[], boolean] => { - const [rooms, setRooms] = useState([]); - const [hierarchy, setHierarchy] = useState(); - - 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): 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({ - eventName: "WebSearch", - viaSpotlight, - numResults, - queryLength, - }); - }, 1000); - - return () => { - clearTimeout(timeoutId); - }; - }, [numResults, queryLength, viaSpotlight]); -}; - -const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => { - const cli = MatrixClientPeg.get(); - const rovingContext = useContext(RovingTabIndexContext); - const [query, _setQuery] = useState(initialText); - const [recentSearches, clearRecentSearches] = useRecentSearches(); - - const possibleResults = useMemo(() => [ - ...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({ - section: Section.Spaces, - avatar: ( -
- ), - 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): 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({ - 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 ( - - ); - } - - // IResult case - return ( - - ); - }; - - let peopleSection: JSX.Element; - if (people.length) { - peopleSection =
-

{ _t("People") }

-
- { people.slice(0, SECTION_LIMIT).map(resultMapper) } -
-
; - } - - let roomsSection: JSX.Element; - if (rooms.length) { - roomsSection =
-

{ _t("Rooms") }

-
- { rooms.slice(0, SECTION_LIMIT).map(resultMapper) } -
-
; - } - - let spacesSection: JSX.Element; - if (spaces.length) { - spacesSection =
-

{ _t("Spaces you're in") }

-
- { spaces.slice(0, SECTION_LIMIT).map(resultMapper) } -
-
; - } - - let spaceRoomsSection: JSX.Element; - if (spaceResults.length) { - spaceRoomsSection =
-

{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }

-
- { spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => ( - - )) } - { spaceResultsLoading && } -
-
; - } - - let joinRoomSection: JSX.Element; - if (trimmedQuery.startsWith("#") && - trimmedQuery.includes(":") && - (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery))) - ) { - joinRoomSection =
-
- -
-
; - } - - content = <> - { peopleSection } - { roomsSection } - { spacesSection } - { spaceRoomsSection } - { joinRoomSection } -
-

{ _t('Use "%(query)s" to search', { query }) }

-
- - -
-
-
-

{ _t("Other searches") }

-
- { _t("To search messages, look for this icon at the top of a room ", {}, { - icon: () =>
, - }) } -
-
- ; - } else { - let recentSearchesSection: JSX.Element; - if (recentSearches.length) { - recentSearchesSection = ( -
-

- { _t("Recent searches") } - - { _t("Clear") } - -

-
- { recentSearches.map(room => ( - - )) } -
-
- ); - } - - content = <> -
-

{ _t("Recently viewed") }

-
- { BreadcrumbsStore.instance.rooms - .filter(r => r.roomId !== RoomViewStore.instance.getRoomId()) - .map(room => ( - { - viewRoom(room.roomId, false, ev.type !== "click"); - }} - > - - { room.name } - - )) - } -
-
- - { recentSearchesSection } - -
-

{ _t("Other searches") }

-
- -
-
- ; - } - - 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; - - 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 <> -
- { _t("Use to scroll", {}, { - arrows: () => <> -
-
- { !query &&
} - { !query &&
} - , - }) } -
- - -
- -
- -
- { content } -
- -
- { - defaultDispatcher.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Labs, - }); - onFinished(); - }} /> - { openFeedback && _t("Results not as expected? Please give feedback.", {}, { - a: sub => - { sub } - , - }) } - { openFeedback && - { _t("Feedback") } - } -
-
- ; -}; - -const RovingSpotlightDialog: React.FC = (props) => { - return - { () => } - ; -}; - -export default RovingSpotlightDialog; diff --git a/src/components/views/dialogs/spotlight/Option.tsx b/src/components/views/dialogs/spotlight/Option.tsx new file mode 100644 index 0000000000..3e11e7c38f --- /dev/null +++ b/src/components/views/dialogs/spotlight/Option.tsx @@ -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 { + endAdornment?: ReactNode; +} + +export const Option: React.FC = ({ inputRef, children, endAdornment, className, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return + { children } +
+ { endAdornment } +
; +}; diff --git a/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx b/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx new file mode 100644 index 0000000000..2ffcad349b --- /dev/null +++ b/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx @@ -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 ( +
+
+ { name } + + { room.canonical_alias ?? room.room_id } + +
+
+ + { _t("%(count)s Members", { + count: room.num_joined_members, + }) } + + { topic && ( + <> +  ·  + + + ) } +
+
+ ); +} diff --git a/src/components/views/dialogs/spotlight/RoomResultDetails.tsx b/src/components/views/dialogs/spotlight/RoomResultDetails.tsx new file mode 100644 index 0000000000..39465b9c73 --- /dev/null +++ b/src/components/views/dialogs/spotlight/RoomResultDetails.tsx @@ -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
+ { contextDetails } +
; + } + + return null; +}; diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx new file mode 100644 index 0000000000..862cd4948a --- /dev/null +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -0,0 +1,1057 @@ +/* +Copyright 2021-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 { sum } from "lodash"; +import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch"; +import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; +import { IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { normalize } from "matrix-js-sdk/src/utils"; +import React, { + ChangeEvent, + KeyboardEvent, + RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import sanitizeHtml from "sanitize-html"; + +import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; +import { Ref } from "../../../../accessibility/roving/types"; +import { + findSiblingElement, + RovingTabIndexContext, + RovingTabIndexProvider, + Type, +} from "../../../../accessibility/RovingTabIndex"; +import { mediaFromMxc } from "../../../../customisations/Media"; +import { Action } from "../../../../dispatcher/actions"; +import defaultDispatcher from "../../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; +import { useDebouncedCallback } from "../../../../hooks/spotlight/useDebouncedCallback"; +import { useRecentSearches } from "../../../../hooks/spotlight/useRecentSearches"; +import { useProfileInfo } from "../../../../hooks/useProfileInfo"; +import { usePublicRoomDirectory } from "../../../../hooks/usePublicRoomDirectory"; +import { useSpaceResults } from "../../../../hooks/useSpaceResults"; +import { useUserDirectory } from "../../../../hooks/useUserDirectory"; +import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; +import { _t } from "../../../../languageHandler"; +import { MatrixClientPeg } from "../../../../MatrixClientPeg"; +import Modal from "../../../../Modal"; +import { PosthogAnalytics } from "../../../../PosthogAnalytics"; +import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache"; +import { showStartChatInviteDialog } from "../../../../RoomInvite"; +import SdkConfig from "../../../../SdkConfig"; +import { SettingLevel } from "../../../../settings/SettingLevel"; +import SettingsStore from "../../../../settings/SettingsStore"; +import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore"; +import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore"; +import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { RoomViewStore } from "../../../../stores/RoomViewStore"; +import { getMetaSpaceName } from "../../../../stores/spaces"; +import SpaceStore from "../../../../stores/spaces/SpaceStore"; +import { DirectoryMember, Member, startDm } from "../../../../utils/direct-messages"; +import DMRoomMap from "../../../../utils/DMRoomMap"; +import { makeUserPermalink } from "../../../../utils/permalinks/Permalinks"; +import { buildActivityScores, buildMemberScores, compareMembers } from "../../../../utils/SortMembers"; +import { copyPlaintext } from "../../../../utils/strings"; +import BaseAvatar from "../../avatars/BaseAvatar"; +import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar"; +import { SearchResultAvatar } from "../../avatars/SearchResultAvatar"; +import { BetaPill } from "../../beta/BetaCard"; +import { NetworkDropdown } from "../../directory/NetworkDropdown"; +import AccessibleButton from "../../elements/AccessibleButton"; +import Spinner from "../../elements/Spinner"; +import NotificationBadge from "../../rooms/NotificationBadge"; +import BaseDialog from "../BaseDialog"; +import BetaFeedbackDialog from "../BetaFeedbackDialog"; +import { IDialogProps } from "../IDialogProps"; +import { UserTab } from "../UserTab"; +import { Option } from "./Option"; +import { PublicRoomResultDetails } from "./PublicRoomResultDetails"; +import { RoomResultDetails } from "./RoomResultDetails"; +import { TooltipOption } from "./TooltipOption"; + +const MAX_RECENT_SEARCHES = 10; +const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons +const AVATAR_SIZE = 24; + +interface IProps extends IDialogProps { + initialText?: string; + initialFilter?: Filter; +} + +function refIsForRecentlyViewed(ref: RefObject): boolean { + return ref.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true; +} + +enum Section { + People, + Rooms, + Spaces, + Suggestions, + PublicRooms, +} + +export enum Filter { + People, + PublicRooms, +} + +function filterToLabel(filter: Filter): string { + switch (filter) { + case Filter.People: return _t("People"); + case Filter.PublicRooms: return _t("Public rooms"); + } +} + +interface IBaseResult { + section: Section; + filter: Filter[]; + query?: string[]; // extra fields to query match, stored as lowercase +} + +interface IPublicRoomResult extends IBaseResult { + publicRoom: IPublicRoomsChunkRoom; +} + +interface IRoomResult extends IBaseResult { + room: Room; +} + +interface IMemberResult extends IBaseResult { + member: Member | RoomMember; +} + +interface IResult extends IBaseResult { + avatar: JSX.Element; + name: string; + description?: string; + onClick?(): void; +} + +type Result = IRoomResult | IPublicRoomResult | IMemberResult | IResult; + +const isRoomResult = (result: any): result is IRoomResult => !!result?.room; +const isPublicRoomResult = (result: any): result is IPublicRoomResult => !!result?.publicRoom; +const isMemberResult = (result: any): result is IMemberResult => !!result?.member; + +const toPublicRoomResult = (publicRoom: IPublicRoomsChunkRoom): IPublicRoomResult => ({ + publicRoom, + section: Section.PublicRooms, + filter: [Filter.PublicRooms], + query: [ + publicRoom.room_id.toLowerCase(), + publicRoom.canonical_alias?.toLowerCase(), + publicRoom.name?.toLowerCase(), + sanitizeHtml(publicRoom.topic?.toLowerCase() ?? "", { allowedTags: [] }), + ...(publicRoom.aliases?.map(it => it.toLowerCase()) || []), + ].filter(Boolean), +}); + +const toRoomResult = (room: Room): IRoomResult => { + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + if (otherUserId) { + return { + room, + section: Section.People, + filter: [Filter.People], + query: [ + otherUserId.toLowerCase(), + room.getMember(otherUserId)?.name.toLowerCase(), + ].filter(Boolean), + }; + } else if (room.isSpaceRoom()) { + return { + room, + section: Section.Spaces, + filter: [], + }; + } else { + return { + room, + section: Section.Rooms, + filter: [], + }; + } +}; + +const toMemberResult = (member: Member | RoomMember): IMemberResult => ({ + member, + section: Section.Suggestions, + filter: [Filter.People], + query: [ + member.userId.toLowerCase(), + member.name.toLowerCase(), + ].filter(Boolean), +}); + +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({ + eventName: "WebSearch", + viaSpotlight, + numResults, + queryLength, + }); + }, 1000); + + return () => { + clearTimeout(timeoutId); + }; + }, [numResults, queryLength, viaSpotlight]); +}; + +const findVisibleRooms = (cli: MatrixClient) => { + return cli.getVisibleRooms().filter(room => { + // TODO we may want to put invites in their own list + return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; + }); +}; + +const findVisibleRoomMembers = (cli: MatrixClient, filterDMs = true) => { + return Object.values( + findVisibleRooms(cli) + .filter(room => !filterDMs || !DMRoomMap.shared().getUserIdForRoomId(room.roomId)) + .reduce((members, room) => { + for (const member of room.getJoinedMembers()) { + members[member.userId] = member; + } + return members; + }, {} as Record), + ).filter(it => it.userId !== cli.getUserId()); +}; + +interface IDirectoryOpts { + limit: number; + query: string; +} + +const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = null, onFinished }) => { + const inputRef = useRef(); + const scrollContainerRef = useRef(); + const cli = MatrixClientPeg.get(); + const rovingContext = useContext(RovingTabIndexContext); + const [query, _setQuery] = useState(initialText); + const [recentSearches, clearRecentSearches] = useRecentSearches(); + const [filter, setFilterInternal] = useState(initialFilter); + const setFilter = useCallback( + (filter: Filter | null) => { + setFilterInternal(filter); + inputRef.current?.focus(); + scrollContainerRef.current?.scrollTo?.({ top: 0 }); + }, + [], + ); + const memberComparator = useMemo(() => { + const activityScores = buildActivityScores(cli); + const memberScores = buildMemberScores(cli); + return compareMembers(activityScores, memberScores); + }, [cli]); + + const ownInviteLink = makeUserPermalink(cli.getUserId()); + const [inviteLinkCopied, setInviteLinkCopied] = useState(false); + const trimmedQuery = useMemo(() => query.trim(), [query]); + + const { loading: publicRoomsLoading, publicRooms, protocols, config, setConfig, search: searchPublicRooms } = + usePublicRoomDirectory(); + const { loading: peopleLoading, users, search: searchPeople } = useUserDirectory(); + const { loading: profileLoading, profile, search: searchProfileInfo } = useProfileInfo(); + const searchParams: [IDirectoryOpts] = useMemo(() => ([{ + query: trimmedQuery, + limit: SECTION_LIMIT, + }]), [trimmedQuery]); + useDebouncedCallback( + filter === Filter.PublicRooms, + searchPublicRooms, + searchParams, + ); + useDebouncedCallback( + filter === Filter.People, + searchPeople, + searchParams, + ); + useDebouncedCallback( + filter === Filter.People, + searchProfileInfo, + searchParams, + ); + const possibleResults = useMemo( + () => { + const roomMembers = findVisibleRoomMembers(cli); + const roomMemberIds = new Set(roomMembers.map(item => item.userId)); + return [ + ...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({ + section: Section.Spaces, + filter: [], + avatar:
, + name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome), + onClick() { + SpaceStore.instance.setActiveSpace(spaceKey); + }, + })), + ...findVisibleRooms(cli).map(toRoomResult), + ...roomMembers.map(toMemberResult), + ...users.filter(item => !roomMemberIds.has(item.userId)).map(toMemberResult), + ...(profile ? [new DirectoryMember(profile)] : []).map(toMemberResult), + ...publicRooms.map(toPublicRoomResult), + ].filter(result => filter === null || result.filter.includes(filter)); + }, + [cli, users, profile, publicRooms, filter], + ); + + const results = useMemo>(() => { + const results: Record = { + [Section.People]: [], + [Section.Rooms]: [], + [Section.Spaces]: [], + [Section.Suggestions]: [], + [Section.PublicRooms]: [], + }; + + // Group results in their respective sections + if (trimmedQuery) { + const lcQuery = trimmedQuery.toLowerCase(); + const normalizedQuery = normalize(trimmedQuery); + + 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 (isMemberResult(entry)) { + if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query + } else if (isPublicRoomResult(entry)) { + if (!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); + }); + } else if (filter === Filter.PublicRooms) { + // return all results for public rooms if no query is given + possibleResults.forEach(entry => { + if (isPublicRoomResult(entry)) { + results[entry.section].push(entry); + } + }); + } else if (filter === Filter.People) { + // return all results for people if no query is given + possibleResults.forEach(entry => { + if (isMemberResult(entry)) { + results[entry.section].push(entry); + } + }); + } + + // Sort results by most recent activity + + const myUserId = cli.getUserId(); + for (const resultArray of Object.values(results)) { + resultArray.sort((a: Result, b: Result) => { + if (isRoomResult(a) || isRoomResult(b)) { + // Room results should appear at the top of the list + if (!isRoomResult(b)) return -1; + if (!isRoomResult(a)) return -1; + + return recentAlgorithm.getLastTs(b.room, myUserId) - recentAlgorithm.getLastTs(a.room, myUserId); + } else if (isMemberResult(a) || isMemberResult(b)) { + // Member results should appear just after room results + if (!isMemberResult(b)) return -1; + if (!isMemberResult(a)) return -1; + + return memberComparator(a.member, b.member); + } + }); + } + + return results; + }, [trimmedQuery, filter, cli, possibleResults, memberComparator]); + + const numResults = sum(Object.values(results).map(it => it.length)); + useWebSearchMetrics(numResults, query.length, true); + + const activeSpace = SpaceStore.instance.activeSpaceRoom; + const [spaceResults, spaceResultsLoading] = useSpaceResults(activeSpace, query); + + const setQuery = (e: ChangeEvent): void => { + const newQuery = e.currentTarget.value; + _setQuery(newQuery); + }; + useEffect(() => { + setImmediate(() => { + let ref: Ref; + if (rovingContext.state.refs) { + ref = rovingContext.state.refs[0]; + } + rovingContext.dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + ref?.current?.scrollIntoView?.({ + block: "nearest", + }); + }); + // we intentionally ignore changes to the rovingContext for the purpose of this hook + // we only want to reset the focus whenever the results or filters change + // eslint-disable-next-line + }, [results, filter]); + + 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({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: "WebUnifiedSearch", + metricsViaKeyboard: viaKeyboard, + }); + onFinished(); + }; + + let otherSearchesSection: JSX.Element; + if (trimmedQuery || filter !== Filter.PublicRooms) { + otherSearchesSection = ( +
+

+ { trimmedQuery + ? _t('Use "%(query)s" to search', { query }) + : _t("Search for") } +

+
+ { (filter !== Filter.PublicRooms) && ( + + ) } + { (filter !== Filter.People) && ( + + ) } +
+
+ ); + } + + let content: JSX.Element; + if (trimmedQuery || filter !== null) { + const resultMapper = (result: Result): JSX.Element => { + if (isRoomResult(result)) { + return ( + + ); + } + if (isMemberResult(result)) { + return ( + + ); + } + if (isPublicRoomResult(result)) { + const clientRoom = cli.getRoom(result.publicRoom.room_id); + const listener = (ev) => { + viewRoom(result.publicRoom.room_id, true, ev.type !== "click"); + }; + return ( + + ); + } + + // IResult case + return ( + + ); + }; + + let peopleSection: JSX.Element; + if (results[Section.People].length) { + peopleSection = ( +
+

{ _t("Recent Conversations") }

+
+ { results[Section.People].slice(0, SECTION_LIMIT).map(resultMapper) } +
+
+ ); + } + + let suggestionsSection: JSX.Element; + if (results[Section.Suggestions].length && filter === Filter.People) { + suggestionsSection = ( +
+

{ _t("Suggestions") }

+
+ { results[Section.Suggestions].slice(0, SECTION_LIMIT).map(resultMapper) } +
+
+ ); + } + + let roomsSection: JSX.Element; + if (results[Section.Rooms].length) { + roomsSection = ( +
+

{ _t("Rooms") }

+
+ { results[Section.Rooms].slice(0, SECTION_LIMIT).map(resultMapper) } +
+
+ ); + } + + let spacesSection: JSX.Element; + if (results[Section.Spaces].length) { + spacesSection = ( +
+

{ _t("Spaces you're in") }

+
+ { results[Section.Spaces].slice(0, SECTION_LIMIT).map(resultMapper) } +
+
+ ); + } + + let publicRoomsSection: JSX.Element; + if (filter === Filter.PublicRooms) { + publicRoomsSection = ( +
+
+

{ _t("Suggestions") }

+ +
+
+ { results[Section.PublicRooms].slice(0, SECTION_LIMIT).map(resultMapper) } +
+
+ ); + } + + let spaceRoomsSection: JSX.Element; + if (spaceResults.length && activeSpace && filter === null) { + spaceRoomsSection = ( +
+

{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }

+
+ { spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => ( + + )) } + { spaceResultsLoading && } +
+
+ ); + } + + let joinRoomSection: JSX.Element; + if (trimmedQuery.startsWith("#") && + trimmedQuery.includes(":") && + (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery))) + ) { + joinRoomSection = ( +
+
+ +
+
+ ); + } + + let hiddenResultsSection: JSX.Element; + if (filter === Filter.People) { + hiddenResultsSection = ( +
+

{ _t('Some results may be hidden for privacy') }

+
+ { _t("If you can't see who you're looking for, send them your invite link.") } +
+ { setInviteLinkCopied(true); copyPlaintext(ownInviteLink); }} + onHideTooltip={() => setInviteLinkCopied(false)} + title={inviteLinkCopied ? _t("Copied!") : _t("Copy")} + > + + { _t("Copy invite link") } + + +
+ ); + } else if (trimmedQuery && filter === Filter.PublicRooms) { + hiddenResultsSection = ( +
+

{ _t('Some results may be hidden') }

+
+ { _t("If you can't find the room you're looking for, " + + "ask for an invite or create a new room.") } +
+ +
+ ); + } + + let groupChatSection: JSX.Element; + if (filter === Filter.People) { + groupChatSection = ( +
+

{ _t('Other options') }

+ +
+ ); + } + + let messageSearchSection: JSX.Element; + if (filter === null) { + messageSearchSection = ( +
+

{ _t("Other searches") }

+
+ { _t( + "To search messages, look for this icon at the top of a room ", + {}, + { icon: () =>
}, + ) } +
+
+ ); + } + + content = <> + { peopleSection } + { suggestionsSection } + { roomsSection } + { spacesSection } + { spaceRoomsSection } + { publicRoomsSection } + { joinRoomSection } + { hiddenResultsSection } + { otherSearchesSection } + { groupChatSection } + { messageSearchSection } + ; + } else { + let recentSearchesSection: JSX.Element; + if (recentSearches.length) { + recentSearchesSection = ( +
+

+ { _t("Recent searches") } + + { _t("Clear") } + +

+
+ { recentSearches.map(room => ( + + )) } +
+
+ ); + } + + content = <> +
+

{ _t("Recently viewed") }

+
+ { BreadcrumbsStore.instance.rooms + .filter(r => r.roomId !== RoomViewStore.instance.getRoomId()) + .map(room => ( + { + viewRoom(room.roomId, false, ev.type !== "click"); + }} + > + + { room.name } + + )) + } +
+
+ + { recentSearchesSection } + { otherSearchesSection } + ; + } + + 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; + + const action = getKeyBindingsManager().getAccessibilityAction(ev); + + switch (action) { + case KeyBindingAction.Backspace: + if (!query && filter !== null) { + ev.stopPropagation(); + ev.preventDefault(); + setFilter(null); + } + break; + case KeyBindingAction.ArrowUp: + case KeyBindingAction.ArrowDown: + ev.stopPropagation(); + ev.preventDefault(); + + if (rovingContext.state.refs.length > 0) { + let refs = rovingContext.state.refs; + if (!query && !filter !== null) { + // 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 && !filter !== null && + 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 <> +
+ { _t("Use to scroll", {}, { + arrows: () => <> +
+
+ { !filter !== null && !query &&
} + { !filter !== null && !query &&
} + , + }) } +
+ + +
+ { filter !== null && ( +
+ { filterToLabel(filter) } + setFilter(null)} + /> +
+ ) } + + { (publicRoomsLoading || peopleLoading || profileLoading) && ( + + ) } +
+ +
+ { content } +
+ +
+ { + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + onFinished(); + }} /> + { openFeedback && _t("Results not as expected? Please give feedback.", {}, { + a: sub => + { sub } + , + }) } + { openFeedback && + { _t("Feedback") } + } +
+
+ ; +}; + +const RovingSpotlightDialog: React.FC = (props) => { + return + { () => } + ; +}; + +export default RovingSpotlightDialog; diff --git a/src/components/views/dialogs/spotlight/TooltipOption.tsx b/src/components/views/dialogs/spotlight/TooltipOption.tsx new file mode 100644 index 0000000000..f24ddc8f09 --- /dev/null +++ b/src/components/views/dialogs/spotlight/TooltipOption.tsx @@ -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 { + endAdornment?: ReactNode; +} + +export const TooltipOption: React.FC = ({ inputRef, className, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return ; +}; diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index d3de216411..701e5e39b9 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2016, 2020 The Matrix.org Foundation C.I.C. +Copyright 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,41 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, useState } from "react"; -import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { without } from "lodash"; +import React, { useCallback, useEffect, useState } from "react"; +import { MatrixError } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import { instanceForInstanceId, ALL_ROOMS, Protocols } from '../../../utils/DirectoryUtils'; -import ContextMenu, { - ChevronFace, - ContextMenuButton, - MenuGroup, - MenuItem, - MenuItemRadio, - useContextMenu, -} from "../../structures/ContextMenu"; +import { MenuItemRadio } from "../../../accessibility/context_menu/MenuItemRadio"; import { _t } from "../../../languageHandler"; -import SdkConfig from "../../../SdkConfig"; -import { useSettingValue } from "../../../hooks/useSettings"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Modal from "../../../Modal"; -import SettingsStore from "../../../settings/SettingsStore"; -import withValidation from "../elements/Validation"; +import SdkConfig from "../../../SdkConfig"; import { SettingLevel } from "../../../settings/SettingLevel"; +import SettingsStore from "../../../settings/SettingsStore"; +import { Protocols } from "../../../utils/DirectoryUtils"; +import { GenericDropdownMenu, GenericDropdownMenuItem } from "../../structures/GenericDropdownMenu"; import TextInputDialog from "../dialogs/TextInputDialog"; -import QuestionDialog from "../dialogs/QuestionDialog"; -import UIStore from "../../../stores/UIStore"; -import { compare } from "../../../utils/strings"; -import { SnakedObject } from "../../../utils/SnakedObject"; -import { IConfigOptions } from "../../../IConfigOptions"; +import AccessibleButton from "../elements/AccessibleButton"; +import withValidation from "../elements/Validation"; const SETTING_NAME = "room_directory_servers"; -const inPlaceOf = (elementRect: Pick) => ({ - right: UIStore.instance.windowWidth - elementRect.right, - top: elementRect.top, - chevronOffset: 0, - chevronFace: ChevronFace.None, -}); +export interface IPublicRoomDirectoryConfig { + roomServer: string; + instanceId?: string; +} const validServer = withValidation({ deriveData: async ({ value }) => { @@ -74,228 +61,170 @@ const validServer = withValidation({ final: true, test: async (_, { error }) => !error, valid: () => _t("Looks good"), - invalid: ({ error }) => error.errcode === "M_FORBIDDEN" + invalid: ({ error }) => error?.errcode === "M_FORBIDDEN" ? _t("You are not allowed to view this server's rooms list") : _t("Can't find this server or its room list"), }, ], }); -interface IProps { - protocols: Protocols; - selectedServerName: string; - selectedInstanceId: string; - onOptionChange(server: string, instanceId?: string): void; +function useSettingsValueWithSetter( + settingName: string, + level: SettingLevel, + roomId: string | null = null, + excludeDefault = false, +): [T, (value: T) => Promise] { + const [value, setValue] = useState(SettingsStore.getValue(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(settingName, roomId, excludeDefault)); + }); + // clean-up + return () => { + SettingsStore.unwatchSetting(ref); + }; + }, [settingName, roomId, excludeDefault]); + + return [value, setter]; } -// This dropdown sources homeservers from three places: -// + your currently connected homeserver -// + homeservers in config.json["roomDirectory"] -// + homeservers in SettingsStore["room_directory_servers"] -// if a server exists in multiple, only keep the top-most entry. +interface ServerList { + allServers: string[]; + homeServer: string; + userDefinedServers: string[]; + setUserDefinedServers: (servers: string[]) => void; +} -const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - const _userDefinedServers: string[] = useSettingValue(SETTING_NAME); - const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers); +function removeAll(target: Set, ...toRemove: T[]) { + for (const value of toRemove) { + target.delete(value); + } +} - const handlerFactory = (server, instanceId) => { - return () => { - onOptionChange(server, instanceId); - closeMenu(); - }; - }; +function useServers(): ServerList { + const [userDefinedServers, setUserDefinedServers] = useSettingsValueWithSetter( + SETTING_NAME, + 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]); + const homeServer = MatrixClientPeg.getHomeserverName(); + const configServers = new Set( + SdkConfig.getObject("room_directory")?.get("servers") ?? [], + ); + removeAll(configServers, homeServer); + // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. + const removableServers = new Set(userDefinedServers); + removeAll(removableServers, homeServer); + removeAll(removableServers, ...configServers); - // we either show the button or the dropdown in its place. - let content; - if (menuDisplayed) { - const roomDirectory = SdkConfig.getObject("room_directory") - ?? new SnakedObject({ servers: [] }); - - const hsName = MatrixClientPeg.getHomeserverName(); - const configServers = new Set(roomDirectory.get("servers")); - - // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. - const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName)); - const servers = [ + return { + allServers: [ // we always show our connected HS, this takes precedence over it being configured or user-defined - hsName, - ...Array.from(configServers).filter(s => s !== hsName).sort(), + homeServer, + ...Array.from(configServers).sort(), ...Array.from(removableServers).sort(), - ]; + ], + homeServer, + userDefinedServers: Array.from(removableServers).sort(), + setUserDefinedServers, + }; +} - // For our own HS, we can use the instance_ids given in the third party protocols - // response to get the server to filter the room list by network for us. - // We can't get thirdparty protocols for remote server yet though, so for those - // we can only show the default room list. - const options = servers.map(server => { - const serverSelected = server === selectedServerName; - const entries = []; +interface IProps { + protocols: Protocols | null; + config: IPublicRoomDirectoryConfig | null; + setConfig: (value: IPublicRoomDirectoryConfig | null) => void; +} - 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: "", - }); - } +export const NetworkDropdown = ({ protocols, config, setConfig }: IProps) => { + const { allServers, homeServer, userDefinedServers, setUserDefinedServers } = useServers(); - protocolsList.forEach(({ instances=[] }) => { - [...instances].sort((b, a) => { - return compare(a.desc, b.desc); - }).forEach(({ desc, instance_id: instanceId }) => { - entries.push( - - { desc } - ); - }); - }); + const options: GenericDropdownMenuItem[] = 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: ( + setUserDefinedServers(without(userDefinedServers, roomServer))} + /> + ), + }) : {}), + })); - let subtitle; - if (server === hsName) { - subtitle = ( -
- { _t("Your server") } -
- ); - } - - let removeButton; - if (removableServers.has(server)) { - const onClick = async () => { + const addNewServer = useCallback(({ closeMenu }) => ( + <> + + { closeMenu(); - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("Are you sure?"), - description: _t("Are you sure you want to remove %(serverName)s", { - serverName: server, - }, { - b: serverName => { serverName }, - }), - button: _t("Remove"), + const { finished } = Modal.createDialog(TextInputDialog, { + title: _t("Add a new server"), + description: _t("Enter the name of a new server you want to explore."), + button: _t("Add"), + hasCancel: false, + placeholder: _t("Server name"), + validator: validServer, fixedWidth: false, }, "mx_NetworkDropdown_dialog"); - const [ok] = await finished; + const [ok, newServer] = 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); + if (!allServers.includes(newServer)) { + setUserDefinedServers([...userDefinedServers, newServer]); + setConfig({ + roomServer: newServer, + }); } - }; - removeButton = ; - } + }} + > +
+ + { _t("Add new server…") } + +
+
+ + ), [allServers, setConfig, setUserDefinedServers, userDefinedServers]); - // ARIA: in actual fact the entire menu is one large radio group but for better screen reader support - // we use group to notate server wrongly. - return ( - -
- { server } - { removeButton } -
- { subtitle } - - - { _t("Matrix") } - - { entries } -
- ); - }); - - const onClick = async () => { - closeMenu(); - const { finished } = Modal.createDialog(TextInputDialog, { - title: _t("Add a new server"), - description: _t("Enter the name of a new server you want to explore."), - button: _t("Add"), - hasCancel: false, - placeholder: _t("Server name"), - validator: validServer, - fixedWidth: false, - }, "mx_NetworkDropdown_dialog"); - - const [ok, newServer] = await finished; - if (!ok) return; - - if (!userDefinedServers.includes(newServer)) { - setUserDefinedServers([...userDefinedServers, newServer]); - } - - onOptionChange(newServer); // change filter to the new server - }; - - const buttonRect = handle.current.getBoundingClientRect(); - content = -
- { options } - - { _t("Add a new server...") } - -
-
; - } 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 = - - { currentValue } - - ({ selectedServerName }) - - ; - } - - return
- { content } -
; + return ( + + 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; diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index 4e39082817..8cba91def0 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -54,7 +54,7 @@ import TooltipTarget from "../elements/TooltipTarget"; import { BetaPill } from "../beta/BetaCard"; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { useWebSearchMetrics } from "../dialogs/SpotlightDialog"; +import { useWebSearchMetrics } from "../dialogs/spotlight/SpotlightDialog"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; diff --git a/src/hooks/spotlight/useDebouncedCallback.ts b/src/hooks/spotlight/useDebouncedCallback.ts new file mode 100644 index 0000000000..9548ce5e0c --- /dev/null +++ b/src/hooks/spotlight/useDebouncedCallback.ts @@ -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( + 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]); +} diff --git a/src/hooks/spotlight/useRecentSearches.ts b/src/hooks/spotlight/useRecentSearches.ts new file mode 100644 index 0000000000..1ae43be69e --- /dev/null +++ b/src/hooks/spotlight/useRecentSearches.ts @@ -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([]); + }]; +}; diff --git a/src/hooks/useLatestResult.ts b/src/hooks/useLatestResult.ts new file mode 100644 index 0000000000..fde3ab89dd --- /dev/null +++ b/src/hooks/useLatestResult.ts @@ -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 = (onResultChanged: (result: R) => void): + [(query: T | null) => void, (query: T | null, result: R) => void] => { + const ref = useRef(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]; +}; diff --git a/src/hooks/useProfileInfo.ts b/src/hooks/useProfileInfo.ts new file mode 100644 index 0000000000..ed70d4bc42 --- /dev/null +++ b/src/hooks/useProfileInfo.ts @@ -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(null); + + const [loading, setLoading] = useState(false); + + const [updateQuery, updateResult] = useLatestResult(setProfile); + + const search = useCallback(async ({ query: term }: IProfileInfoOpts): Promise => { + 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; +}; diff --git a/src/hooks/usePublicRoomDirectory.ts b/src/hooks/usePublicRoomDirectory.ts index 24cc8f541a..fd0b764372 100644 --- a/src/hooks/usePublicRoomDirectory.ts +++ b/src/hooks/usePublicRoomDirectory.ts @@ -14,14 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useCallback, useEffect, useState } from "react"; -import { IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests"; +import { IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; +import { useCallback, useEffect, useState } from "react"; +import { IPublicRoomDirectoryConfig } from "../components/views/directory/NetworkDropdown"; import { MatrixClientPeg } from "../MatrixClientPeg"; import SdkConfig from "../SdkConfig"; import SettingsStore from "../settings/SettingsStore"; import { Protocols } from "../utils/DirectoryUtils"; +import { useLatestResult } from "./useLatestResult"; export const ALL_ROOMS = "ALL_ROOMS"; const LAST_SERVER_KEY = "mx_last_room_directory_server"; @@ -37,13 +39,15 @@ let thirdParty: Protocols; export const usePublicRoomDirectory = () => { const [publicRooms, setPublicRooms] = useState([]); - const [roomServer, setRoomServer] = useState(undefined); - const [instanceId, setInstanceId] = useState(undefined); + const [config, setConfigInternal] = useState(undefined); + const [protocols, setProtocols] = useState(null); const [ready, setReady] = useState(false); const [loading, setLoading] = useState(false); + const [updateQuery, updateResult] = useLatestResult(setPublicRooms); + async function initProtocols() { if (!MatrixClientPeg.get()) { // We may not have a client yet when invoked from welcome page @@ -57,12 +61,11 @@ export const usePublicRoomDirectory = () => { } } - function setConfig(server: string, instanceId?: string) { + function setConfig(config: IPublicRoomDirectoryConfig) { if (!ready) { throw new Error("public room configuration not initialised yet"); } else { - setRoomServer(server); - setInstanceId(instanceId ?? null); + setConfigInternal(config); } } @@ -70,21 +73,16 @@ export const usePublicRoomDirectory = () => { limit = 20, query, }: IPublicRoomsOpts): Promise => { - if (!query?.length) { - setPublicRooms([]); - return true; - } - const opts: IRoomDirectoryOptions = { limit }; - if (roomServer != MatrixClientPeg.getHomeserverName()) { - opts.server = roomServer; + if (config?.roomServer != MatrixClientPeg.getHomeserverName()) { + opts.server = config?.roomServer; } - if (instanceId === ALL_ROOMS) { + if (config?.instanceId === ALL_ROOMS) { opts.include_all_networks = true; - } else if (instanceId) { - opts.third_party_instance_id = instanceId; + } else if (config?.instanceId) { + opts.third_party_instance_id = config.instanceId; } if (query) { @@ -93,19 +91,20 @@ export const usePublicRoomDirectory = () => { }; } + updateQuery(opts); try { setLoading(true); const { chunk } = await MatrixClientPeg.get().publicRooms(opts); - setPublicRooms(chunk); + updateResult(opts, chunk); return true; } catch (e) { console.error("Could not fetch public rooms for params", opts, e); - setPublicRooms([]); + updateResult(opts, []); return false; } finally { setLoading(false); } - }, [roomServer, instanceId]); + }, [config, updateQuery, updateResult]); useEffect(() => { initProtocols(); @@ -118,9 +117,9 @@ export const usePublicRoomDirectory = () => { const myHomeserver = MatrixClientPeg.getHomeserverName(); const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY); - const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY); + const lsInstanceId: string | undefined = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined; - let roomServer = myHomeserver; + let roomServer: string = myHomeserver; if ( SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) || SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) @@ -128,7 +127,7 @@ export const usePublicRoomDirectory = () => { roomServer = lsRoomServer; } - let instanceId: string | null = null; + let instanceId: string | undefined = undefined; if (roomServer === myHomeserver && ( lsInstanceId === ALL_ROOMS || Object.values(protocols).some((p: IProtocol) => { @@ -139,25 +138,24 @@ export const usePublicRoomDirectory = () => { } setReady(true); - setInstanceId(instanceId); - setRoomServer(roomServer); + setConfigInternal({ roomServer, instanceId }); }, [protocols]); useEffect(() => { - localStorage.setItem(LAST_SERVER_KEY, roomServer); - }, [roomServer]); - - useEffect(() => { - localStorage.setItem(LAST_INSTANCE_KEY, instanceId); - }, [instanceId]); + localStorage.setItem(LAST_SERVER_KEY, config?.roomServer); + if (config?.instanceId) { + localStorage.setItem(LAST_INSTANCE_KEY, config?.instanceId); + } else { + localStorage.removeItem(LAST_INSTANCE_KEY); + } + }, [config]); return { ready, loading, publicRooms, protocols, - roomServer, - instanceId, + config, search, setConfig, } as const; diff --git a/src/hooks/useSpaceResults.ts b/src/hooks/useSpaceResults.ts new file mode 100644 index 0000000000..7497689b28 --- /dev/null +++ b/src/hooks/useSpaceResults.ts @@ -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([]); + const [hierarchy, setHierarchy] = useState(); + + 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]; +}; diff --git a/src/hooks/useUserDirectory.ts b/src/hooks/useUserDirectory.ts index cb7307af2a..10df6bc1c8 100644 --- a/src/hooks/useUserDirectory.ts +++ b/src/hooks/useUserDirectory.ts @@ -18,6 +18,7 @@ import { useCallback, useState } from "react"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { DirectoryMember } from "../utils/direct-messages"; +import { useLatestResult } from "./useLatestResult"; export interface IUserDirectoryOpts { limit: number; @@ -29,10 +30,15 @@ export const useUserDirectory = () => { const [loading, setLoading] = useState(false); + const [updateQuery, updateResult] = useLatestResult<{ term: string, limit?: number }, DirectoryMember[]>(setUsers); + const search = useCallback(async ({ limit = 20, query: term, }: IUserDirectoryOpts): Promise => { + const opts = { limit, term }; + updateQuery(opts); + if (!term?.length) { setUsers([]); return true; @@ -40,20 +46,17 @@ export const useUserDirectory = () => { try { setLoading(true); - const { results } = await MatrixClientPeg.get().searchUserDirectory({ - limit, - term, - }); - setUsers(results.map(user => new DirectoryMember(user))); + const { results } = await MatrixClientPeg.get().searchUserDirectory(opts); + updateResult(opts, results.map(user => new DirectoryMember(user))); return true; } catch (e) { console.error("Could not fetch user in user directory for params", { limit, term }, e); - setUsers([]); + updateResult(opts, []); return false; } finally { setLoading(false); } - }, []); + }, [updateQuery, updateResult]); return { ready: true, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 810d387e4a..64ba5dfea4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2383,15 +2383,14 @@ "You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list", "Can't find this server or its room list": "Can't find this server or its room list", "Your server": "Your server", - "Are you sure you want to remove %(serverName)s": "Are you sure you want to remove %(serverName)s", - "Remove server": "Remove server", "Matrix": "Matrix", + "Remove server “%(roomServer)s”": "Remove server “%(roomServer)s”", "Add a new server": "Add a new server", "Enter the name of a new server you want to explore.": "Enter the name of a new server you want to explore.", "Server name": "Server name", - "Add a new server...": "Add a new server...", - "%(networkName)s rooms": "%(networkName)s rooms", - "Matrix rooms": "Matrix rooms", + "Add new server…": "Add new server…", + "Show: %(instance)s rooms (%(server)s)": "Show: %(instance)s rooms (%(server)s)", + "Show: Matrix rooms": "Show: Matrix rooms", "Add existing space": "Add existing space", "Want to add a new space instead?": "Want to add a new space instead?", "Create a new space": "Create a new space", @@ -2759,18 +2758,6 @@ "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.", "Space settings": "Space settings", "Settings - %(spaceName)s": "Settings - %(spaceName)s", - "Spaces you're in": "Spaces you're in", - "Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s", - "Join %(roomAddress)s": "Join %(roomAddress)s", - "Use \"%(query)s\" to search": "Use \"%(query)s\" to search", - "Public rooms": "Public rooms", - "Other searches": "Other searches", - "To search messages, look for this icon at the top of a room ": "To search messages, look for this icon at the top of a room ", - "Recent searches": "Recent searches", - "Clear": "Clear", - "Use to scroll": "Use to scroll", - "Search Dialog": "Search Dialog", - "Results not as expected? Please give feedback.": "Results not as expected? Please give feedback.", "To help us prevent this in future, please send us logs.": "To help us prevent this in future, please send us logs.", "Missing session data": "Missing session data", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.", @@ -2810,6 +2797,30 @@ "Allow this widget to verify your identity": "Allow this widget to verify your identity", "The widget will verify your user ID, but won't be able to perform actions for you:": "The widget will verify your user ID, but won't be able to perform actions for you:", "Remember this": "Remember this", + "%(count)s Members|other": "%(count)s Members", + "%(count)s Members|one": "%(count)s Member", + "Public rooms": "Public rooms", + "Use \"%(query)s\" to search": "Use \"%(query)s\" to search", + "Search for": "Search for", + "Spaces you're in": "Spaces you're in", + "Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s", + "Join %(roomAddress)s": "Join %(roomAddress)s", + "Some results may be hidden for privacy": "Some results may be hidden for privacy", + "If you can't see who you're looking for, send them your invite link.": "If you can't see who you're looking for, send them your invite link.", + "Copy invite link": "Copy invite link", + "Some results may be hidden": "Some results may be hidden", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.", + "Create new Room": "Create new Room", + "Other options": "Other options", + "Start a group chat": "Start a group chat", + "Other searches": "Other searches", + "To search messages, look for this icon at the top of a room ": "To search messages, look for this icon at the top of a room ", + "Recent searches": "Recent searches", + "Clear": "Clear", + "Use to scroll": "Use to scroll", + "Search Dialog": "Search Dialog", + "Remove search filter for %(filter)s": "Remove search filter for %(filter)s", + "Results not as expected? Please give feedback.": "Results not as expected? Please give feedback.", "Wrong file type": "Wrong file type", "Looks good!": "Looks good!", "Wrong Security Key": "Wrong Security Key", diff --git a/src/utils/DirectoryUtils.ts b/src/utils/DirectoryUtils.ts index f8b9e858d1..429c54dd4f 100644 --- a/src/utils/DirectoryUtils.ts +++ b/src/utils/DirectoryUtils.ts @@ -23,7 +23,7 @@ export type Protocols = Record; // Find a protocol 'instance' with a given instance_id // in the supplied protocols dict -export function instanceForInstanceId(protocols: Protocols, instanceId: string): IInstance { +export function instanceForInstanceId(protocols: Protocols, instanceId: string | null | undefined): IInstance | null { if (!instanceId) return null; for (const proto of Object.keys(protocols)) { if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue; @@ -31,11 +31,12 @@ export function instanceForInstanceId(protocols: Protocols, instanceId: string): if (instance.instance_id == instanceId) return instance; } } + return null; } // given an instance_id, return the name of the protocol for // that instance ID in the supplied protocols dict -export function protocolNameForInstanceId(protocols: Protocols, instanceId: string): string { +export function protocolNameForInstanceId(protocols: Protocols, instanceId: string | null | undefined): string | null { if (!instanceId) return null; for (const proto of Object.keys(protocols)) { if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue; @@ -43,4 +44,5 @@ export function protocolNameForInstanceId(protocols: Protocols, instanceId: stri if (instance.instance_id == instanceId) return proto; } } + return null; } diff --git a/src/utils/SortMembers.ts b/src/utils/SortMembers.ts new file mode 100644 index 0000000000..74d6388c93 --- /dev/null +++ b/src/utils/SortMembers.ts @@ -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, + memberScores: Record, +) => (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)), + }; + }); +} diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx new file mode 100644 index 0000000000..7c8bbc6bc7 --- /dev/null +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -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; + 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 shining 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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(); + }); + }); +}); diff --git a/test/hooks/useDebouncedCallback-test.tsx b/test/hooks/useDebouncedCallback-test.tsx new file mode 100644 index 0000000000..8aa28cb8f5 --- /dev/null +++ b/test/hooks/useDebouncedCallback-test.tsx @@ -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
+ { JSON.stringify(params) } +
; +} + +describe("useDebouncedCallback", () => { + it("should be able to handle empty parameters", async () => { + const params = []; + const callback = jest.fn(); + + const wrapper = mount(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/test/hooks/useLatestResult-test.tsx b/test/hooks/useLatestResult-test.tsx new file mode 100644 index 0000000000..159c5f3e2f --- /dev/null +++ b/test/hooks/useLatestResult-test.tsx @@ -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(0); + const [updateQuery, updateResult] = useLatestResult(setValueInternal); + useEffect(() => { + updateQuery(query); + doRequest(query).then(it => { + updateResult(query, it); + }); + }, [doRequest, query, updateQuery, updateResult]); + + return
+ { value } +
; +} + +describe("useLatestResult", () => { + it("should return results", async () => { + const doRequest = async (query) => { + await sleep(20); + return query; + }; + + const wrapper = mount(); + 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(); + 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"); + }); +}); diff --git a/test/hooks/useProfileInfo-test.tsx b/test/hooks/useProfileInfo-test.tsx new file mode 100644 index 0000000000..e77d7c2834 --- /dev/null +++ b/test/hooks/useProfileInfo-test.tsx @@ -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
onClick(profileInfo)}> + { (!ready || loading) && `ready: ${ready}, loading: ${loading}` } + { profile && ( + `Name: ${profile.display_name}` + ) } +
; +} + +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( { + 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( { + 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( { + 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( { + 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( { + hook.search({ + limit: 1, + query, + }); + }} />); + await act(async () => { + await sleep(1); + wrapper.simulate("click"); + return act(() => sleep(1)); + }); + + expect(wrapper.text()).toBe(""); + }); +}); diff --git a/test/hooks/useUserDirectory-test.tsx b/test/hooks/useUserDirectory-test.tsx index bcd2861dfe..44721e3643 100644 --- a/test/hooks/useUserDirectory-test.tsx +++ b/test/hooks/useUserDirectory-test.tsx @@ -96,7 +96,7 @@ describe("useUserDirectory", () => { expect(wrapper.text()).toBe("ready: true, loading: false"); }); - it("should work with empty queries", async () => { + it("should recover from a server exception", async () => { cli.searchUserDirectory = () => { throw new Error("Oops"); }; const query = "Bob";