From 3511c870e5401127d3d8c429eee151bb2bd9d330 Mon Sep 17 00:00:00 2001 From: Badi Ifaoui Date: Thu, 7 Dec 2023 13:17:36 +0100 Subject: [PATCH] Features/verified user badge (#12) * feat: verified user badge * fix: style lint issue * fix: custom styles * feat: right panel user info badge --- components.json | 4 +- config.sample.json | 1 + package.json | 1 + res/css/superhero/custom.css | 202 +- res/themes/superhero/img/icons/verified.svg | 13 + src/atoms.ts | 3 + src/components/views/dialogs/InviteDialog.tsx | 1586 +++++++++++++++ src/components/views/elements/RoomName.tsx | 15 +- .../views/elements/UserVerifiedBadge.tsx | 14 + src/components/views/right_panel/UserInfo.tsx | 1784 +++++++++++++++++ src/context/SuperheroProvider.tsx | 38 + src/hooks/useRoomName.ts | 9 - src/hooks/useVerifiedRoom.ts | 24 + src/hooks/useVerifiedUser.ts | 19 + src/vector/app.tsx | 25 +- yarn.lock | 5 + 16 files changed, 3617 insertions(+), 126 deletions(-) create mode 100644 res/themes/superhero/img/icons/verified.svg create mode 100644 src/atoms.ts create mode 100644 src/components/views/dialogs/InviteDialog.tsx create mode 100644 src/components/views/elements/UserVerifiedBadge.tsx create mode 100644 src/components/views/right_panel/UserInfo.tsx create mode 100644 src/context/SuperheroProvider.tsx create mode 100644 src/hooks/useVerifiedRoom.ts create mode 100644 src/hooks/useVerifiedUser.ts diff --git a/components.json b/components.json index ab9cccbdab..2c7bb36186 100644 --- a/components.json +++ b/components.json @@ -10,5 +10,7 @@ "src/components/views/spaces/SpacePanel.tsx": "src/components/views/spaces/SpacePanel.tsx", "src/hooks/useRoomName.ts": "src/hooks/useRoomName.ts", "src/editor/commands.tsx": "src/editor/commands.tsx", - "src/autocomplete/Autocompleter.ts": "src/autocomplete/Autocompleter.ts" + "src/autocomplete/Autocompleter.ts": "src/autocomplete/Autocompleter.ts", + "src/components/views/dialogs/InviteDialog.tsx": "src/components/views/dialogs/InviteDialog.tsx", + "src/components/views/right_panel/UserInfo.tsx": "src/components/views/right_panel/UserInfo.tsx" } diff --git a/config.sample.json b/config.sample.json index 579b28619a..5f302345c9 100644 --- a/config.sample.json +++ b/config.sample.json @@ -8,6 +8,7 @@ "base_url": "https://vector.im" } }, + "bots_backend_url": "https://http://matrix.superhero.com/wallet", "disable_custom_urls": false, "disable_guests": false, "disable_login_language_selector": false, diff --git a/package.json b/package.json index 13dcfa0b66..2b537af8ef 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@matrix-org/olm": "3.2.15", "@matrix-org/react-sdk-module-api": "^2.2.1", "gfm.css": "^1.1.2", + "jotai": "^2.6.0", "jsrsasign": "^10.5.25", "katex": "^0.16.0", "lodash": "^4.17.21", diff --git a/res/css/superhero/custom.css b/res/css/superhero/custom.css index 05598d2644..0c31656bf4 100644 --- a/res/css/superhero/custom.css +++ b/res/css/superhero/custom.css @@ -1,62 +1,63 @@ -.sh_RoomTokenGatedRoom { +.sh_RoomTokenGatedRoom, +.mx_InviteDialog_tile_nameStack_name { align-items: center; display: flex; } -.sh_RoomTokenGatedRoomIcon { +.sh_RoomTokenGatedRoomIcon, +.sh_VerifiedIcon { width: 16px; height: 16px; margin-right: 4px; } -h2 .sh_RoomTokenGatedRoomIcon { +h2 .sh_RoomTokenGatedRoomIcon, +h2 .sh_VerifiedIcon { width: 26px; height: 26px; } +.sh_VerifiedIcon { + margin-right: 4px; + margin-left: 4px; +} + +.mx_UserInfo_profile .sh_VerifiedIcon { + width: 18px; + height: 18px; + margin-top: 3px; +} + .mx_QuickSettingsButton.sh_SuperheroDexButton::before { - -webkit-mask-image: url(../../themes/superhero/img/icons/diamond.svg); - mask-image: url(../../themes/superhero/img/icons/diamond.svg); + -webkit-mask-image: url("../../themes/superhero/img/icons/diamond.svg"); + mask-image: url("../../themes/superhero/img/icons/diamond.svg"); } .mx_QuickSettingsButton.sh_MintTokenButton::before { - -webkit-mask-image: url(../../themes/superhero/img/icons/tokens.svg); - mask-image: url(../../themes/superhero/img/icons/tokens.svg); + -webkit-mask-image: url("../../themes/superhero/img/icons/tokens.svg"); + mask-image: url("../../themes/superhero/img/icons/tokens.svg"); } - /* START - Update @user chat message highlighting */ -.mx_EventTile.mx_EventTile_highlight, .mx_EventTile.mx_EventTile_highlight .markdown-body, .mx_EventTile.mx_EventTile_highlight .mx_EventTile_edited { - color: var(--cpd-color-orange-1000)!important; +.mx_EventTile.mx_EventTile_highlight, +.mx_EventTile.mx_EventTile_highlight .markdown-body, +.mx_EventTile.mx_EventTile_highlight .mx_EventTile_edited { + color: var(--cpd-color-orange-1000) !important; /* font-weight: bold; */ } -.mx_EventTile[data-layout="irc"].mx_EventTile_highlight .mx_EventTile_line, .mx_EventTile[data-layout="irc"].mx_EventTile_highlight .markdown-body .mx_EventTile_line, .mx_EventTile[data-layout="group"].mx_EventTile_highlight .mx_EventTile_line, .mx_EventTile[data-layout="group"].mx_EventTile_highlight .markdown-body .mx_EventTile_line { - /* background-color:var(--cpd-color-alpha-yellow-300)!important; */ -} - -.mx_EventTile[data-layout="group"].mx_EventTile_highlight .markdown-body .mx_EventTile_line, .mx_EventTile[data-layout="group"].mx_EventTile_highlight .mx_EventTile_line, .mx_EventTile[data-layout="irc"].mx_EventTile_highlight .markdown-body .mx_EventTile_line, .mx_EventTile[data-layout="irc"].mx_EventTile_highlight .mx_EventTile_line { - /* background-color:var(--cpd-color-alpha-yellow-700)!important; */ -} -/* END - Update @user chat message highlighting */ - - /* START - Custom Side Panel styling */ .mx_SpacePanel { - --activeBackground-color: var(--cpd-color-alpha-gray-500)!important; + --activeBackground-color: var(--cpd-color-alpha-gray-500) !important; background-image: linear-gradient(-75deg, #cc3ae6, #6147ff); } .mx_SpacePanel .mx_UserMenu_name { - color: white!important; - margin-left: 14px!important; -} - -.mx_SpacePanel .mx_SpaceButton.mx_SpaceButton_active.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { - background: white!important; + color: white !important; + margin-left: 14px !important; } .mx_SpaceButton_home .mx_SpaceButton_icon { - background: white!important; + background: white !important; outline: 1px solid white; } @@ -72,17 +73,18 @@ h2 .sh_RoomTokenGatedRoomIcon { } .mx_SpacePanel .mx_SpaceButton.mx_SpaceButton_active.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { - outline: 1px rgb(36, 36, 36) solid!important; - border: 3px rgb(36, 36, 36) solid!important; + background: white !important; + outline: 1px rgb(36, 36, 36) solid !important; + border: 3px rgb(36, 36, 36) solid !important; } .mx_NotificationBadge.mx_NotificationBadge_visible.mx_NotificationBadge_dot { - background-color: var(--cpd-color-text-action-accent)!important; + background-color: var(--cpd-color-text-action-accent) !important; } .mx_NotificationBadge_visible { - background-color: black!important; - font-weight: 700!important; + background-color: black !important; + font-weight: 700 !important; } .mx_SpacePanel .mx_SpaceButton_active .mx_SpaceButton_avatarWrapper .mx_BaseAvatar { @@ -90,107 +92,106 @@ h2 .sh_RoomTokenGatedRoomIcon { } .mx_SpacePanel .mx_SpaceButton .mx_SpaceButton_name { - color: white!important; - margin-left: 14px!important; + color: white !important; + margin-left: 14px !important; } .mx_SpacePanel .mx_SpaceButton .mx_SpaceButton_selectionWrapper { - padding-right: 20px!important; + padding-right: 20px !important; } .mx_SpacePanel.collapsed .mx_SpaceButton .mx_SpaceButton_selectionWrapper { - padding-right: 0px!important; + padding-right: 0px !important; } .mx_SpacePanel .mx_AccessibleButton.mx_SpacePanel_toggleCollapse { - background-color: rgb(36, 36, 36)!important; + background-color: rgb(36, 36, 36) !important; } .cpd-theme-dark .mx_SpacePanel .mx_AccessibleButton.mx_SpacePanel_toggleCollapse { - background-color: white!important; + background-color: white !important; } .mx_SpacePanel .mx_QuickSettingsButton { - color: white!important; + color: white !important; } .mx_SpacePanel .mx_QuickSettingsButton::before { - background-color: white!important; + background-color: white !important; } .mx_SpacePanel .mx_QuickSettingsButton:not(.expanded):hover { - background-color: rgba(255, 255, 255, 0.3)!important; + background-color: rgba(255, 255, 255, 0.3) !important; } .mx_ToastContainer { - top: 3px!important; - left: 72px!important; + top: 3px !important; + left: 72px !important; } .mx_Toast_toast { - background: var(--cpd-color-theme-bg)!important; + background: var(--cpd-color-theme-bg) !important; width: 260px; } /* END - Custom Side Panel styling */ .cpd-theme-light { - --cpd-color-text-primary: black!important; + --cpd-color-text-primary: black !important; /* --cpd-color-text-secondary: black!important; */ } .mx_LegacyRoomHeader_button:hover { - background: rgba(0, 0, 0, 0.06)!important; + background: rgba(0, 0, 0, 0.06) !important; } /* START - Custom Room Info Panel styling */ .mx_BaseCard { - background-color: rgba(0, 0, 0, 0.025)!important; + background-color: rgba(0, 0, 0, 0.025) !important; } .mx_BaseCard ._item_zxa40_17 { - background: transparent!important; + background: transparent !important; } .cpd-theme-dark .mx_BaseCard { - background-color: rgba(0, 0, 0, 0.13)!important; + background-color: rgba(0, 0, 0, 0.13) !important; } /* END - Custom Room Info Panel styling */ .cpd-theme-dark .mx_LeftPanel_wrapper .mx_LeftPanel_wrapper--user { - background-color: rgba(0, 0, 0, 0.74)!important; + background-color: rgba(0, 0, 0, 0.74) !important; } .cpd-theme-dark .mx_LeftPanel .mx_LeftPanel_roomListContainer { - background-color: rgba(38, 40, 45, 0.9)!important; + background-color: rgba(38, 40, 45, 0.9) !important; } .cpd-theme-dark { - --cpd-color-text-primary: white!important; - --cpd-color-text-secondary: var(--cpd-color-gray-1200)!important; + --cpd-color-text-primary: white !important; + --cpd-color-text-secondary: var(--cpd-color-gray-1200) !important; } .cpd-theme-dark .mx_SpacePanel { /* --activeBackground-color: var(--cpd-color-alpha-gray-500)!important; */ /* background-image: linear-gradient(25deg, #700483, #110177)!important; */ - background: linear-gradient(25deg, rgba(204, 58, 230, 0.20) 0%, rgba(97, 71, 255, 0.20) 100%), #313338; + background: linear-gradient(25deg, rgba(204, 58, 230, 0.2) 0%, rgba(97, 71, 255, 0.2) 100%), #313338; } .cpd-theme-dark .mx_SpacePanel .mx_SpaceButton.mx_SpaceButton_home .mx_SpaceButton_icon::before { - background-color: #393559!important; + background-color: #393559 !important; } .cpd-theme-dark .mx_NotificationBadge_visible { - background-color: white!important; + background-color: white !important; } .cpd-theme-dark .mx_NotificationBadge_visible .mx_NotificationBadge_count { - color: black!important; + color: black !important; } - /* CPD Light Theme Overwrite */ .cpd-theme-light.cpd-theme-light { - --cpd-color-text-action-accent: #6147ff!important; + --cpd-color-text-action-accent: #6147ff !important; /* --cpd-color-alpha-pink-1400: hsl(339, 100%, 13%, 1); --cpd-color-alpha-pink-1300: hsl(333, 100%, 19%, 1); --cpd-color-alpha-pink-1200: hsla(330, 98%, 24%, 0.98); @@ -347,7 +348,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-alpha-gray-100: hsla(210, 48%, 41%, 0.02); --cpd-color-pink-1400: #430017; --cpd-color-pink-1300: #5f002b; */ - --cpd-color-pink-1200: #a80298!important; + --cpd-color-pink-1200: #a80298 !important; /* --cpd-color-pink-1100: #9f0850; --cpd-color-pink-1000: #b80a5b; --cpd-color-pink-900: #d20c65; @@ -361,7 +362,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-pink-100: #fffafb; --cpd-color-fuchsia-1400: #34004c; --cpd-color-fuchsia-1300: #4e0068; */ - --cpd-color-fuchsia-1200: #8201aa!important; + --cpd-color-fuchsia-1200: #8201aa !important; /* --cpd-color-fuchsia-1100: #822198; --cpd-color-fuchsia-1000: #972aaa; --cpd-color-fuchsia-900: #ad33bd; @@ -375,7 +376,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-fuchsia-100: #fefafe; --cpd-color-purple-1400: #200066; --cpd-color-purple-1300: #33008d; */ - --cpd-color-purple-1200: #5f01ed!important; + --cpd-color-purple-1200: #5f01ed !important; /* --cpd-color-purple-1100: #5d26cd; --cpd-color-purple-1000: #6b37de; --cpd-color-purple-900: #7a47f1; @@ -389,7 +390,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-purple-100: #fbfbff; --cpd-color-blue-1400: #000e65; --cpd-color-blue-1300: #012478; */ - --cpd-color-blue-1200: #0530cd!important; + --cpd-color-blue-1200: #0530cd !important; /* --cpd-color-blue-1100: #064ab1; --cpd-color-blue-1000: #0558c7; --cpd-color-blue-900: #0467dd; @@ -403,7 +404,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-blue-100: #f9fcff; --cpd-color-cyan-1400: #00194f; --cpd-color-cyan-1300: #002b61; */ - --cpd-color-cyan-1200: #02b5c5!important; + --cpd-color-cyan-1200: #02b5c5 !important; /* --cpd-color-cyan-1100: #00548c; --cpd-color-cyan-1000: #00629c; --cpd-color-cyan-900: #0072ac; @@ -417,10 +418,10 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-cyan-100: #f8fdfd; --cpd-color-green-1400: #002311; --cpd-color-green-1300: #003420; */ - --cpd-color-green-1200: #009e3d!important; + --cpd-color-green-1200: #009e3d !important; /* --cpd-color-green-1100: #005c45; --cpd-color-green-1000: #006b52; */ - --cpd-color-green-900: #02a769!important; + --cpd-color-green-900: #02a769 !important; /* --cpd-color-green-800: #009b78; --cpd-color-green-700: #0bc491; --cpd-color-green-600: #71d7ae; @@ -431,7 +432,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-green-100: #f8fdfb; --cpd-color-lime-1400: #002400; --cpd-color-lime-1300: #003600; */ - --cpd-color-lime-1200: #00b300!important; + --cpd-color-lime-1200: #00b300 !important; /* --cpd-color-lime-1100: #005f00; --cpd-color-lime-1000: #006e00; --cpd-color-lime-900: #197d0c; @@ -445,7 +446,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-lime-100: #f8fdf6; --cpd-color-yellow-1400: #410600; --cpd-color-yellow-1300: #541a00; */ - --cpd-color-yellow-1200: #c09000!important; + --cpd-color-yellow-1200: #c09000 !important; /* --cpd-color-yellow-1100: #803f00; --cpd-color-yellow-1000: #8f4d00; --cpd-color-yellow-900: #9f5b00; @@ -459,7 +460,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-yellow-100: #fffcf0; --cpd-color-orange-1400: #450000; --cpd-color-orange-1300: #620000; */ - --cpd-color-orange-1200: #d4570f!important; + --cpd-color-orange-1200: #d4570f !important; /* --cpd-color-orange-1100: #9b2200; --cpd-color-orange-1000: #ac3300; --cpd-color-orange-900: #bc4500; @@ -474,7 +475,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-red-1400: #450000; --cpd-color-red-1300: #620000; --cpd-color-red-1200: #c5000a; */ - --cpd-color-red-1100: #a4041d!important; + --cpd-color-red-1100: #a4041d !important; /* --cpd-color-red-1000: #bc0f22; --cpd-color-red-900: #d51928; --cpd-color-red-800: #ff3d3d; @@ -502,15 +503,15 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-theme-bg: #ffffff; */ --cpd-color-bg-subtle-secondary-level-0: var(--cpd-color-gray-300); --cpd-color-bg-canvas-default-level-1: var(--cpd-color-theme-bg); - } - +} + /* CPD Dark Theme Overwrite */ .cpd-theme-dark.cpd-theme-dark { - --cpd-color-text-action-accent: #6147ff!important; - --cpd-color-theme-bg: #313338!important; - --cpd-color-text-link-external: rgb(141, 149, 255)!important; + --cpd-color-text-action-accent: #6147ff !important; + --cpd-color-theme-bg: #313338 !important; + --cpd-color-text-link-external: rgb(141, 149, 255) !important; --cpd-color-bg-subtle-secondary-level-0: var(--cpd-color-theme-bg); - --cpd-color-bg-subtle-primary:rgba(0, 0, 0, 0.075)!important; + --cpd-color-bg-subtle-primary: rgba(0, 0, 0, 0.075) !important; --cpd-color-bg-canvas-default-level-1: var(--cpd-color-gray-300); /* --cpd-color-alpha-pink-1400: hsl(347, 100%, 96%, 1); --cpd-color-alpha-pink-1300: hsl(347, 100%, 91%, 1); @@ -668,7 +669,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-alpha-gray-100: hsla(214, 10%, 86%, 0.02); --cpd-color-pink-1400: #ffe8ed; --cpd-color-pink-1300: #ffd2dc; */ - --cpd-color-pink-1200: #c81fb7!important; + --cpd-color-pink-1200: #c81fb7 !important; /* --cpd-color-pink-1100: #fe84a2; --cpd-color-pink-1000: #fa658f; --cpd-color-pink-900: #f4427d; @@ -677,12 +678,12 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-pink-600: #7c0c41; --cpd-color-pink-500: #6d0036; --cpd-color-pink-400: #550024; */ - --cpd-color-pink-300: #544352!important; + --cpd-color-pink-300: #544352 !important; /* --cpd-color-pink-200: #3c0012; --cpd-color-pink-100: #37000f; --cpd-color-fuchsia-1400: #f8e9f9; --cpd-color-fuchsia-1300: #f1d4f3; */ - --cpd-color-fuchsia-1200: #b11c91!important; + --cpd-color-fuchsia-1200: #b11c91 !important; /* --cpd-color-fuchsia-1100: #d991de; --cpd-color-fuchsia-1000: #cf78d7; --cpd-color-fuchsia-900: #c560cf; @@ -691,12 +692,12 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-fuchsia-600: #65177d; --cpd-color-fuchsia-500: #560f6f; --cpd-color-fuchsia-400: #46005e; */ - --cpd-color-fuchsia-300: #52424f!important; + --cpd-color-fuchsia-300: #52424f !important; /* --cpd-color-fuchsia-200: #2e0044; --cpd-color-fuchsia-100: #28003d; --cpd-color-purple-1400: #eeebff; --cpd-color-purple-1300: #dedaff; */ - --cpd-color-purple-1200: #9a30fd!important; + --cpd-color-purple-1200: #9a30fd !important; /* --cpd-color-purple-1100: #ad9cfe; --cpd-color-purple-1000: #9e87fc; --cpd-color-purple-900: #9171f9; @@ -705,12 +706,12 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-purple-600: #4a0db1; --cpd-color-purple-500: #3d009e; --cpd-color-purple-400: #2c0080; */ - --cpd-color-purple-300: #443f4c!important; + --cpd-color-purple-300: #443f4c !important; /* --cpd-color-purple-200: #1c005a; --cpd-color-purple-100: #1a0055; --cpd-color-blue-1400: #e4eefe; --cpd-color-blue-1300: #cbdffc; */ - --cpd-color-blue-1200: #006aff!important; + --cpd-color-blue-1200: #006aff !important; /* --cpd-color-blue-1100: #7aacf4; --cpd-color-blue-1000: #5e99f0; --cpd-color-blue-900: #4187eb; @@ -719,12 +720,12 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-blue-600: #083891; --cpd-color-blue-500: #062d80; --cpd-color-blue-400: #001e6f; */ - --cpd-color-blue-300: #414852!important; + --cpd-color-blue-300: #414852 !important; /* --cpd-color-blue-200: #00095d; --cpd-color-blue-100: #00055a; --cpd-color-cyan-1400: #dbf2f5; --cpd-color-cyan-1300: #b8e5eb; */ - --cpd-color-cyan-1200: #08eaff!important; + --cpd-color-cyan-1200: #08eaff !important; /* --cpd-color-cyan-1100: #21bacd; --cpd-color-cyan-1000: #02a7c6; --cpd-color-cyan-900: #0093be; @@ -733,26 +734,26 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-cyan-600: #003f75; --cpd-color-cyan-500: #003468; --cpd-color-cyan-400: #002559; */ - --cpd-color-cyan-300: #374445!important; + --cpd-color-cyan-300: #374445 !important; /* --cpd-color-cyan-200: #001448; --cpd-color-cyan-100: #001144; --cpd-color-green-1400: #d9f4e7; --cpd-color-green-1300: #b5e8d1; */ - --cpd-color-green-1200: #00ff80!important; + --cpd-color-green-1200: #00ff80 !important; /* --cpd-color-green-1100: #1fc090; --cpd-color-green-1000: #17ac84; */ - --cpd-color-green-900: #00ff80!important; + --cpd-color-green-900: #00ff80 !important; /* --cpd-color-green-800: #007a62; --cpd-color-green-700: #005a43; --cpd-color-green-600: #004832; --cpd-color-green-500: #003d29; */ - --cpd-color-green-400: #3f4d46!important; - --cpd-color-green-300: #3f4d46!important; + --cpd-color-green-400: #3f4d46 !important; + --cpd-color-green-300: #3f4d46 !important; /* --cpd-color-green-200: #001f0e; --cpd-color-green-100: #001c0b; --cpd-color-lime-1400: #daf6d0; --cpd-color-lime-1300: #b6eca3; */ - --cpd-color-lime-1200: #48ff00!important; + --cpd-color-lime-1200: #48ff00 !important; /* --cpd-color-lime-1100: #56c02c; --cpd-color-lime-1000: #47ad26; --cpd-color-lime-900: #389b20; @@ -761,12 +762,12 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-lime-600: #004a00; --cpd-color-lime-500: #003e00; --cpd-color-lime-400: #003000; */ - --cpd-color-lime-300: #414d3c!important; + --cpd-color-lime-300: #414d3c !important; /* --cpd-color-lime-200: #002000; --cpd-color-lime-100: #001b00; --cpd-color-yellow-1400: #ffedb1; --cpd-color-yellow-1300: #fef358; */ - --cpd-color-yellow-1200: #ffe100!important; + --cpd-color-yellow-1200: #ffe100 !important; /* --cpd-color-yellow-1100: #db9f00; --cpd-color-yellow-1000: #cc8c00; --cpd-color-yellow-900: #bc7a00; @@ -775,12 +776,12 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-yellow-600: #682e03; --cpd-color-yellow-500: #5c2400; --cpd-color-yellow-400: #4c1400; */ - --cpd-color-yellow-300: #514e3e!important; + --cpd-color-yellow-300: #514e3e !important; /* --cpd-color-yellow-200: #3a0300; --cpd-color-yellow-100: #360000; --cpd-color-orange-1400: #ffeadb; --cpd-color-orange-1300: #ffd5b9; */ - --cpd-color-orange-1200: #ff7700!important; + --cpd-color-orange-1200: #ff7700 !important; /* --cpd-color-orange-1100: #f6913d; --cpd-color-orange-1000: #eb7a12; --cpd-color-orange-900: #da670d; @@ -789,12 +790,12 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-orange-600: #830500; --cpd-color-orange-500: #710000; --cpd-color-orange-400: #580000; */ - --cpd-color-orange-300: #574c42!important; + --cpd-color-orange-300: #574c42 !important; /* --cpd-color-orange-200: #3c0000; --cpd-color-orange-100: #380000; --cpd-color-red-1400: #ffe9e6; --cpd-color-red-1300: #ffd4cd; */ - --cpd-color-red-1200: #ff2600!important; + --cpd-color-red-1200: #ff2600 !important; /* --cpd-color-red-1100: #ff877c; --cpd-color-red-1000: #ff665d; --cpd-color-red-900: #fd3e3c; @@ -803,7 +804,7 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-red-600: #830009; --cpd-color-red-500: #710000; --cpd-color-red-400: #590000; */ - --cpd-color-red-300: #51423f!important; + --cpd-color-red-300: #51423f !important; /* --cpd-color-red-200: #3e0000; --cpd-color-red-100: #370000; --cpd-color-gray-1400: #ebeef2; @@ -820,5 +821,4 @@ h2 .sh_RoomTokenGatedRoomIcon { --cpd-color-gray-300: #1d1f24; --cpd-color-gray-200: #181a1f; --cpd-color-gray-100: #14171b; */ - } - \ No newline at end of file +} diff --git a/res/themes/superhero/img/icons/verified.svg b/res/themes/superhero/img/icons/verified.svg new file mode 100644 index 0000000000..5ad577ad9a --- /dev/null +++ b/res/themes/superhero/img/icons/verified.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/atoms.ts b/src/atoms.ts new file mode 100644 index 0000000000..69b907e800 --- /dev/null +++ b/src/atoms.ts @@ -0,0 +1,3 @@ +import { atomWithStorage } from "jotai/utils"; + +export const verifiedAccountsAtom = atomWithStorage>("VERIFIED_ACCOUNTS", {}); diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx new file mode 100644 index 0000000000..9ed31af258 --- /dev/null +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -0,0 +1,1586 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* +Copyright 2019 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { createRef, ReactNode, SyntheticEvent } from "react"; +import classNames from "classnames"; +import { RoomMember, Room, MatrixError, EventType } from "matrix-js-sdk/src/matrix"; +import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { logger } from "matrix-js-sdk/src/logger"; +import { uniqBy } from "lodash"; +import { Icon as InfoIcon } from "matrix-react-sdk/res/img/element-icons/info.svg"; +import { Icon as EmailPillAvatarIcon } from "matrix-react-sdk/res/img/icon-email-pill-avatar.svg"; +import { _t, _td } from "matrix-react-sdk/src/languageHandler"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import { makeRoomPermalink, makeUserPermalink } from "matrix-react-sdk/src/utils/permalinks/Permalinks"; +import DMRoomMap from "matrix-react-sdk/src/utils/DMRoomMap"; +import SdkConfig from "matrix-react-sdk/src/SdkConfig"; +import * as Email from "matrix-react-sdk/src/email"; +import { + getDefaultIdentityServerUrl, + setToDefaultIdentityServer, +} from "matrix-react-sdk/src/utils/IdentityServerUtils"; +import { buildActivityScores, buildMemberScores, compareMembers } from "matrix-react-sdk/src/utils/SortMembers"; +import { abbreviateUrl } from "matrix-react-sdk/src/utils/UrlUtils"; +import IdentityAuthClient from "matrix-react-sdk/src/IdentityAuthClient"; +import { humanizeTime } from "matrix-react-sdk/src/utils/humanize"; +import { IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "matrix-react-sdk/src/RoomInvite"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import { DefaultTagID } from "matrix-react-sdk/src/stores/room-list/models"; +import RoomListStore from "matrix-react-sdk/src/stores/room-list/RoomListStore"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { UIFeature } from "matrix-react-sdk/src/settings/UIFeature"; +import { mediaFromMxc } from "matrix-react-sdk/src/customisations/Media"; +import BaseAvatar from "matrix-react-sdk/src/components/views/avatars/BaseAvatar"; +import { SearchResultAvatar } from "matrix-react-sdk/src/components/views/avatars/SearchResultAvatar"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import { selectText } from "matrix-react-sdk/src/utils/strings"; +import Field from "matrix-react-sdk/src/components/views/elements/Field"; +import TabbedView, { Tab, TabLocation } from "matrix-react-sdk/src/components/structures/TabbedView"; +import Dialpad from "matrix-react-sdk/src/components/views/voip/DialPad"; +import QuestionDialog from "matrix-react-sdk/src/components/views/dialogs/QuestionDialog"; +import Spinner from "matrix-react-sdk/src/components/views/elements/Spinner"; +import BaseDialog from "matrix-react-sdk/src/components/views/dialogs/BaseDialog"; +import DialPadBackspaceButton from "matrix-react-sdk/src/components/views/elements/DialPadBackspaceButton"; +import LegacyCallHandler from "matrix-react-sdk/src/LegacyCallHandler"; +import UserIdentifierCustomisations from "matrix-react-sdk/src/customisations/UserIdentifier"; +import CopyableText from "matrix-react-sdk/src/components/views/elements/CopyableText"; +import { ScreenName } from "matrix-react-sdk/src/PosthogTrackers"; +import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager"; +import { + DirectoryMember, + IDMUserTileProps, + Member, + startDmOnFirstMessage, + ThreepidMember, +} from "matrix-react-sdk/src/utils/direct-messages"; +import { InviteKind } from "matrix-react-sdk/src/components/views/dialogs/InviteDialogTypes"; +import Modal from "matrix-react-sdk/src/Modal"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { privateShouldBeEncrypted } from "matrix-react-sdk/src/utils/rooms"; +import { NonEmptyArray } from "matrix-react-sdk/src/@types/common"; +import { UNKNOWN_PROFILE_ERRORS } from "matrix-react-sdk/src/utils/MultiInviter"; +import AskInviteAnywayDialog, { + UnknownProfiles, +} from "matrix-react-sdk/src/components/views/dialogs/AskInviteAnywayDialog"; +import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext"; +import { UserProfilesStore } from "matrix-react-sdk/src/stores/UserProfilesStore"; + +import { UserVerifiedBadge } from "../elements/UserVerifiedBadge"; + +// we have a number of types defined from the Matrix spec which can't reasonably be altered here. +/* eslint-disable camelcase */ + +const extractTargetUnknownProfiles = async ( + targets: Member[], + profilesStores: UserProfilesStore, +): Promise => { + const directoryMembers = targets.filter((t): t is DirectoryMember => t instanceof DirectoryMember); + await Promise.all(directoryMembers.map((t) => profilesStores.getOrFetchProfile(t.userId))); + return directoryMembers.reduce((unknownProfiles: UnknownProfiles, target: DirectoryMember) => { + const lookupError = profilesStores.getProfileLookupError(target.userId); + + if ( + lookupError instanceof MatrixError && + lookupError.errcode && + UNKNOWN_PROFILE_ERRORS.includes(lookupError.errcode) + ) { + unknownProfiles.push({ + userId: target.userId, + errorText: lookupError.data.error || "", + }); + } + + return unknownProfiles; + }, []); +}; + +interface Result { + userId: string; + user: Member; + lastActive?: number; +} + +const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first +const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked + +enum TabId { + UserDirectory = "users", + DialPad = "dialpad", +} + +class DMUserTile extends React.PureComponent { + private onRemove = (e: ButtonEvent): void => { + // Stop the browser from highlighting text + e.preventDefault(); + e.stopPropagation(); + + this.props.onRemove!(this.props.member); + }; + + public render(): React.ReactNode { + const avatarSize = "20px"; + const avatar = ; + + let closeButton; + if (this.props.onRemove) { + closeButton = ( + + {_t("action|remove")} + + ); + } + + return ( + + + {avatar} + {this.props.member.name} + + {closeButton} + + ); + } +} + +/** + * Converts a RoomMember to a Member. + * Returns the Member if it is already a Member. + */ +const toMember = (member: RoomMember | Member): Member => { + return member instanceof RoomMember + ? new DirectoryMember({ + user_id: member.userId, + display_name: member.name, + avatar_url: member.getMxcAvatarUrl(), + }) + : member; +}; + +interface IDMRoomTileProps { + member: Member; + lastActiveTs?: number; + onToggle(member: Member): void; + highlightWord: string; + isSelected: boolean; +} + +class DMRoomTile extends React.PureComponent { + private onClick = (e: ButtonEvent): void => { + // Stop the browser from highlighting text + e.preventDefault(); + e.stopPropagation(); + + this.props.onToggle(this.props.member); + }; + + private highlightName(str: string): ReactNode { + if (!this.props.highlightWord) return str; + + // We convert things to lowercase for index searching, but pull substrings from + // the submitted text to preserve case. Note: we don't need to htmlEntities the + // string because React will safely encode the text for us. + const lowerStr = str.toLowerCase(); + const filterStr = this.props.highlightWord.toLowerCase(); + + const result: JSX.Element[] = []; + + let i = 0; + let ii: number; + while ((ii = lowerStr.indexOf(filterStr, i)) >= 0) { + // Push any text we missed (first bit/middle of text) + if (ii > i) { + // Push any text we aren't highlighting (middle of text match, or beginning of text) + result.push({str.substring(i, ii)}); + } + + i = ii; // copy over ii only if we have a match (to preserve i for end-of-text matching) + + // Highlight the word the user entered + const substr = str.substring(i, filterStr.length + i); + result.push( + + {substr} + , + ); + i += substr.length; + } + + // Push any text we missed (end of text) + if (i < str.length) { + result.push({str.substring(i)}); + } + + return result; + } + + public render(): React.ReactNode { + let timestamp: JSX.Element | undefined; + if (this.props.lastActiveTs) { + const humanTs = humanizeTime(this.props.lastActiveTs); + timestamp = {humanTs}; + } + + const avatarSize = "36px"; + const avatar = (this.props.member as ThreepidMember).isEmail ? ( + + ) : ( + + ); + + let checkmark: JSX.Element | undefined; + if (this.props.isSelected) { + // To reduce flickering we put the 'selected' room tile above the real avatar + checkmark =
; + } + + // To reduce flickering we put the checkmark on top of the actual avatar (prevents + // the browser from reloading the image source when the avatar remounts). + const stackedAvatar = ( + + {avatar} + {checkmark} + + ); + + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, { + withDisplayName: true, + }); + + const caption = (this.props.member as ThreepidMember).isEmail + ? _t("invite|email_caption") + : this.highlightName(userIdentifier || this.props.member.userId); + + return ( + + {stackedAvatar} + +
+ {this.highlightName(this.props.member.name)} + +
+
{caption}
+
+ {timestamp} +
+ ); + } +} + +interface BaseProps { + // Takes a boolean which is true if a user / users were invited / + // a call transfer was initiated or false if the dialog was cancelled + // with no action taken. + onFinished: (success?: boolean) => void; + + // Initial value to populate the filter with + initialText?: string; +} + +interface InviteDMProps extends BaseProps { + // The kind of invite being performed. Assumed to be InviteKind.Dm if not provided. + kind?: InviteKind.Dm; +} + +interface InviteRoomProps extends BaseProps { + kind: InviteKind.Invite; + + // The room ID this dialog is for. Only required for InviteKind.Invite. + roomId: string; +} + +function isRoomInvite(props: Props): props is InviteRoomProps { + return props.kind === InviteKind.Invite; +} + +interface InviteCallProps extends BaseProps { + kind: InviteKind.CallTransfer; + + // The call to transfer. Only required for InviteKind.CallTransfer. + call: MatrixCall; +} + +type Props = InviteDMProps | InviteRoomProps | InviteCallProps; + +interface IInviteDialogState { + targets: Member[]; // array of Member objects (see interface above) + filterText: string; + recents: Result[]; + numRecentsShown: number; + suggestions: Result[]; + numSuggestionsShown: number; + serverResultsMixin: Result[]; + threepidResultsMixin: Result[]; + canUseIdentityServer: boolean; + tryingIdentityServer: boolean; + consultFirst: boolean; + dialPadValue: string; + currentTabId: TabId; + + // These two flags are used for the 'Go' button to communicate what is going on. + busy: boolean; + errorText?: string; +} + +export default class InviteDialog extends React.PureComponent { + public static defaultProps: Partial = { + kind: InviteKind.Dm, + initialText: "", + }; + + private debounceTimer: number | null = null; // actually number because we're in the browser + private editorRef = createRef(); + private numberEntryFieldRef: React.RefObject = createRef(); + private unmounted = false; + private encryptionByDefault = false; + private profilesStore: UserProfilesStore; + + public constructor(props: Props) { + super(props); + + if (props.kind === InviteKind.Invite && !props.roomId) { + throw new Error("When using InviteKind.Invite a roomId is required for an InviteDialog"); + } else if (props.kind === InviteKind.CallTransfer && !props.call) { + throw new Error("When using InviteKind.CallTransfer a call is required for an InviteDialog"); + } + + this.profilesStore = SdkContextClass.instance.userProfilesStore; + + const excludedIds = new Set([MatrixClientPeg.safeGet().getUserId()!]); + const welcomeUserId = SdkConfig.get("welcome_user_id"); + if (welcomeUserId) excludedIds.add(welcomeUserId); + + if (isRoomInvite(props)) { + const room = MatrixClientPeg.safeGet().getRoom(props.roomId); + const isFederated = room?.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent()["m.federate"]; + if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); + room.getMembersWithMembership("invite").forEach((m) => excludedIds.add(m.userId)); + room.getMembersWithMembership("join").forEach((m) => excludedIds.add(m.userId)); + // add banned users, so we don't try to invite them + room.getMembersWithMembership("ban").forEach((m) => excludedIds.add(m.userId)); + if (isFederated === false) { + // exclude users from external servers + const homeserver = props.roomId.split(":")[1]; + this.excludeExternals(homeserver, excludedIds); + } + } + + this.state = { + targets: [], // array of Member objects (see interface above) + filterText: this.props.initialText || "", + // Mutates alreadyInvited set so that buildSuggestions doesn't duplicate any users + recents: InviteDialog.buildRecents(excludedIds), + numRecentsShown: INITIAL_ROOMS_SHOWN, + suggestions: this.buildSuggestions(excludedIds), + numSuggestionsShown: INITIAL_ROOMS_SHOWN, + serverResultsMixin: [], + threepidResultsMixin: [], + canUseIdentityServer: !!MatrixClientPeg.safeGet().getIdentityServerUrl(), + tryingIdentityServer: false, + consultFirst: false, + dialPadValue: "", + currentTabId: TabId.UserDirectory, + + // These two flags are used for the 'Go' button to communicate what is going on. + busy: false, + }; + } + + public componentDidMount(): void { + this.encryptionByDefault = privateShouldBeEncrypted(MatrixClientPeg.safeGet()); + + if (this.props.initialText) { + this.updateSuggestions(this.props.initialText); + } + } + + public componentWillUnmount(): void { + this.unmounted = true; + } + + private onConsultFirstChange = (ev: React.ChangeEvent): void => { + this.setState({ consultFirst: ev.target.checked }); + }; + + private excludeExternals(homeserver: string, excludedTargetIds: Set): void { + const client = MatrixClientPeg.safeGet(); + // users with room membership + const members = Object.values(buildMemberScores(client)).map(({ member }) => member.userId); + // users with dm membership + const roomMembers = Object.keys(DMRoomMap.shared().getUniqueRoomsWithIndividuals()); + roomMembers.forEach((id) => members.push(id)); + // filter duplicates and user IDs from external servers + const externals = new Set(members.filter((id) => !id.includes(homeserver))); + externals.forEach((id) => excludedTargetIds.add(id)); + } + + public static buildRecents(excludedTargetIds: Set): Result[] { + const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room + + // Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the + // room list doesn't tag the room for the DMRoomMap, but does for the room list. + const dmTaggedRooms = RoomListStore.instance.orderedLists[DefaultTagID.DM] || []; + const myUserId = MatrixClientPeg.safeGet().getUserId(); + for (const dmRoom of dmTaggedRooms) { + const otherMembers = dmRoom.getJoinedMembers().filter((u) => u.userId !== myUserId); + for (const member of otherMembers) { + if (rooms[member.userId]) continue; // already have a room + + logger.warn(`Adding DM room for ${member.userId} as ${dmRoom.roomId} from tag, not DM map`); + rooms[member.userId] = dmRoom; + } + } + + const recents: { + userId: string; + user: Member; + lastActive: number; + }[] = []; + + for (const userId in rooms) { + // Filter out user IDs that are already in the room / should be excluded + if (excludedTargetIds.has(userId)) { + logger.warn(`[Invite:Recents] Excluding ${userId} from recents`); + continue; + } + + const room = rooms[userId]; + const roomMember = room.getMember(userId); + if (!roomMember) { + // just skip people who don't have memberships for some reason + logger.warn(`[Invite:Recents] ${userId} is missing a member object in their own DM (${room.roomId})`); + continue; + } + + // Find the last timestamp for a message event + const searchTypes = ["m.room.message", "m.room.encrypted", "m.sticker"]; + const maxSearchEvents = 20; // to prevent traversing history + let lastEventTs = 0; + if (room.timeline && room.timeline.length) { + for (let i = room.timeline.length - 1; i >= 0; i--) { + const ev = room.timeline[i]; + if (searchTypes.includes(ev.getType())) { + lastEventTs = ev.getTs(); + break; + } + if (room.timeline.length - i > maxSearchEvents) break; + } + } + if (!lastEventTs) { + // something weird is going on with this room + logger.warn(`[Invite:Recents] ${userId} (${room.roomId}) has a weird last timestamp: ${lastEventTs}`); + continue; + } + + recents.push({ userId, user: toMember(roomMember), lastActive: lastEventTs }); + // We mutate the given set so that any later callers avoid duplicating these users + excludedTargetIds.add(userId); + } + if (!recents) logger.warn("[Invite:Recents] No recents to suggest!"); + + // Sort the recents by last active to save us time later + recents.sort((a, b) => b.lastActive - a.lastActive); + + return recents; + } + + private buildSuggestions(excludedTargetIds: Set): { userId: string; user: Member }[] { + const cli = MatrixClientPeg.safeGet(); + const activityScores = buildActivityScores(cli); + const memberScores = buildMemberScores(cli); + + const memberComparator = compareMembers(activityScores, memberScores); + + return Object.values(memberScores) + .map(({ member }) => member) + .filter((member) => !excludedTargetIds.has(member.userId)) + .sort(memberComparator) + .map((member) => ({ userId: member.userId, user: toMember(member) })); + } + + private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean { + this.setState({ busy: false }); + const userMap = new Map(this.state.targets.map((member) => [member.userId, member])); + return !showAnyInviteErrors(result.states, room, result.inviter, userMap); + } + + private convertFilter(): Member[] { + // Check to see if there's anything to convert first + if (!this.state.filterText || !this.state.filterText.includes("@")) return this.state.targets || []; + + if (!this.canInviteMore()) { + // There should only be one third-party invite → do not allow more targets + return this.state.targets; + } + + let newMember: Member | undefined; + if (this.state.filterText.startsWith("@")) { + // Assume mxid + newMember = new DirectoryMember({ user_id: this.state.filterText }); + } else if (SettingsStore.getValue(UIFeature.IdentityServer)) { + // Assume email + if (this.canInviteThirdParty()) { + newMember = new ThreepidMember(this.state.filterText); + } + } + if (!newMember) return this.state.targets; + + const newTargets = [...(this.state.targets || []), newMember]; + this.setState({ targets: newTargets, filterText: "" }); + return newTargets; + } + + /** + * Check if there are unknown profiles if promptBeforeInviteUnknownUsers setting is enabled. + * If so show the "invite anyway?" dialog. Otherwise directly create the DM local room. + */ + private checkProfileAndStartDm = async (): Promise => { + this.setBusy(true); + const targets = this.convertFilter(); + + if (SettingsStore.getValue("promptBeforeInviteUnknownUsers")) { + const unknownProfileUsers = await extractTargetUnknownProfiles(targets, this.profilesStore); + + if (unknownProfileUsers.length) { + this.showAskInviteAnywayDialog(unknownProfileUsers); + return; + } + } + + await this.startDm(); + }; + + private startDm = async (): Promise => { + this.setBusy(true); + + try { + const cli = MatrixClientPeg.safeGet(); + const targets = this.convertFilter(); + await startDmOnFirstMessage(cli, targets); + this.props.onFinished(true); + } catch (err) { + logger.error(err); + this.setState({ + busy: false, + errorText: _t("invite|error_dm"), + }); + } + }; + + private setBusy(busy: boolean): void { + this.setState({ + busy, + }); + } + + private showAskInviteAnywayDialog(unknownProfileUsers: { userId: string; errorText: string }[]): void { + Modal.createDialog(AskInviteAnywayDialog, { + unknownProfileUsers, + onInviteAnyways: () => this.startDm(), + onGiveUp: () => { + this.setBusy(false); + }, + description: _t("invite|ask_anyway_description"), + inviteNeverWarnLabel: _t("invite|ask_anyway_never_warn_label"), + inviteLabel: _t("invite|ask_anyway_label"), + }); + } + + private inviteUsers = async (): Promise => { + if (this.props.kind !== InviteKind.Invite) return; + this.setState({ busy: true }); + this.convertFilter(); + const targets = this.convertFilter(); + const targetIds = targets.map((t) => t.userId); + + const cli = MatrixClientPeg.safeGet(); + const room = cli.getRoom(this.props.roomId); + if (!room) { + logger.error("Failed to find the room to invite users to"); + this.setState({ + busy: false, + errorText: _t("invite|error_find_room"), + }); + return; + } + + try { + const result = await inviteMultipleToRoom(cli, this.props.roomId, targetIds, true); + if (!this.shouldAbortAfterInviteError(result, room)) { + // handles setting error message too + this.props.onFinished(true); + } + } catch (err) { + logger.error(err); + this.setState({ + busy: false, + errorText: _t("invite|error_invite"), + }); + } + }; + + private transferCall = async (): Promise => { + if (this.props.kind !== InviteKind.CallTransfer) return; + if (this.state.currentTabId == TabId.UserDirectory) { + this.convertFilter(); + const targets = this.convertFilter(); + const targetIds = targets.map((t) => t.userId); + if (targetIds.length > 1) { + this.setState({ + errorText: _t("invite|error_transfer_multiple_target"), + }); + return; + } + + LegacyCallHandler.instance.startTransferToMatrixID(this.props.call, targetIds[0], this.state.consultFirst); + } else { + LegacyCallHandler.instance.startTransferToPhoneNumber( + this.props.call, + this.state.dialPadValue, + this.state.consultFirst, + ); + } + this.props.onFinished(true); + }; + + private onKeyDown = (e: React.KeyboardEvent): void => { + if (this.state.busy) return; + + let handled = false; + const value = e.currentTarget.value.trim(); + const action = getKeyBindingsManager().getAccessibilityAction(e); + + switch (action) { + case KeyBindingAction.Backspace: + if (value || this.state.targets.length <= 0) break; + + // when the field is empty and the user hits backspace remove the right-most target + this.removeMember(this.state.targets[this.state.targets.length - 1]); + handled = true; + break; + case KeyBindingAction.Space: + if (!value || !value.includes("@") || value.includes(" ")) break; + + // when the user hits space and their input looks like an e-mail/MXID then try to convert it + this.convertFilter(); + handled = true; + break; + case KeyBindingAction.Enter: + if (!value) break; + + // when the user hits enter with something in their field try to convert it + this.convertFilter(); + handled = true; + break; + } + + if (handled) { + e.preventDefault(); + } + }; + + private onCancel = (): void => { + this.props.onFinished(false); + }; + + private updateSuggestions = async (term: string): Promise => { + MatrixClientPeg.safeGet() + .searchUserDirectory({ term }) + .then(async (r): Promise => { + if (term !== this.state.filterText) { + // Discard the results - we were probably too slow on the server-side to make + // these results useful. This is a race we want to avoid because we could overwrite + // more accurate results. + return; + } + + if (!r.results) r.results = []; + + // While we're here, try and autocomplete a search result for the mxid itself + // if there's no matches (and the input looks like a mxid). + if (term[0] === "@" && term.indexOf(":") > 1) { + try { + const profile = await this.profilesStore.getOrFetchProfile(term, { shouldThrow: true }); + + if (profile) { + // If we have a profile, we have enough information to assume that + // the mxid can be invited - add it to the list. We stick it at the + // top so it is most obviously presented to the user. + r.results.splice(0, 0, { + user_id: term, + display_name: profile["displayname"], + avatar_url: profile["avatar_url"], + }); + } + } catch (e) { + logger.warn("Non-fatal error trying to make an invite for a user ID", e); + } + } + + this.setState({ + serverResultsMixin: r.results.map((u) => ({ + userId: u.user_id, + user: new DirectoryMember(u), + })), + }); + }) + .catch((e) => { + logger.error("Error searching user directory:"); + logger.error(e); + this.setState({ serverResultsMixin: [] }); // clear results because it's moderately fatal + }); + + // Whenever we search the directory, also try to search the identity server. It's + // all debounced the same anyways. + if (!this.state.canUseIdentityServer) { + // The user doesn't have an identity server set - warn them of that. + this.setState({ tryingIdentityServer: true }); + return; + } + if (Email.looksValid(term) && this.canInviteThirdParty() && SettingsStore.getValue(UIFeature.IdentityServer)) { + // Start off by suggesting the plain email while we try and resolve it + // to a real account. + this.setState({ + // per above: the userId is a lie here - it's just a regular identifier + threepidResultsMixin: [{ user: new ThreepidMember(term), userId: term }], + }); + try { + const authClient = new IdentityAuthClient(); + const token = await authClient.getAccessToken(); + // No token → unable to try a lookup + if (!token) return; + + if (term !== this.state.filterText) return; // abandon hope + + const lookup = await MatrixClientPeg.safeGet().lookupThreePid("email", term, token); + if (term !== this.state.filterText) return; // abandon hope + + if (!lookup || !("mxid" in lookup)) { + // We weren't able to find anyone - we're already suggesting the plain email + // as an alternative, so do nothing. + return; + } + + // We append the user suggestion to give the user an option to click + // the email anyways, and so we don't cause things to jump around. In + // theory, the user would see the user pop up and think "ah yes, that + // person!" + const profile = await this.profilesStore.getOrFetchProfile(lookup.mxid); + if (term !== this.state.filterText || !profile) return; // abandon hope + this.setState({ + threepidResultsMixin: [ + ...this.state.threepidResultsMixin, + { + user: new DirectoryMember({ + user_id: lookup.mxid, + display_name: profile.displayname, + avatar_url: profile.avatar_url, + }), + // Use the search term as identifier, so that it shows up in suggestions. + userId: term, + }, + ], + }); + } catch (e) { + logger.error("Error searching identity server:"); + logger.error(e); + this.setState({ threepidResultsMixin: [] }); // clear results because it's moderately fatal + } + } + }; + + private updateFilter = (e: React.ChangeEvent): void => { + const term = e.target.value; + this.setState({ filterText: term }); + + // Debounce server lookups to reduce spam. We don't clear the existing server + // results because they might still be vaguely accurate, likewise for races which + // could happen here. + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.updateSuggestions(term); + }, 150); // 150ms debounce (human reaction time + some) + }; + + private showMoreRecents = (): void => { + this.setState({ numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN }); + }; + + private showMoreSuggestions = (): void => { + this.setState({ numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN }); + }; + + private toggleMember = (member: Member): void => { + if (!this.state.busy) { + let filterText = this.state.filterText; + let targets = this.state.targets.map((t) => t); // cheap clone for mutation + const idx = targets.findIndex((m) => m.userId === member.userId); + if (idx >= 0) { + targets.splice(idx, 1); + } else { + if (this.props.kind === InviteKind.CallTransfer && targets.length > 0) { + targets = []; + } + targets.push(member); + filterText = ""; // clear the filter when the user accepts a suggestion + } + this.setState({ targets, filterText }); + + if (this.editorRef && this.editorRef.current) { + this.editorRef.current.focus(); + } + } + }; + + private removeMember = (member: Member): void => { + const targets = this.state.targets.map((t) => t); // cheap clone for mutation + const idx = targets.indexOf(member); + if (idx >= 0) { + targets.splice(idx, 1); + this.setState({ targets }); + } + + if (this.editorRef && this.editorRef.current) { + this.editorRef.current.focus(); + } + }; + + private parseFilter(filter: string): string[] { + return filter + .split(/[\s,]+/) + .map((p) => p.trim()) + .filter((p) => !!p); // filter empty strings + } + + private onPaste = async (e: React.ClipboardEvent): Promise => { + if (this.state.filterText) { + // if the user has already typed something, just let them + // paste normally. + return; + } + + const text = e.clipboardData.getData("text"); + const potentialAddresses = this.parseFilter(text); + // one search term which is not a mxid or email address + if (potentialAddresses.length === 1 && !potentialAddresses[0].includes("@")) { + return; + } + + // Prevent the text being pasted into the input + e.preventDefault(); + + // Process it as a list of addresses to add instead + const possibleMembers = [ + // If we can avoid hitting the profile endpoint, we should. + ...this.state.recents, + ...this.state.suggestions, + ...this.state.serverResultsMixin, + ...this.state.threepidResultsMixin, + ]; + const toAdd: Member[] = []; + const failed: string[] = []; + + // Addresses that could not be added. + // Will be displayed as filter text to provide feedback. + const unableToAddMore: string[] = []; + + for (const address of potentialAddresses) { + const member = possibleMembers.find((m) => m.userId === address); + if (member) { + if (this.canInviteMore([...this.state.targets, ...toAdd])) { + toAdd.push(member.user); + } else { + // Invite not possible for current targets and pasted targets. + unableToAddMore.push(address); + } + continue; + } + + if (Email.looksValid(address)) { + if (this.canInviteThirdParty([...this.state.targets, ...toAdd])) { + toAdd.push(new ThreepidMember(address)); + } else { + // Third-party invite not possible for current targets and pasted targets. + unableToAddMore.push(address); + } + continue; + } + + if (address[0] !== "@") { + failed.push(address); // not a user ID + continue; + } + + try { + const profile = await this.profilesStore.getOrFetchProfile(address); + toAdd.push( + new DirectoryMember({ + user_id: address, + display_name: profile?.displayname, + avatar_url: profile?.avatar_url, + }), + ); + } catch (e) { + logger.error("Error looking up profile for " + address); + logger.error(e); + failed.push(address); + } + } + if (this.unmounted) return; + + if (failed.length > 0) { + Modal.createDialog(QuestionDialog, { + title: _t("invite|error_find_user_title"), + description: _t("invite|error_find_user_description", { csvNames: failed.join(", ") }), + button: _t("action|ok"), + }); + } + + if (unableToAddMore) { + this.setState({ + filterText: unableToAddMore.join(" "), + targets: uniqBy([...this.state.targets, ...toAdd], (t) => t.userId), + }); + } else { + this.setState({ + targets: uniqBy([...this.state.targets, ...toAdd], (t) => t.userId), + }); + } + }; + + private onClickInputArea = (e: React.MouseEvent): void => { + // Stop the browser from highlighting text + e.preventDefault(); + e.stopPropagation(); + + if (this.editorRef && this.editorRef.current) { + this.editorRef.current.focus(); + } + }; + + private onUseDefaultIdentityServerClick = (e: ButtonEvent): void => { + e.preventDefault(); + + // Update the IS in account data. Actually using it may trigger terms. + // eslint-disable-next-line react-hooks/rules-of-hooks + setToDefaultIdentityServer(MatrixClientPeg.safeGet()); + this.setState({ canUseIdentityServer: true, tryingIdentityServer: false }); + }; + + private onManageSettingsClick = (e: ButtonEvent): void => { + e.preventDefault(); + dis.fire(Action.ViewUserSettings); + this.props.onFinished(false); + }; + + private renderSection(kind: "recents" | "suggestions"): ReactNode { + let sourceMembers = kind === "recents" ? this.state.recents : this.state.suggestions; + let showNum = kind === "recents" ? this.state.numRecentsShown : this.state.numSuggestionsShown; + const showMoreFn = kind === "recents" ? this.showMoreRecents.bind(this) : this.showMoreSuggestions.bind(this); + const lastActive = (m: Result): number | undefined => (kind === "recents" ? m.lastActive : undefined); + let sectionName = kind === "recents" ? _t("invite|recents_section") : _t("common|suggestions"); + + if (this.props.kind === InviteKind.Invite) { + sectionName = kind === "recents" ? _t("invite|suggestions_section") : _t("common|suggestions"); + } + + // Mix in the server results if we have any, but only if we're searching. We track the additional + // members separately because we want to filter sourceMembers but trust the mixin arrays to have + // the right members in them. + let priorityAdditionalMembers: Result[] = []; // Shows up before our own suggestions, higher quality + let otherAdditionalMembers: Result[] = []; // Shows up after our own suggestions, lower quality + const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin; + if (this.state.filterText && hasMixins && kind === "suggestions") { + // We don't want to duplicate members though, so just exclude anyone we've already seen. + // The type of u is a pain to define but members of both mixins have the 'userId' property + const notAlreadyExists = (u: any): boolean => { + return ( + !this.state.recents.some((m) => m.userId === u.userId) && + !sourceMembers.some((m) => m.userId === u.userId) && + !priorityAdditionalMembers.some((m) => m.userId === u.userId) && + !otherAdditionalMembers.some((m) => m.userId === u.userId) + ); + }; + + otherAdditionalMembers = this.state.serverResultsMixin.filter(notAlreadyExists); + priorityAdditionalMembers = this.state.threepidResultsMixin.filter(notAlreadyExists); + } + const hasAdditionalMembers = priorityAdditionalMembers.length > 0 || otherAdditionalMembers.length > 0; + + // Hide the section if there's nothing to filter by + if (sourceMembers.length === 0 && !hasAdditionalMembers) return null; + + if (!this.canInviteThirdParty()) { + // It is currently not allowed to add more third-party invites. Filter them out. + priorityAdditionalMembers = priorityAdditionalMembers.filter((s) => s instanceof ThreepidMember); + } + + // Do some simple filtering on the input before going much further. If we get no results, say so. + if (this.state.filterText) { + const filterBy = this.state.filterText.toLowerCase(); + sourceMembers = sourceMembers.filter( + (m) => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy), + ); + + if (sourceMembers.length === 0 && !hasAdditionalMembers) { + return ( +
+

{sectionName}

+

{_t("common|no_results")}

+
+ ); + } + } + + // Now we mix in the additional members. Again, we presume these have already been filtered. We + // also assume they are more relevant than our suggestions and prepend them to the list. + sourceMembers = [...priorityAdditionalMembers, ...sourceMembers, ...otherAdditionalMembers]; + + // If we're going to hide one member behind 'show more', just use up the space of the button + // with the member's tile instead. + if (showNum === sourceMembers.length - 1) showNum++; + + // .slice() will return an incomplete array but won't error on us if we go too far + const toRender = sourceMembers.slice(0, showNum); + const hasMore = toRender.length < sourceMembers.length; + + let showMore: JSX.Element | undefined; + if (hasMore) { + showMore = ( +
+ + {_t("common|show_more")} + +
+ ); + } + + const tiles = toRender.map((r) => ( + t.userId === r.userId)} + /> + )); + return ( +
+

{sectionName}

+ {tiles} + {showMore} +
+ ); + } + + private renderEditor(): JSX.Element { + const hasPlaceholder = + this.props.kind == InviteKind.CallTransfer && + this.state.targets.length === 0 && + this.state.filterText.length === 0; + const targets = this.state.targets.map((t) => ( + + )); + const input = ( + 0) + } + autoComplete="off" + placeholder={hasPlaceholder ? _t("action|search") : undefined} + data-testid="invite-dialog-input" + /> + ); + return ( +
+ {targets} + {input} +
+ ); + } + + private renderIdentityServerWarning(): ReactNode { + if ( + !this.state.tryingIdentityServer || + this.state.canUseIdentityServer || + !SettingsStore.getValue(UIFeature.IdentityServer) + ) { + return null; + } + + const defaultIdentityServerUrl = getDefaultIdentityServerUrl(); + if (defaultIdentityServerUrl) { + return ( +
+ {_t( + "invite|email_use_default_is", + { + defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), + }, + { + default: (sub) => ( + + {sub} + + ), + settings: (sub) => ( + + {sub} + + ), + }, + )} +
+ ); + } else { + return ( +
+ {_t( + "invite|email_use_is", + {}, + { + settings: (sub) => ( + + {sub} + + ), + }, + )} +
+ ); + } + } + + private onDialFormSubmit = (ev: SyntheticEvent): void => { + ev.preventDefault(); + this.transferCall(); + }; + + private onDialChange = (ev: React.ChangeEvent): void => { + this.setState({ dialPadValue: ev.currentTarget.value }); + }; + + private onDigitPress = (digit: string, ev: ButtonEvent): void => { + this.setState({ dialPadValue: this.state.dialPadValue + digit }); + + // Keep the number field focused so that keyboard entry is still available + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } + }; + + private onDeletePress = (ev: ButtonEvent): void => { + if (this.state.dialPadValue.length === 0) return; + this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) }); + + // Keep the number field focused so that keyboard entry is still available + // However, don't focus if this wasn't the result of directly clicking on the button, + // i.e someone using keyboard navigation. + if (ev.type === "click") { + this.numberEntryFieldRef.current?.focus(); + } + }; + + private onTabChange = (tabId: TabId): void => { + this.setState({ currentTabId: tabId }); + }; + + private async onLinkClick(e: React.MouseEvent): Promise { + e.preventDefault(); + selectText(e.currentTarget); + } + + private get screenName(): ScreenName | undefined { + switch (this.props.kind) { + case InviteKind.Dm: + return "StartChat"; + default: + return undefined; + } + } + + /** + * If encryption by default is enabled, third-party invites should be encrypted as well. + * For encryption to work, the other side requires a device. + * To achieve this Element implements a waiting room until all have joined. + * Waiting for many users degrades the UX → only one email invite is allowed at a time. + * + * @param targets - Optional member list to check. Uses targets from state if not provided. + */ + private canInviteMore(targets?: (Member | RoomMember)[]): boolean { + targets = targets || this.state.targets; + return this.canInviteThirdParty(targets) || !targets.some((t) => t instanceof ThreepidMember); + } + + /** + * A third-party invite is possible if + * - this is a non-DM dialog or + * - there are no invites yet or + * - encryption by default is not enabled + * + * Also see {@link InviteDialog#canInviteMore}. + * + * @param targets - Optional member list to check. Uses targets from state if not provided. + */ + private canInviteThirdParty(targets?: (Member | RoomMember)[]): boolean { + targets = targets || this.state.targets; + return this.props.kind !== InviteKind.Dm || targets.length === 0 || !this.encryptionByDefault; + } + + private hasFilterAtLeastOneEmail(): boolean { + if (!this.state.filterText) return false; + + return this.parseFilter(this.state.filterText).some((address: string) => { + return Email.looksValid(address); + }); + } + + public render(): React.ReactNode { + let spinner: JSX.Element | undefined; + if (this.state.busy) { + spinner = ; + } + + let title; + let helpText; + let buttonText; + let goButtonFn: (() => Promise) | null = null; + let consultConnectSection; + let extraSection; + let footer; + let keySharingWarning = ; + + const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); + + const hasSelection = + this.state.targets.length > 0 || (this.state.filterText && this.state.filterText.includes("@")); + + const cli = MatrixClientPeg.safeGet(); + const userId = cli.getUserId()!; + if (this.props.kind === InviteKind.Dm) { + title = _t("space|add_existing_room_space|dm_heading"); + + if (identityServersEnabled) { + helpText = _t( + "invite|start_conversation_name_email_mxid_prompt", + {}, + { + userId: () => { + return ( + + {userId} + + ); + }, + }, + ); + } else { + helpText = _t( + "invite|start_conversation_name_mxid_prompt", + {}, + { + userId: () => { + return ( + + {userId} + + ); + }, + }, + ); + } + + buttonText = _t("action|go"); + goButtonFn = this.checkProfileAndStartDm; + extraSection = ( +
+ {_t("invite|suggestions_disclaimer")} +

{_t("invite|suggestions_disclaimer_prompt")}

+
+ ); + const link = makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId()); + footer = ( +
+

{_t("invite|send_link_prompt")}

+ makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId())} + > + + {link} + + +
+ ); + } else if (this.props.kind === InviteKind.Invite) { + const roomId = this.props.roomId; + const room = MatrixClientPeg.get()?.getRoom(roomId); + const isSpace = room?.isSpaceRoom(); + title = isSpace + ? _t("invite|to_space", { + spaceName: room?.name || _t("common|unnamed_space"), + }) + : _t("invite|to_room", { + roomName: room?.name || _t("common|unnamed_room"), + }); + + let helpTextUntranslated; + if (isSpace) { + if (identityServersEnabled) { + helpTextUntranslated = _td("invite|name_email_mxid_share_space"); + } else { + helpTextUntranslated = _td("invite|name_mxid_share_space"); + } + } else { + if (identityServersEnabled) { + helpTextUntranslated = _td("invite|name_email_mxid_share_room"); + } else { + helpTextUntranslated = _td("invite|name_mxid_share_room"); + } + } + + helpText = _t( + helpTextUntranslated, + {}, + { + userId: () => ( + + {userId} + + ), + a: (sub) => ( + + {sub} + + ), + }, + ); + + buttonText = _t("action|invite"); + goButtonFn = this.inviteUsers; + + if (cli.isRoomEncrypted(this.props.roomId)) { + const room = cli.getRoom(this.props.roomId)!; + const visibilityEvent = room.currentState.getStateEvents("m.room.history_visibility", ""); + const visibility = + visibilityEvent && visibilityEvent.getContent() && visibilityEvent.getContent().history_visibility; + if (visibility === "world_readable" || visibility === "shared") { + keySharingWarning = ( +

+ + {" " + _t("invite|key_share_warning")} +

+ ); + } + } + } else if (this.props.kind === InviteKind.CallTransfer) { + title = _t("action|transfer"); + + consultConnectSection = ( +
+ + + {_t("action|cancel")} + + + {_t("action|transfer")} + +
+ ); + } + + const goButton = + this.props.kind == InviteKind.CallTransfer ? null : ( + + {buttonText} + + ); + + let results: React.ReactNode | null = null; + let onlyOneThreepidNote: React.ReactNode | null = null; + + if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) { + // We are in DM case here, because of the checks in canInviteMore() / canInviteThirdParty(). + onlyOneThreepidNote =
{_t("invite|email_limit_one")}
; + } else { + results = ( +
+ {this.renderSection("recents")} + {this.renderSection("suggestions")} + {extraSection} +
+ ); + } + + const usersSection = ( + +

{helpText}

+
+ {this.renderEditor()} +
+ {goButton} + {spinner} +
+
+ {keySharingWarning} + {this.renderIdentityServerWarning()} +
{this.state.errorText}
+ {onlyOneThreepidNote} + {results} + {footer} +
+ ); + + let dialogContent; + if (this.props.kind === InviteKind.CallTransfer) { + const tabs: NonEmptyArray> = [ + new Tab( + TabId.UserDirectory, + _td("invite|transfer_user_directory_tab"), + "mx_InviteDialog_userDirectoryIcon", + usersSection, + ), + ]; + + const backspaceButton = ; + + // Only show the backspace button if the field has content + let dialPadField; + if (this.state.dialPadValue.length !== 0) { + dialPadField = ( + + ); + } else { + dialPadField = ( + + ); + } + + const dialPadSection = ( +
+
{dialPadField}
+ +
+ ); + tabs.push( + new Tab( + TabId.DialPad, + _td("invite|transfer_dial_pad_tab"), + "mx_InviteDialog_dialPadIcon", + dialPadSection, + ), + ); + dialogContent = ( + + + {consultConnectSection} + + ); + } else { + dialogContent = ( + + {usersSection} + {consultConnectSection} + + ); + } + + return ( + +
{dialogContent}
+
+ ); + } +} diff --git a/src/components/views/elements/RoomName.tsx b/src/components/views/elements/RoomName.tsx index 5a7b4cd837..bc3738d533 100644 --- a/src/components/views/elements/RoomName.tsx +++ b/src/components/views/elements/RoomName.tsx @@ -18,7 +18,9 @@ import { IPublicRoomsChunkRoom, Room } from "matrix-js-sdk/src/matrix"; import React, { useCallback, useMemo } from "react"; import { Icon as TokenGatedRoomIcon } from "../../../../res/themes/superhero/img/icons/tokengated-room.svg"; -import { isTokenGatedRoom, useRoomName } from "../../../hooks/useRoomName"; +import { useRoomName } from "../../../hooks/useRoomName"; +import { useVerifiedRoom } from "../../../hooks/useVerifiedRoom"; +import { UserVerifiedBadge } from "./UserVerifiedBadge"; interface IProps { room?: Room | IPublicRoomsChunkRoom; @@ -28,9 +30,13 @@ interface IProps { export const RoomName = ({ room, children, maxLength }: IProps): JSX.Element => { const roomName = useRoomName(room); + const isVerifiedRoom = useVerifiedRoom(room); - const isVerifiedRoom = useMemo(() => { - return isTokenGatedRoom(room); + const roomUsers: string[] = useMemo(() => { + return (room as Room) + .getMembers() + .map((m) => m.userId) + .filter((userId) => !!userId && userId != (room as Room).myUserId); }, [room]); const truncatedRoomName = useMemo(() => { @@ -45,9 +51,10 @@ export const RoomName = ({ room, children, maxLength }: IProps): JSX.Element => {isVerifiedRoom && } {truncatedRoomName} + {roomUsers?.length && } ), - [truncatedRoomName, isVerifiedRoom], + [truncatedRoomName, isVerifiedRoom, roomUsers], ); if (children) return children(renderRoomName()); diff --git a/src/components/views/elements/UserVerifiedBadge.tsx b/src/components/views/elements/UserVerifiedBadge.tsx new file mode 100644 index 0000000000..799be8f863 --- /dev/null +++ b/src/components/views/elements/UserVerifiedBadge.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +import { Icon as VerifiedIcon } from "../../../../res/themes/superhero/img/icons/verified.svg"; +import { useVerifiedUser } from "../../../hooks/useVerifiedUser"; + +export interface UserVerifiedBadgeProps { + userId: string; +} + +export const UserVerifiedBadge = ({ userId }: UserVerifiedBadgeProps): JSX.Element => { + const isVerifiedUser = useVerifiedUser(userId); + + return <>{isVerifiedUser && }; +}; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx new file mode 100644 index 0000000000..edc2b1ff2f --- /dev/null +++ b/src/components/views/right_panel/UserInfo.tsx @@ -0,0 +1,1784 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017, 2018 Vector Creations Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import classNames from "classnames"; +import { + ClientEvent, + MatrixClient, + RoomMember, + Room, + RoomStateEvent, + MatrixEvent, + User, + Device, + EventType, +} from "matrix-js-sdk/src/matrix"; +import { UserVerificationStatus, VerificationRequest } from "matrix-js-sdk/src/crypto-api"; +import { logger } from "matrix-js-sdk/src/logger"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import Modal from "matrix-react-sdk/src/Modal"; +import { _t, UserFriendlyError } from "matrix-react-sdk/src/languageHandler"; +import DMRoomMap from "matrix-react-sdk/src/utils/DMRoomMap"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import SdkConfig from "matrix-react-sdk/src/SdkConfig"; +import MultiInviter from "matrix-react-sdk/src/utils/MultiInviter"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import E2EIcon from "matrix-react-sdk/src/components/views/rooms/E2EIcon"; +import { useTypedEventEmitter } from "matrix-react-sdk/src/hooks/useEventEmitter"; +import { textualPowerLevel } from "matrix-react-sdk/src/Roles"; +import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import { RightPanelPhases } from "matrix-react-sdk/src/stores/right-panel/RightPanelStorePhases"; +import EncryptionPanel from "matrix-react-sdk/src/components/views/right_panel/EncryptionPanel"; +import { useAsyncMemo } from "matrix-react-sdk/src/hooks/useAsyncMemo"; +import { legacyVerifyUser, verifyDevice, verifyUser } from "matrix-react-sdk/src/verification"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import { useIsEncrypted } from "matrix-react-sdk/src/hooks/useIsEncrypted"; +import BaseCard from "matrix-react-sdk/src/components/views/right_panel/BaseCard"; +import { E2EStatus } from "matrix-react-sdk/src/utils/ShieldUtils"; +import ImageView from "matrix-react-sdk/src/components/views/elements/ImageView"; +import Spinner from "matrix-react-sdk/src/components/views/elements/Spinner"; +import PowerSelector from "matrix-react-sdk/src/components/views/elements/PowerSelector"; +import MemberAvatar from "matrix-react-sdk/src/components/views/avatars/MemberAvatar"; +import PresenceLabel from "matrix-react-sdk/src/components/views/rooms/PresenceLabel"; +import BulkRedactDialog from "matrix-react-sdk/src/components/views/dialogs/BulkRedactDialog"; +import ShareDialog from "matrix-react-sdk/src/components/views/dialogs/ShareDialog"; +import ErrorDialog from "matrix-react-sdk/src/components/views/dialogs/ErrorDialog"; +import QuestionDialog from "matrix-react-sdk/src/components/views/dialogs/QuestionDialog"; +import ConfirmUserActionDialog from "matrix-react-sdk/src/components/views/dialogs/ConfirmUserActionDialog"; +import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar"; +import RoomName from "matrix-react-sdk/src/components/views/elements/RoomName"; +import { mediaFromMxc } from "matrix-react-sdk/src/customisations/Media"; +import { ComposerInsertPayload } from "matrix-react-sdk/src/dispatcher/payloads/ComposerInsertPayload"; +import ConfirmSpaceUserActionDialog from "matrix-react-sdk/src/components/views/dialogs/ConfirmSpaceUserActionDialog"; +import { bulkSpaceBehaviour } from "matrix-react-sdk/src/utils/space"; +import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents"; +import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature"; +import { TimelineRenderingType } from "matrix-react-sdk/src/contexts/RoomContext"; +import RightPanelStore from "matrix-react-sdk/src/stores/right-panel/RightPanelStore"; +import { IRightPanelCardState } from "matrix-react-sdk/src/stores/right-panel/RightPanelStoreIPanelState"; +import UserIdentifierCustomisations from "matrix-react-sdk/src/customisations/UserIdentifier"; +import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { DirectoryMember, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; +import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext"; +import { asyncSome } from "matrix-react-sdk/src/utils/arrays"; +import UIStore from "matrix-react-sdk/src/stores/UIStore"; + +import { UserVerifiedBadge } from "../elements/UserVerifiedBadge"; + +export interface IDevice extends Device { + ambiguous?: boolean; +} + +export const disambiguateDevices = (devices: IDevice[]): void => { + const names = Object.create(null); + for (let i = 0; i < devices.length; i++) { + const name = devices[i].displayName ?? ""; + const indexList = names[name] || []; + indexList.push(i); + names[name] = indexList; + } + for (const name in names) { + if (names[name].length > 1) { + names[name].forEach((j: number) => { + devices[j].ambiguous = true; + }); + } + } +}; + +export const getE2EStatus = async ( + cli: MatrixClient, + userId: string, + devices: IDevice[], +): Promise => { + const crypto = cli.getCrypto(); + if (!crypto) return undefined; + const isMe = userId === cli.getUserId(); + const userTrust = await crypto.getUserVerificationStatus(userId); + if (!userTrust.isCrossSigningVerified()) { + return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal; + } + + const anyDeviceUnverified = await asyncSome(devices, async (device) => { + const { deviceId } = device; + // For your own devices, we use the stricter check of cross-signing + // verification to encourage everyone to trust their own devices via + // cross-signing so that other users can then safely trust you. + // For other people's devices, the more general verified check that + // includes locally verified devices can be used. + const deviceTrust = await crypto.getDeviceVerificationStatus(userId, deviceId); + return isMe ? !deviceTrust?.crossSigningVerified : !deviceTrust?.isVerified(); + }); + return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified; +}; + +/** + * Converts the member to a DirectoryMember and starts a DM with them. + */ +async function openDmForUser(matrixClient: MatrixClient, user: Member): Promise { + const avatarUrl = user instanceof User ? user.avatarUrl : user.getMxcAvatarUrl(); + const startDmUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: avatarUrl, + }); + await startDmOnFirstMessage(matrixClient, [startDmUser]); +} + +type SetUpdating = (updating: boolean) => void; + +function useHasCrossSigningKeys( + cli: MatrixClient, + member: User, + canVerify: boolean, + setUpdating: SetUpdating, +): boolean | undefined { + return useAsyncMemo(async () => { + if (!canVerify) { + return undefined; + } + setUpdating(true); + try { + return await cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true); + } finally { + setUpdating(false); + } + }, [cli, member, canVerify]); +} + +/** + * Display one device and the related actions + * @param userId current user id + * @param device device to display + * @param isUserVerified false when the user is not verified + * @constructor + */ +export function DeviceItem({ + userId, + device, + isUserVerified, +}: { + userId: string; + device: IDevice; + isUserVerified: boolean; +}): JSX.Element { + const cli = useContext(MatrixClientContext); + const isMe = userId === cli.getUserId(); + + /** is the device verified? */ + const isVerified = useAsyncMemo(async () => { + const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, device.deviceId); + if (!deviceTrust) return false; + + // For your own devices, we use the stricter check of cross-signing + // verification to encourage everyone to trust their own devices via + // cross-signing so that other users can then safely trust you. + // For other people's devices, the more general verified check that + // includes locally verified devices can be used. + return isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified(); + }, [cli, userId, device]); + + const classes = classNames("mx_UserInfo_device", { + mx_UserInfo_device_verified: isVerified, + mx_UserInfo_device_unverified: !isVerified, + }); + const iconClasses = classNames("mx_E2EIcon", { + mx_E2EIcon_normal: !isUserVerified, + mx_E2EIcon_verified: isVerified, + mx_E2EIcon_warning: isUserVerified && !isVerified, + }); + + const onDeviceClick = (): void => { + const user = cli.getUser(userId); + if (user) { + verifyDevice(cli, user, device); + } + }; + + let deviceName; + if (!device.displayName?.trim()) { + deviceName = device.deviceId; + } else { + deviceName = device.ambiguous ? device.displayName + " (" + device.deviceId + ")" : device.displayName; + } + + let trustedLabel: string | undefined; + if (isUserVerified) trustedLabel = isVerified ? _t("common|trusted") : _t("common|not_trusted"); + + if (isVerified === undefined) { + // we're still deciding if the device is verified + return
; + } else if (isVerified) { + return ( +
+
+
{deviceName}
+
{trustedLabel}
+
+ ); + } else { + return ( + +
+
{deviceName}
+
{trustedLabel}
+ + ); + } +} + +/** + * Display a list of devices + * @param devices devices to display + * @param userId current user id + * @param loading displays a spinner instead of the device section + * @param isUserVerified is false when + * - the user is not verified, or + * - `MatrixClient.getCrypto.getUserVerificationStatus` async call is in progress (in which case `loading` will also be `true`) + * @constructor + */ +function DevicesSection({ + devices, + userId, + loading, + isUserVerified, +}: { + devices: IDevice[]; + userId: string; + loading: boolean; + isUserVerified: boolean; +}): JSX.Element { + const cli = useContext(MatrixClientContext); + + const [isExpanded, setExpanded] = useState(false); + + const deviceTrusts = useAsyncMemo(() => { + const cryptoApi = cli.getCrypto(); + if (!cryptoApi) return Promise.resolve(undefined); + return Promise.all(devices.map((d) => cryptoApi.getDeviceVerificationStatus(userId, d.deviceId))); + }, [cli, userId, devices]); + + if (loading || deviceTrusts === undefined) { + // still loading + return ; + } + const isMe = userId === cli.getUserId(); + + let expandSectionDevices: IDevice[] = []; + const unverifiedDevices: IDevice[] = []; + + let expandCountCaption; + let expandHideCaption; + let expandIconClasses = "mx_E2EIcon"; + + if (isUserVerified) { + for (let i = 0; i < devices.length; ++i) { + const device = devices[i]; + const deviceTrust = deviceTrusts[i]; + // For your own devices, we use the stricter check of cross-signing + // verification to encourage everyone to trust their own devices via + // cross-signing so that other users can then safely trust you. + // For other people's devices, the more general verified check that + // includes locally verified devices can be used. + const isVerified = deviceTrust && (isMe ? deviceTrust.crossSigningVerified : deviceTrust.isVerified()); + + if (isVerified) { + expandSectionDevices.push(device); + } else { + unverifiedDevices.push(device); + } + } + expandCountCaption = _t("user_info|count_of_verified_sessions", { count: expandSectionDevices.length }); + expandHideCaption = _t("user_info|hide_verified_sessions"); + expandIconClasses += " mx_E2EIcon_verified"; + } else { + expandSectionDevices = devices; + expandCountCaption = _t("user_info|count_of_sessions", { count: devices.length }); + expandHideCaption = _t("user_info|hide_sessions"); + expandIconClasses += " mx_E2EIcon_normal"; + } + + let expandButton; + if (expandSectionDevices.length) { + if (isExpanded) { + expandButton = ( + setExpanded(false)}> +
{expandHideCaption}
+
+ ); + } else { + expandButton = ( + setExpanded(true)}> +
+
{expandCountCaption}
+ + ); + } + } + + let deviceList = unverifiedDevices.map((device, i) => { + return ; + }); + if (isExpanded) { + const keyStart = unverifiedDevices.length; + deviceList = deviceList.concat( + expandSectionDevices.map((device, i) => { + return ( + + ); + }), + ); + } + + return ( +
+
{deviceList}
+
{expandButton}
+
+ ); +} + +const MessageButton = ({ member }: { member: Member }): JSX.Element => { + const cli = useContext(MatrixClientContext); + const [busy, setBusy] = useState(false); + + return ( + => { + if (busy) return; + setBusy(true); + await openDmForUser(cli, member); + setBusy(false); + }} + className="mx_UserInfo_field" + disabled={busy} + > + {_t("common|message")} + + ); +}; + +export const UserOptionsSection: React.FC<{ + member: Member; + isIgnored: boolean; + canInvite: boolean; + isSpace?: boolean; +}> = ({ member, isIgnored, canInvite, isSpace }) => { + const cli = useContext(MatrixClientContext); + + let ignoreButton: JSX.Element | undefined; + let insertPillButton: JSX.Element | undefined; + let inviteUserButton: JSX.Element | undefined; + let readReceiptButton: JSX.Element | undefined; + + const isMe = member.userId === cli.getUserId(); + const onShareUserClick = (): void => { + Modal.createDialog(ShareDialog, { + target: member, + }); + }; + + const unignore = useCallback(() => { + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + cli.setIgnoredUsers(ignoredUsers); + }, [cli, member]); + + const ignore = useCallback(async () => { + const name = (member instanceof User ? member.displayName : member.name) || member.userId; + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|ignore_confirm_title", { user: name }), + description:
{_t("user_info|ignore_confirm_description")}
, + button: _t("action|ignore"), + }); + const [confirmed] = await finished; + + if (confirmed) { + const ignoredUsers = cli.getIgnoredUsers(); + ignoredUsers.push(member.userId); + cli.setIgnoredUsers(ignoredUsers); + } + }, [cli, member]); + + // Only allow the user to ignore the user if its not ourselves + // same goes for jumping to read receipt + if (!isMe) { + ignoreButton = ( + + {isIgnored ? _t("action|unignore") : _t("action|ignore")} + + ); + + if (member instanceof RoomMember && member.roomId && !isSpace) { + const onReadReceiptButton = function (): void { + const room = cli.getRoom(member.roomId); + dis.dispatch({ + action: Action.ViewRoom, + highlighted: true, + // this could return null, the default prevents a type error + event_id: room?.getEventReadUpTo(member.userId) || undefined, + room_id: member.roomId, + metricsTrigger: undefined, // room doesn't change + }); + }; + + const onInsertPillButton = function (): void { + dis.dispatch({ + action: Action.ComposerInsert, + userId: member.userId, + timelineRenderingType: TimelineRenderingType.Room, + }); + }; + + const room = member instanceof RoomMember ? cli.getRoom(member.roomId) : undefined; + if (room?.getEventReadUpTo(member.userId)) { + readReceiptButton = ( + + {_t("user_info|jump_to_rr_button")} + + ); + } + + insertPillButton = ( + + {_t("action|mention")} + + ); + } + + if ( + member instanceof RoomMember && + canInvite && + (member?.membership ?? "leave") === "leave" && + shouldShowComponent(UIComponent.InviteUsers) + ) { + const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId(); + const onInviteUserButton = async (ev: ButtonEvent): Promise => { + try { + // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. + const inviter = new MultiInviter(cli, roomId || ""); + await inviter.invite([member.userId]).then(() => { + if (inviter.getCompletionState(member.userId) !== "invited") { + const errorStringFromInviterUtility = inviter.getErrorText(member.userId); + if (errorStringFromInviterUtility) { + throw new Error(errorStringFromInviterUtility); + } else { + throw new UserFriendlyError("slash_command|invite_failed", { + user: member.userId, + roomId, + cause: undefined, + }); + } + } + }); + } catch (err) { + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("invite|failed_title"), + description, + }); + } + + PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoInviteButton", ev); + }; + + inviteUserButton = ( + + {_t("action|invite")} + + ); + } + } + + const shareUserButton = ( + + {_t("user_info|share_button")} + + ); + + const directMessageButton = isMe ? null : ; + + return ( +
+

{_t("common|options")}

+
+ {directMessageButton} + {readReceiptButton} + {shareUserButton} + {insertPillButton} + {inviteUserButton} + {ignoreButton} +
+
+ ); +}; + +export const warnSelfDemote = async (isSpace: boolean): Promise => { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|demote_self_confirm_title"), + description: ( +
+ {isSpace + ? _t("user_info|demote_self_confirm_description_space") + : _t("user_info|demote_self_confirm_room")} +
+ ), + button: _t("user_info|demote_button"), + }); + + const [confirmed] = await finished; + return !!confirmed; +}; + +const GenericAdminToolsContainer: React.FC<{ + children: ReactNode; +}> = ({ children }) => { + return ( +
+

{_t("user_info|admin_tools_section")}

+
{children}
+
+ ); +}; + +interface IPowerLevelsContent { + events?: Record; + // eslint-disable-next-line camelcase + users_default?: number; + // eslint-disable-next-line camelcase + events_default?: number; + // eslint-disable-next-line camelcase + state_default?: number; + ban?: number; + kick?: number; + redact?: number; +} + +export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent): boolean => { + if (!powerLevelContent || !member) return false; + + const levelToSend = + (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) || + powerLevelContent.events_default; + + // levelToSend could be undefined as .events_default is optional. Coercing in this case using + // Number() would always return false, so this preserves behaviour + // FIXME: per the spec, if `events_default` is unset, it defaults to zero. If + // the member has a negative powerlevel, this will give an incorrect result. + if (levelToSend === undefined) return false; + + return member.powerLevel < levelToSend; +}; + +export const getPowerLevels = (room: Room): IPowerLevelsContent => + room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; + +export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsContent => { + const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); + + const update = useCallback( + (ev?: MatrixEvent) => { + if (!room) return; + if (ev && ev.getType() !== EventType.RoomPowerLevels) return; + setPowerLevels(getPowerLevels(room)); + }, + [room], + ); + + useTypedEventEmitter(cli, RoomStateEvent.Events, update); + useEffect(() => { + update(); + return () => { + setPowerLevels({}); + }; + }, [update]); + return powerLevels; +}; + +interface IBaseProps { + member: RoomMember; + isUpdating: boolean; + startUpdating(): void; + stopUpdating(): void; +} + +export const RoomKickButton = ({ + room, + member, + isUpdating, + startUpdating, + stopUpdating, +}: Omit): JSX.Element | null => { + const cli = useContext(MatrixClientContext); + + // check if user can be kicked/disinvited + if (member.membership !== "invite" && member.membership !== "join") return <>; + + const onKick = async (): Promise => { + if (isUpdating) return; // only allow one operation at a time + startUpdating(); + + const commonProps = { + member, + action: room.isSpaceRoom() + ? member.membership === "invite" + ? _t("user_info|disinvite_button_space") + : _t("user_info|kick_button_space") + : member.membership === "invite" + ? _t("user_info|disinvite_button_room") + : _t("user_info|kick_button_room"), + title: + member.membership === "invite" + ? _t("user_info|disinvite_button_room_name", { roomName: room.name }) + : _t("user_info|kick_button_room_name", { roomName: room.name }), + askReason: member.membership === "join", + danger: true, + }; + + let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>; + + if (room.isSpaceRoom()) { + ({ finished } = Modal.createDialog( + ConfirmSpaceUserActionDialog, + { + ...commonProps, + space: room, + spaceChildFilter: (child: Room) => { + // Return true if the target member is not banned and we have sufficient PL to ban them + const myMember = child.getMember(cli.credentials.userId || ""); + const theirMember = child.getMember(member.userId); + return ( + !!myMember && + !!theirMember && + theirMember.membership === member.membership && + myMember.powerLevel > theirMember.powerLevel && + child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel) + ); + }, + allLabel: _t("user_info|kick_button_space_everything"), + specificLabel: _t("user_info|kick_space_specific"), + warningMessage: _t("user_info|kick_space_warning"), + }, + "mx_ConfirmSpaceUserActionDialog_wrapper", + )); + } else { + ({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps)); + } + + const [proceed, reason, rooms = []] = await finished; + if (!proceed) { + stopUpdating(); + return; + } + + bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined)) + .then( + () => { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + logger.log("Kick success"); + }, + function (err) { + logger.error("Kick error: " + err); + Modal.createDialog(ErrorDialog, { + title: _t("user_info|error_kicking_user"), + description: err && err.message ? err.message : "Operation failed", + }); + }, + ) + .finally(() => { + stopUpdating(); + }); + }; + + const kickLabel = room.isSpaceRoom() + ? member.membership === "invite" + ? _t("user_info|disinvite_button_space") + : _t("user_info|kick_button_space") + : member.membership === "invite" + ? _t("user_info|disinvite_button_room") + : _t("user_info|kick_button_room"); + + return ( + + {kickLabel} + + ); +}; + +const RedactMessagesButton: React.FC = ({ member }) => { + const cli = useContext(MatrixClientContext); + + const onRedactAllMessages = (): void => { + const room = cli.getRoom(member.roomId); + if (!room) return; + + Modal.createDialog(BulkRedactDialog, { + matrixClient: cli, + room, + member, + }); + }; + + return ( + + {_t("user_info|redact_button")} + + ); +}; + +export const BanToggleButton = ({ + room, + member, + isUpdating, + startUpdating, + stopUpdating, +}: Omit): JSX.Element => { + const cli = useContext(MatrixClientContext); + + const isBanned = member.membership === "ban"; + const onBanOrUnban = async (): Promise => { + if (isUpdating) return; // only allow one operation at a time + startUpdating(); + + const commonProps = { + member, + action: room.isSpaceRoom() + ? isBanned + ? _t("user_info|unban_button_space") + : _t("user_info|ban_button_space") + : isBanned + ? _t("user_info|unban_button_room") + : _t("user_info|ban_button_room"), + title: isBanned + ? _t("user_info|unban_room_confirm_title", { roomName: room.name }) + : _t("user_info|ban_room_confirm_title", { roomName: room.name }), + askReason: !isBanned, + danger: !isBanned, + }; + + let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>; + + if (room.isSpaceRoom()) { + ({ finished } = Modal.createDialog( + ConfirmSpaceUserActionDialog, + { + ...commonProps, + space: room, + spaceChildFilter: isBanned + ? (child: Room): boolean => { + // Return true if the target member is banned and we have sufficient PL to unban + const myMember = child.getMember(cli.credentials.userId || ""); + const theirMember = child.getMember(member.userId); + return ( + !!myMember && + !!theirMember && + theirMember.membership === "ban" && + myMember.powerLevel > theirMember.powerLevel && + child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel) + ); + } + : (child: Room): boolean => { + // Return true if the target member isn't banned and we have sufficient PL to ban + const myMember = child.getMember(cli.credentials.userId || ""); + const theirMember = child.getMember(member.userId); + return ( + !!myMember && + !!theirMember && + theirMember.membership !== "ban" && + myMember.powerLevel > theirMember.powerLevel && + child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel) + ); + }, + allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"), + specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"), + warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"), + }, + "mx_ConfirmSpaceUserActionDialog_wrapper", + )); + } else { + ({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps)); + } + + const [proceed, reason, rooms = []] = await finished; + if (!proceed) { + stopUpdating(); + return; + } + + const fn = (roomId: string): Promise => { + if (isBanned) { + return cli.unban(roomId, member.userId); + } else { + return cli.ban(roomId, member.userId, reason || undefined); + } + }; + + bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId)) + .then( + () => { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + logger.log("Ban success"); + }, + function (err) { + logger.error("Ban error: " + err); + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("user_info|error_ban_user"), + }); + }, + ) + .finally(() => { + stopUpdating(); + }); + }; + + let label = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room"); + if (isBanned) { + label = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room"); + } + + const classes = classNames("mx_UserInfo_field", { + mx_UserInfo_destructive: !isBanned, + }); + + return ( + + {label} + + ); +}; + +interface IBaseRoomProps extends IBaseProps { + room: Room; + powerLevels: IPowerLevelsContent; + children?: ReactNode; +} + +// We do not show a Mute button for ourselves so it doesn't need to handle warning self demotion +const MuteToggleButton: React.FC = ({ + member, + room, + powerLevels, + isUpdating, + startUpdating, + stopUpdating, +}) => { + const cli = useContext(MatrixClientContext); + + // Don't show the mute/unmute option if the user is not in the room + if (member.membership !== "join") return null; + + const muted = isMuted(member, powerLevels); + const onMuteToggle = async (): Promise => { + if (isUpdating) return; // only allow one operation at a time + startUpdating(); + + const roomId = member.roomId; + const target = member.userId; + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + const powerLevels = powerLevelEvent?.getContent(); + const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default; + let level; + if (muted) { + // unmute + level = levelToSend; + } else { + // mute + level = levelToSend - 1; + } + level = parseInt(level); + + if (isNaN(level)) { + stopUpdating(); + return; + } + + cli.setPowerLevel(roomId, target, level, powerLevelEvent) + .then( + () => { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + logger.log("Mute toggle success"); + }, + function (err) { + logger.error("Mute error: " + err); + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("user_info|error_mute_user"), + }); + }, + ) + .finally(() => { + stopUpdating(); + }); + }; + + const classes = classNames("mx_UserInfo_field", { + mx_UserInfo_destructive: !muted, + }); + + const muteLabel = muted ? _t("common|unmute") : _t("common|mute"); + return ( + + {muteLabel} + + ); +}; + +export const RoomAdminToolsContainer: React.FC = ({ + room, + children, + member, + isUpdating, + startUpdating, + stopUpdating, + powerLevels, +}) => { + const cli = useContext(MatrixClientContext); + let kickButton; + let banButton; + let muteButton; + let redactButton; + + const editPowerLevel = + (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default; + + // if these do not exist in the event then they should default to 50 as per the spec + const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels; + + const me = room.getMember(cli.getUserId() || ""); + if (!me) { + // we aren't in the room, so return no admin tooling + return
; + } + + const isMe = me.userId === member.userId; + const canAffectUser = member.powerLevel < me.powerLevel || isMe; + + if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) { + kickButton = ( + + ); + } + if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) { + redactButton = ( + + ); + } + if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) { + banButton = ( + + ); + } + if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) { + muteButton = ( + + ); + } + + if (kickButton || banButton || muteButton || redactButton || children) { + return ( + + {muteButton} + {kickButton} + {banButton} + {redactButton} + {children} + + ); + } + + return
; +}; + +const useIsSynapseAdmin = (cli?: MatrixClient): boolean => { + return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false); +}; + +const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => { + return useAsyncMemo( + async () => { + return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"); + }, + [cli], + false, + ); +}; + +interface IRoomPermissions { + modifyLevelMax: number; + canEdit: boolean; + canInvite: boolean; +} + +function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IRoomPermissions { + const [roomPermissions, setRoomPermissions] = useState({ + // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + + const updateRoomPermissions = useCallback(() => { + const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + if (!powerLevels) return; + + const me = room.getMember(cli.getUserId() || ""); + if (!me) return; + + const them = user; + const isMe = me.userId === them.userId; + const canAffectUser = them.powerLevel < me.powerLevel || isMe; + + let modifyLevelMax = -1; + if (canAffectUser) { + const editPowerLevel = powerLevels.events?.[EventType.RoomPowerLevels] ?? powerLevels.state_default ?? 50; + if (me.powerLevel >= editPowerLevel) { + modifyLevelMax = me.powerLevel; + } + } + + setRoomPermissions({ + canInvite: me.powerLevel >= (powerLevels.invite ?? 0), + canEdit: modifyLevelMax >= 0, + modifyLevelMax, + }); + }, [cli, user, room]); + + useTypedEventEmitter(cli, RoomStateEvent.Update, updateRoomPermissions); + useEffect(() => { + updateRoomPermissions(); + return () => { + setRoomPermissions({ + modifyLevelMax: -1, + canEdit: false, + canInvite: false, + }); + }; + }, [updateRoomPermissions]); + + return roomPermissions; +} + +const PowerLevelSection: React.FC<{ + user: RoomMember; + room: Room; + roomPermissions: IRoomPermissions; + powerLevels: IPowerLevelsContent; +}> = ({ user, room, roomPermissions, powerLevels }) => { + if (roomPermissions.canEdit) { + return ; + } else { + const powerLevelUsersDefault = powerLevels.users_default || 0; + const powerLevel = user.powerLevel; + const role = textualPowerLevel(powerLevel, powerLevelUsersDefault); + return ( +
+
{role}
+
+ ); + } +}; + +export const PowerLevelEditor: React.FC<{ + user: RoomMember; + room: Room; + roomPermissions: IRoomPermissions; +}> = ({ user, room, roomPermissions }) => { + const cli = useContext(MatrixClientContext); + + const [selectedPowerLevel, setSelectedPowerLevel] = useState(user.powerLevel); + useEffect(() => { + setSelectedPowerLevel(user.powerLevel); + }, [user]); + + const onPowerChange = useCallback( + async (powerLevel: number) => { + setSelectedPowerLevel(powerLevel); + + const applyPowerChange = ( + roomId: string, + target: string, + powerLevel: number, + powerLevelEvent: MatrixEvent, + ): Promise => { + return cli.setPowerLevel(roomId, target, powerLevel, powerLevelEvent).then( + function () { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + logger.log("Power change success"); + }, + function (err) { + logger.error("Failed to change power level " + err); + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("error|update_power_level"), + }); + }, + ); + }; + + const roomId = user.roomId; + const target = user.userId; + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (!powerLevelEvent) return; + + const myUserId = cli.getUserId(); + const myPower = powerLevelEvent.getContent().users[myUserId || ""]; + if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("common|warning"), + description: ( +
+ {_t("user_info|promote_warning")} +
+ {_t("common|are_you_sure")} +
+ ), + button: _t("action|continue"), + }); + + const [confirmed] = await finished; + if (!confirmed) return; + } else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) { + // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. + try { + if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; + } catch (e) { + logger.error("Failed to warn about self demotion: ", e); + } + } + + await applyPowerChange(roomId, target, powerLevel, powerLevelEvent); + }, + [user.roomId, user.userId, cli, room], + ); + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0; + + return ( +
+ +
+ ); +}; + +async function getUserDeviceInfo( + userId: string, + cli: MatrixClient, + downloadUncached = false, +): Promise { + const userDeviceMap = await cli.getCrypto()?.getUserDeviceInfo([userId], downloadUncached); + const devicesMap = userDeviceMap?.get(userId); + + if (!devicesMap) return; + + return Array.from(devicesMap.values()); +} + +export const useDevices = (userId: string): IDevice[] | undefined | null => { + const cli = useContext(MatrixClientContext); + + // undefined means yet to be loaded, null means failed to load, otherwise list of devices + const [devices, setDevices] = useState(undefined); + // Download device lists + useEffect(() => { + setDevices(undefined); + + let cancelled = false; + + async function downloadDeviceList(): Promise { + try { + const devices = await getUserDeviceInfo(userId, cli, true); + + if (cancelled || !devices) { + // we got cancelled - presumably a different user now + return; + } + + disambiguateDevices(devices); + setDevices(devices); + } catch (err) { + setDevices(null); + } + } + downloadDeviceList(); + + // Handle being unmounted + return () => { + cancelled = true; + }; + }, [cli, userId]); + + // Listen to changes + useEffect(() => { + let cancel = false; + const updateDevices = async (): Promise => { + const newDevices = await getUserDeviceInfo(userId, cli); + if (cancel || !newDevices) return; + setDevices(newDevices); + }; + const onDevicesUpdated = (users: string[]): void => { + if (!users.includes(userId)) return; + updateDevices(); + }; + const onDeviceVerificationChanged = (_userId: string, deviceId: string): void => { + if (_userId !== userId) return; + updateDevices(); + }; + const onUserTrustStatusChanged = (_userId: string, trustLevel: UserTrustLevel): void => { + if (_userId !== userId) return; + updateDevices(); + }; + cli.on(CryptoEvent.DevicesUpdated, onDevicesUpdated); + cli.on(CryptoEvent.DeviceVerificationChanged, onDeviceVerificationChanged); + cli.on(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged); + // Handle being unmounted + return () => { + cancel = true; + cli.removeListener(CryptoEvent.DevicesUpdated, onDevicesUpdated); + cli.removeListener(CryptoEvent.DeviceVerificationChanged, onDeviceVerificationChanged); + cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged); + }; + }, [cli, userId]); + + return devices; +}; + +const BasicUserInfo: React.FC<{ + room: Room; + member: User | RoomMember; + devices: IDevice[]; + isRoomEncrypted: boolean; +}> = ({ room, member, devices, isRoomEncrypted }) => { + const cli = useContext(MatrixClientContext); + + const powerLevels = useRoomPowerLevels(cli, room); + // Load whether or not we are a Synapse Admin + const isSynapseAdmin = useIsSynapseAdmin(cli); + + // Check whether the user is ignored + const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(member.userId)); + // Recheck if the user or client changes + useEffect(() => { + setIsIgnored(cli.isUserIgnored(member.userId)); + }, [cli, member.userId]); + // Recheck also if we receive new accountData m.ignored_user_list + const accountDataHandler = useCallback( + (ev) => { + if (ev.getType() === "m.ignored_user_list") { + setIsIgnored(cli.isUserIgnored(member.userId)); + } + }, + [cli, member.userId], + ); + useTypedEventEmitter(cli, ClientEvent.AccountData, accountDataHandler); + + // Count of how many operations are currently in progress, if > 0 then show a Spinner + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + const startUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount + 1); + }, [pendingUpdateCount]); + const stopUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount - 1); + }, [pendingUpdateCount]); + + const roomPermissions = useRoomPermissions(cli, room, member as RoomMember); + + const onSynapseDeactivate = useCallback(async () => { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("user_info|deactivate_confirm_title"), + description:
{_t("user_info|deactivate_confirm_description")}
, + button: _t("user_info|deactivate_confirm_action"), + danger: true, + }); + + const [accepted] = await finished; + if (!accepted) return; + try { + await cli.deactivateSynapseUser(member.userId); + } catch (err) { + logger.error("Failed to deactivate user"); + logger.error(err); + + const description = err instanceof Error ? err.message : _t("invite|failed_generic"); + + Modal.createDialog(ErrorDialog, { + title: _t("user_info|error_deactivate"), + description, + }); + } + }, [cli, member.userId]); + + let synapseDeactivateButton; + let spinner; + + // We don't need a perfect check here, just something to pass as "probably not our homeserver". If + // someone does figure out how to bypass this check the worst that happens is an error. + // FIXME this should be using cli instead of MatrixClientPeg.matrixClient + if (isSynapseAdmin && member.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) { + synapseDeactivateButton = ( + + {_t("user_info|deactivate_confirm_action")} + + ); + } + + let memberDetails; + let adminToolsContainer; + if (room && (member as RoomMember).roomId) { + // hide the Roles section for DMs as it doesn't make sense there + if (!DMRoomMap.shared().getUserIdForRoomId((member as RoomMember).roomId)) { + memberDetails = ( +
+

+ {_t( + "user_info|role_label", + {}, + { + RoomName: () => {room.name}, + }, + )} +

+ +
+ ); + } + + adminToolsContainer = ( + 0} + startUpdating={startUpdating} + stopUpdating={stopUpdating} + > + {synapseDeactivateButton} + + ); + } else if (synapseDeactivateButton) { + adminToolsContainer = {synapseDeactivateButton}; + } + + if (pendingUpdateCount > 0) { + spinner = ; + } + + // only display the devices list if our client supports E2E + const cryptoEnabled = Boolean(cli.getCrypto()); + + let text; + if (!isRoomEncrypted) { + if (!cryptoEnabled) { + text = _t("encryption|unsupported"); + } else if (room && !room.isSpaceRoom()) { + text = _t("user_info|room_unencrypted"); + } + } else if (!room.isSpaceRoom()) { + text = _t("user_info|room_encrypted"); + } + + let verifyButton; + const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli); + + const userTrust = useAsyncMemo( + async () => cli.getCrypto()?.getUserVerificationStatus(member.userId), + [member.userId], + // the user verification status is not initialized + undefined, + ); + const hasUserVerificationStatus = Boolean(userTrust); + const isUserVerified = Boolean(userTrust?.isVerified()); + const isMe = member.userId === cli.getUserId(); + const canVerify = + hasUserVerificationStatus && + homeserverSupportsCrossSigning && + !isUserVerified && + !isMe && + devices && + devices.length > 0; + + const setUpdating: SetUpdating = (updating) => { + setPendingUpdateCount((count) => count + (updating ? 1 : -1)); + }; + const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating); + + // Display the spinner only when + // - the devices are not populated yet, or + // - the crypto is available and we don't have the user verification status yet + const showDeviceListSpinner = (cryptoEnabled && !hasUserVerificationStatus) || devices === undefined; + if (canVerify) { + if (hasCrossSigningKeys !== undefined) { + // Note: mx_UserInfo_verifyButton is for the end-to-end tests + verifyButton = ( +
+ { + if (hasCrossSigningKeys) { + verifyUser(cli, member as User); + } else { + legacyVerifyUser(cli, member as User); + } + }} + > + {_t("action|verify")} + +
+ ); + } else if (!showDeviceListSpinner) { + // HACK: only show a spinner if the device section spinner is not shown, + // to avoid showing a double spinner + // We should ask for a design that includes all the different loading states here + verifyButton = ; + } + } + + let editDevices; + if (member.userId == cli.getUserId()) { + editDevices = ( +
+ { + dis.dispatch({ + action: Action.ViewUserDeviceSettings, + }); + }} + > + {_t("user_info|edit_own_devices")} + +
+ ); + } + + const securitySection = ( +
+

{_t("common|security")}

+

{text}

+ {verifyButton} + {cryptoEnabled && ( + + )} + {editDevices} +
+ ); + + return ( + + {memberDetails} + + {securitySection} + + + {adminToolsContainer} + + {spinner} + + ); +}; + +export type Member = User | RoomMember; + +export const UserInfoHeader: React.FC<{ + member: Member; + e2eStatus?: E2EStatus; + roomId?: string; +}> = ({ member, e2eStatus, roomId }) => { + const cli = useContext(MatrixClientContext); + + const onMemberAvatarClick = useCallback(() => { + const avatarUrl = (member as RoomMember).getMxcAvatarUrl + ? (member as RoomMember).getMxcAvatarUrl() + : (member as User).avatarUrl; + + const httpUrl = mediaFromMxc(avatarUrl).srcHttp; + if (!httpUrl) return; + + const params = { + src: httpUrl, + name: (member as RoomMember).name || (member as User).displayName, + }; + + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); + }, [member]); + + const avatarUrl = (member as User).avatarUrl; + + const avatarElement = ( +
+
+
+ +
+
+
+ ); + + let presenceState: string | undefined; + let presenceLastActiveAgo: number | undefined; + let presenceCurrentlyActive: boolean | undefined; + if (member instanceof RoomMember && member.user) { + presenceState = member.user.presence; + presenceLastActiveAgo = member.user.lastActiveAgo; + presenceCurrentlyActive = member.user.currentlyActive; + } + + const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url"); + let showPresence = true; + if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) { + showPresence = enablePresenceByHsUrl[cli.baseUrl]; + } + + let presenceLabel: JSX.Element | undefined; + if (showPresence) { + presenceLabel = ( + + ); + } + + const e2eIcon = e2eStatus ? : null; + + const displayName = (member as RoomMember).rawDisplayName; + return ( + + {avatarElement} + +
+
+
+

+ + + {displayName} + + {e2eIcon} +

+
+
+ {UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { + roomId, + withDisplayName: true, + })} +
+
{presenceLabel}
+
+
+
+ ); +}; + +interface IProps { + user: Member; + room?: Room; + phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.SpaceMemberInfo | RightPanelPhases.EncryptionPanel; + onClose(): void; + verificationRequest?: VerificationRequest; + verificationRequestPromise?: Promise; +} + +const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPhases.RoomMemberInfo, ...props }) => { + const cli = useContext(MatrixClientContext); + + // fetch latest room member if we have a room, so we don't show historical information, falling back to user + const member = useMemo(() => (room ? room.getMember(user.userId) || user : user), [room, user]); + + const isRoomEncrypted = useIsEncrypted(cli, room); + const devices = useDevices(user.userId) ?? []; + + const e2eStatus = useAsyncMemo(async () => { + if (!isRoomEncrypted || !devices) { + return undefined; + } + return await getE2EStatus(cli, user.userId, devices); + }, [cli, isRoomEncrypted, user.userId, devices]); + + const classes = ["mx_UserInfo"]; + + let cardState: IRightPanelCardState = {}; + // We have no previousPhase for when viewing a UserInfo without a Room at this time + if (room && phase === RightPanelPhases.EncryptionPanel) { + cardState = { member }; + } else if (room?.isSpaceRoom()) { + cardState = { spaceId: room.roomId }; + } + + const onEncryptionPanelClose = (): void => { + RightPanelStore.instance.popCard(); + }; + + let content: JSX.Element | undefined; + switch (phase) { + case RightPanelPhases.RoomMemberInfo: + case RightPanelPhases.SpaceMemberInfo: + content = ( + + ); + break; + case RightPanelPhases.EncryptionPanel: + classes.push("mx_UserInfo_smallAvatar"); + content = ( + )} + member={member as User | RoomMember} + onClose={onEncryptionPanelClose} + isRoomEncrypted={Boolean(isRoomEncrypted)} + /> + ); + break; + } + + let closeLabel: string | undefined; + if (phase === RightPanelPhases.EncryptionPanel) { + const verificationRequest = (props as React.ComponentProps).verificationRequest; + if (verificationRequest && verificationRequest.pending) { + closeLabel = _t("action|cancel"); + } + } + + let scopeHeader; + if (room?.isSpaceRoom()) { + scopeHeader = ( +
+ + +
+ ); + } + + const header = ( + <> + {scopeHeader} + + + ); + return ( + } + onClose={onClose} + closeLabel={closeLabel} + cardState={cardState} + onBack={(ev: ButtonEvent): void => { + if (RightPanelStore.instance.previousCard.phase === RightPanelPhases.RoomMemberList) { + PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoBackButton", ev); + } + }} + > + {header} + {content} + + ); +}; + +export default UserInfo; diff --git a/src/context/SuperheroProvider.tsx b/src/context/SuperheroProvider.tsx new file mode 100644 index 0000000000..8200ee9cda --- /dev/null +++ b/src/context/SuperheroProvider.tsx @@ -0,0 +1,38 @@ +import { useAtom } from "jotai"; +import React, { useEffect } from "react"; + +import { verifiedAccountsAtom } from "../atoms"; + +/** + * Provides the superhero context to its children components. + * @param children The child components to be wrapped by the provider. + * @returns The superhero provider component. + */ +export const SuperheroProvider = ({ children, config }: any): any => { + const [verifiedAccounts, setVerifiedAccounts] = useAtom(verifiedAccountsAtom); + + function loadVerifiedAccounts(): void { + if (config.bots_backend_url) { + fetch(`${config.bots_backend_url}/ae-wallet-bot/get-verified-accounts`, { + method: "POST", + }) + .then((res) => res.json()) + .then(setVerifiedAccounts); + } + } + + useEffect(() => { + if (!verifiedAccounts?.length) { + loadVerifiedAccounts(); + } + + const interval = setInterval(() => { + loadVerifiedAccounts(); + }, 10000); + + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>{children}; +}; diff --git a/src/hooks/useRoomName.ts b/src/hooks/useRoomName.ts index cf347292d4..2bc1a48b61 100644 --- a/src/hooks/useRoomName.ts +++ b/src/hooks/useRoomName.ts @@ -34,15 +34,6 @@ export function getRoomName(room?: Room | IPublicRoomsChunkRoom, oobName?: IOOBD ); } -/** - * Determines if a room is a token gated room - * @param room - The room model - * @returns {boolean} true if the room is token gated - */ -export function isTokenGatedRoom(room?: Room | IPublicRoomsChunkRoom): boolean { - return !!room?.name?.includes("[TG]"); -} - /** * Determines the room name from a combination of the room model and potential * out-of-band information diff --git a/src/hooks/useVerifiedRoom.ts b/src/hooks/useVerifiedRoom.ts new file mode 100644 index 0000000000..32fd1d29b1 --- /dev/null +++ b/src/hooks/useVerifiedRoom.ts @@ -0,0 +1,24 @@ +import { IPublicRoomsChunkRoom, Room } from "matrix-js-sdk/src/matrix"; +import { useMemo } from "react"; + +/** + * Determines if a room is a token gated room + * @param room - The room model + * @returns {boolean} true if the room is token gated + */ +export function isTokenGatedRoom(room?: Room | IPublicRoomsChunkRoom): boolean { + return !!room?.name?.startsWith("[TG]"); +} + +/** + * Custom hook to check if a room is verified + * @param room - The room model + * @returns {boolean} true if the room is verified, false otherwise + */ +export function useVerifiedRoom(room?: Room | IPublicRoomsChunkRoom): boolean { + const isVerifiedRoom = useMemo(() => { + return isTokenGatedRoom(room); + }, [room]); + + return isVerifiedRoom; +} diff --git a/src/hooks/useVerifiedUser.ts b/src/hooks/useVerifiedUser.ts new file mode 100644 index 0000000000..fcf130d9be --- /dev/null +++ b/src/hooks/useVerifiedUser.ts @@ -0,0 +1,19 @@ +import { useMemo } from "react"; +import { useAtom } from "jotai"; + +import { verifiedAccountsAtom } from "../atoms"; + +/** + * Custom hook to check if a user is verified. + * @param userId - The ID of the user to check. + * @returns A boolean indicating whether the user is verified or not. + */ +export function useVerifiedUser(userId?: string): boolean { + const [verifiedAccounts] = useAtom(verifiedAccountsAtom); + + const isVerifiedUser: boolean = useMemo(() => { + return !!(userId && !!verifiedAccounts[userId]); + }, [userId, verifiedAccounts]); + + return isVerifiedUser; +} diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 68dd8229a2..e8cdba8b82 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -40,6 +40,7 @@ import { parseQs } from "./url_utils"; import VectorBasePlatform from "./platform/VectorBasePlatform"; import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing"; import { UserFriendlyError } from "../languageHandler"; +import { SuperheroProvider } from "../context/SuperheroProvider"; // add React and ReactPerf to the global namespace, to make them easier to access via the console // this incidentally means we can forget our React imports in JSX files without penalty. @@ -116,17 +117,19 @@ export async function loadApp(fragParams: {}, matrixChatRef: React.Ref - + + + ); } diff --git a/yarn.lock b/yarn.lock index d5b8c0e55b..f176f3f755 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7837,6 +7837,11 @@ jest@^29.0.0: import-local "^3.0.2" jest-cli "^29.7.0" +jotai@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/jotai/-/jotai-2.6.0.tgz#68b5d634f78a9ea55adfb8d92206ef59304b5dd5" + integrity sha512-Vt6hsc04Km4j03l+Ax+Sc+FVft5cRJhqgxt6GTz6GM2eM3DyX3CdBdzcG0z2FrlZToL1/0OAkqDghIyARWnSuQ== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"