diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index dd793898eca..0011f7378f4 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -15,6 +15,7 @@ jobs: # See https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target. if: > github.event.pull_request.merged + && github.repository == 'element-hq/element-web' && ( github.event.action == 'closed' || ( diff --git a/.github/workflows/build_debian.yaml b/.github/workflows/build_debian.yaml index 254b99d261a..978e469606c 100644 --- a/.github/workflows/build_debian.yaml +++ b/.github/workflows/build_debian.yaml @@ -6,6 +6,7 @@ concurrency: ${{ github.workflow }} jobs: build: name: Build package + if: github.event.release.prerelease == false && github.repository == 'element-hq/element-web' environment: packages.element.io runs-on: ubuntu-latest env: diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 79382a74d42..0a69da7193f 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -3,9 +3,9 @@ on: workflow_dispatch: {} push: tags: [v*] - schedule: - # This job can take a while, and we have usage limits, so just publish develop only twice a day - - cron: "0 7/12 * * *" + # schedule: + # This job can take a while, and we have usage limits, so just publish develop only twice a day + # - cron: "0 7/12 * * *" concurrency: ${{ github.workflow }}-${{ github.ref_name }} jobs: buildx: @@ -49,7 +49,7 @@ jobs: uses: docker/metadata-action@dbef88086f6cef02e264edb7dbf63250c17cef6c # v5 with: images: | - vectorim/element-web + superherodotcom/element-web tags: | type=ref,event=branch type=ref,event=tag @@ -73,4 +73,4 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: vectorim/element-web + repository: superherodotcom/element-web diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5dea22bbb42..127f43eb492 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,6 +17,8 @@ concurrency: jobs: build: name: GitHub Pages + # Only respect triggers from our develop branch, ignore that of forks + if: github.repository == 'element-hq/element-web' runs-on: ubuntu-latest steps: - name: Fetch element-desktop diff --git a/.github/workflows/issue_closed.yml b/.github/workflows/issue_closed.yml index ddcd3d12bd9..e88a264972b 100644 --- a/.github/workflows/issue_closed.yml +++ b/.github/workflows/issue_closed.yml @@ -8,6 +8,7 @@ jobs: tidy: name: Tidy closed issues runs-on: ubuntu-latest + if: github.repository == 'element-hq/element-web' steps: - uses: actions/github-script@v7 id: main diff --git a/.github/workflows/localazy_download.yaml b/.github/workflows/localazy_download.yaml index a880c3b2e40..c03ff3e7fce 100644 --- a/.github/workflows/localazy_download.yaml +++ b/.github/workflows/localazy_download.yaml @@ -5,6 +5,7 @@ on: - cron: "0 6 * * 1,3,5" # Every Monday, Wednesday and Friday at 6am UTC jobs: download: + if: github.repository == 'element-hq/element-web' uses: matrix-org/matrix-web-i18n/.github/workflows/localazy_download.yaml@main secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/pending-reviews.yaml b/.github/workflows/pending-reviews.yaml index 5aaae6011b9..29ba28877fd 100644 --- a/.github/workflows/pending-reviews.yaml +++ b/.github/workflows/pending-reviews.yaml @@ -9,6 +9,7 @@ jobs: name: Pending reviews bot runs-on: ubuntu-latest environment: Matrix + if: github.repository == 'element-hq/element-web' env: URL: "https://github.com/pulls?q=is%3Apr+is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+review-requested%3A%40me+sort%3Aupdated-desc+" RELEASE_BLOCKERS_URL: "https://github.com/pulls?q=is%3Aopen+repo%3Amatrix-org%2Fmatrix-js-sdk+repo%3Amatrix-org%2Fmatrix-react-sdk+repo%3Aelement-hq%2Felement-web+repo%3Aelement-hq%2Felement-desktop+sort%3Aupdated-desc+label%3AX-Release-Blocker+" diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml deleted file mode 100644 index 1f49adfcc43..00000000000 --- a/.github/workflows/pull_request.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: Pull Request -on: - pull_request_target: - types: [opened, edited, labeled, unlabeled, synchronize] - merge_group: - types: [checks_requested] -jobs: - action: - uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop - secrets: - ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 2859573d5a8..17123f86450 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -9,6 +9,7 @@ concurrency: cancel-in-progress: true jobs: sonarqube: + if: github.repository == 'element-hq/element-web' name: 🩻 SonarQube uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop secrets: diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index 1d041cd3aca..98e70950091 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -28,9 +28,9 @@ jobs: - name: Typecheck run: "yarn run lint:types" - i18n_lint: - name: "i18n Check" - uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main + # i18n_lint: + # name: "i18n Check" + # uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main js_lint: name: "ESLint" diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index bb22292a64f..a9a61847b49 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -10,6 +10,7 @@ on: - .github/labels.yml jobs: sync-labels: + if: github.repository == 'element-hq/element-web' uses: element-hq/element-meta/.github/workflows/sync-labels.yml@develop with: LABELS: | diff --git a/.github/workflows/triage-assigned.yml b/.github/workflows/triage-assigned.yml index 98112d556f2..79d86c670da 100644 --- a/.github/workflows/triage-assigned.yml +++ b/.github/workflows/triage-assigned.yml @@ -8,9 +8,11 @@ jobs: web-app-team: runs-on: ubuntu-latest if: | + github.repository == 'element-hq/element-web' && ( contains(github.event.issue.assignees.*.login, 't3chguy') || contains(github.event.issue.assignees.*.login, 'andybalaam') || - contains(github.event.issue.assignees.*.login, 'MidhunSureshR') + contains(github.event.issue.assignees.*.login, 'justjanne') + ) steps: - uses: actions/add-to-project@main with: diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml index c5dea9ae6f0..4057c21fcb9 100644 --- a/.github/workflows/triage-incoming.yml +++ b/.github/workflows/triage-incoming.yml @@ -7,6 +7,7 @@ on: jobs: automate-project-columns: runs-on: ubuntu-latest + if: github.repository == 'element-hq/element-web' steps: - uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 with: diff --git a/.github/workflows/triage-move-review-requests.yml b/.github/workflows/triage-move-review-requests.yml index 9d2924c1a92..f7113bfb036 100644 --- a/.github/workflows/triage-move-review-requests.yml +++ b/.github/workflows/triage-move-review-requests.yml @@ -7,6 +7,7 @@ jobs: add_design_pr_to_project: name: Move PRs asking for design review to the design board runs-on: ubuntu-latest + if: github.repository == 'element-hq/element-web' steps: - uses: octokit/graphql-action@v2.x id: find_team_members @@ -74,6 +75,7 @@ jobs: add_product_pr_to_project: name: Move PRs asking for design review to the design board runs-on: ubuntu-latest + if: github.repository == 'element-hq/element-web' steps: - uses: octokit/graphql-action@v2.x id: find_team_members diff --git a/.github/workflows/update-jitsi.yml b/.github/workflows/update-jitsi.yml index e2e9d9cb94f..d5b44bd5f89 100644 --- a/.github/workflows/update-jitsi.yml +++ b/.github/workflows/update-jitsi.yml @@ -6,6 +6,7 @@ on: - cron: "0 3 * * 0" # 3am every Sunday jobs: update: + if: github.repository == 'element-hq/element-web' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/upgrade_dependencies.yml b/.github/workflows/upgrade_dependencies.yml new file mode 100644 index 00000000000..1aee198a8aa --- /dev/null +++ b/.github/workflows/upgrade_dependencies.yml @@ -0,0 +1,9 @@ +name: Upgrade Dependencies +on: + workflow_dispatch: {} +jobs: + upgrade: + uses: matrix-org/matrix-js-sdk/.github/workflows/upgrade_dependencies.yml@develop + if: github.repository == 'element-hq/element-web' + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/components.json b/components.json index cc5046ed695..e16fd910924 100644 --- a/components.json +++ b/components.json @@ -1,5 +1,28 @@ { "src/components/views/auth/AuthFooter.tsx": "src/components/views/auth/VectorAuthFooter.tsx", "src/components/views/auth/AuthHeaderLogo.tsx": "src/components/views/auth/VectorAuthHeaderLogo.tsx", - "src/components/views/auth/AuthPage.tsx": "src/components/views/auth/VectorAuthPage.tsx" + "src/components/views/auth/AuthPage.tsx": "src/components/views/auth/VectorAuthPage.tsx", + "src/components/views/rooms/MessageComposer.tsx": "src/components/views/rooms/MessageComposer.tsx", + "src/components/views/rooms/RoomTile.tsx": "src/components/views/rooms/RoomTile.tsx", + "src/components/views/rooms/RoomPreviewBar.tsx": "src/components/views/rooms/RoomPreviewBar.tsx", + "src/components/views/rooms/NewRoomIntro.tsx": "src/components/views/rooms/NewRoomIntro.tsx", + "src/components/views/elements/RoomName.tsx": "src/components/views/elements/RoomName.tsx", + "src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx": "src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx", + "src/components/views/avatars/BaseAvatar.tsx": "src/components/views/avatars/BaseAvatar.tsx", + "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/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", + "src/components/structures/HomePage.tsx": "src/components/structures/HomePage.tsx", + "src/components/views/dialogs/spotlight/SpotlightDialog.tsx": "src/components/views/dialogs/spotlight/SpotlightDialog.tsx", + "src/components/views/elements/Pill.tsx": "src/components/views/elements/Pill.tsx", + "src/linkify-matrix.ts": "src/linkify-matrix.ts", + "src/components/structures/LeftPanel.tsx": "src/components/structures/LeftPanel.tsx", + "src/components/views/rooms/RoomList.tsx": "src/components/views/rooms/RoomList.tsx", + "src/components/views/rooms/RoomSublist.tsx": "src/components/views/rooms/RoomSublist.tsx", + "src/components/views/dialogs/FeedbackDialog.tsx": "src/components/views/dialogs/FeedbackDialog.tsx", + "src/components/views/user-onboarding/UserOnboardingHeader.tsx": "src/components/views/user-onboarding/UserOnboardingHeader.tsx", + "src/components/views/dialogs/AppDownloadDialog.tsx": "src/components/views/dialogs/AppDownloadDialog.tsx" } diff --git a/config.sample.json b/config.sample.json index 579b28619a6..ff5e158548d 100644 --- a/config.sample.json +++ b/config.sample.json @@ -1,18 +1,20 @@ { "default_server_config": { "m.homeserver": { - "base_url": "https://matrix-client.matrix.org", - "server_name": "matrix.org" + "base_url": "https://matrix.superhero.com", + "server_name": "superhero.com" }, "m.identity_server": { "base_url": "https://vector.im" } }, + "bots_backend_url": "https://matrix.superhero.com/walletbot", + "permalink_prefix": "https://chat.superhero.com", "disable_custom_urls": false, "disable_guests": false, "disable_login_language_selector": false, "disable_3pid_login": false, - "brand": "Element", + "brand": "Superhero", "integrations_ui_url": "https://scalar.vector.im/", "integrations_rest_url": "https://scalar.vector.im/api", "integrations_widgets_urls": [ @@ -45,5 +47,14 @@ "participant_limit": 8, "brand": "Element Call" }, - "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx" + "map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx", + "branding": { + "auth_header_logo_url": "themes/superhero/img/logos/superhero-logo.svg" + }, + "feedback": { + "existing_issues_url": "https://github.com/superhero-com/superhero-chat-web/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc", + "new_issue_url": "https://github.com/superhero-com/superhero-chat-web/issues/new/choose" + }, + "bug_report_endpoint_url": "https://github.com/superhero-com/superhero-chat-web/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc", + "support_channel_room_id": "#superhero_feedback:superhero.chat" } diff --git a/package.json b/package.json index 8187e34c39e..7dd53e2f53a 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@matrix-org/olm": "3.2.15", "@matrix-org/react-sdk-module-api": "^2.3.0", "gfm.css": "^1.1.2", + "jotai": "^2.6.0", "jsrsasign": "^11.0.0", "katex": "^0.16.0", "lodash": "^4.17.21", @@ -190,6 +191,7 @@ "process": "^0.11.10", "proxy-agent": "^6.3.0", "raw-loader": "^4.0.2", + "react-beautiful-dnd": "^13.1.0", "rimraf": "^5.0.0", "semver": "^7.5.2", "setimmediate": "^1.0.5", diff --git a/res/css/structures/ErrorView.pcss b/res/css/structures/ErrorView.pcss index 704c68c1e96..3f07e1b9ea7 100644 --- a/res/css/structures/ErrorView.pcss +++ b/res/css/structures/ErrorView.pcss @@ -46,7 +46,7 @@ limitations under the License. margin-left: 4px; margin-right: 4px; min-width: 80px; - background-color: #03b381; + background-color: #1161fe; color: #fff; cursor: pointer; padding: 12px 22px; diff --git a/res/css/superhero/custom.css b/res/css/superhero/custom.css new file mode 100644 index 00000000000..86d44079047 --- /dev/null +++ b/res/css/superhero/custom.css @@ -0,0 +1,349 @@ +.sh_RoomTokenGatedRoom, +.mx_InviteDialog_tile_nameStack_name { + align-items: center; + display: flex; +} + +.sh_RoomTokenGatedRoomIcon, +.sh_VerifiedIcon { + width: 16px; + height: 16px; + margin-right: 4px; +} + +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"); +} + +/* 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; + /* font-weight: bold; */ +} + +/* START - Custom Side Panel styling */ +.mx_SpacePanel { + --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_SpaceButton_home .mx_SpaceButton_icon { + background: white !important; + outline: 1px solid white; +} + +.mx_SpacePanel .mx_SpaceButton_avatarWrapper .mx_BaseAvatar { + border: 0px solid white; + outline: 2px solid white; + /* border-radius: 9px; */ +} + +.mx_SpacePanel .mx_SpaceButton.mx_SpaceButton_active .mx_BaseAvatar { + border: 0px solid white; + outline: 1px solid white; +} + +.mx_SpacePanel .mx_SpaceButton.mx_SpaceButton_active.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { + 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; +} + +.mx_NotificationBadge_visible { + background-color: black !important; + font-weight: 700 !important; +} + +.mx_SpacePanel .mx_SpaceButton_active .mx_SpaceButton_avatarWrapper .mx_BaseAvatar { + border: 0px solid white; +} + +.mx_SpacePanel .mx_SpaceButton .mx_SpaceButton_name { + color: white !important; + margin-left: 14px !important; +} + +.mx_SpacePanel .mx_SpaceButton .mx_SpaceButton_selectionWrapper { + padding-right: 20px !important; +} + +.mx_SpacePanel.collapsed .mx_SpaceButton .mx_SpaceButton_selectionWrapper { + padding-right: 0px !important; +} + +.mx_SpacePanel .mx_AccessibleButton.mx_SpacePanel_toggleCollapse { + background-color: rgb(36, 36, 36) !important; +} + +.mx_AccessibleButton.mx_LegacyRoomHeader_button.mx_AccessibleButton_disabled { + display: none; +} + +.cpd-theme-dark .mx_SpacePanel .mx_AccessibleButton.mx_SpacePanel_toggleCollapse { + background-color: white !important; +} + +.mx_SpacePanel .mx_QuickSettingsButton { + color: white !important; +} + +.mx_SpacePanel .mx_QuickSettingsButton::before { + background-color: white !important; +} + +.mx_SpacePanel .mx_QuickSettingsButton:not(.expanded):hover { + background-color: rgba(255, 255, 255, 0.3) !important; +} + +.mx_ToastContainer { + top: 3px !important; + left: 72px !important; +} + +.mx_Toast_toast { + 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-secondary: black!important; */ +} + +.mx_LegacyRoomHeader_button:hover { + 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; +} + +.mx_BaseCard ._item_zxa40_17 { + background: transparent !important; +} + +.cpd-theme-dark .mx_BaseCard { + 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; +} + +.cpd-theme-dark .mx_LeftPanel .mx_LeftPanel_roomListContainer { + 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-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.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; +} + +.cpd-theme-dark .mx_NotificationBadge_visible { + background-color: white !important; +} + +.cpd-theme-dark .mx_NotificationBadge_visible .mx_NotificationBadge_count { + color: black !important; +} + +/* CPD Light Theme Overwrite */ +.cpd-theme-light.cpd-theme-light { + --cpd-color-text-action-accent: #6147ff !important; + --cpd-color-alpha-gray-500: hsla(212, 87%, 15%, 0.2); + --cpd-color-pink-1200: #a80298 !important; + --cpd-color-fuchsia-1200: #8201aa !important; + --cpd-color-purple-1200: #5f01ed !important; + --cpd-color-blue-1200: #0530cd !important; + --cpd-color-cyan-1200: #02b5c5 !important; + --cpd-color-green-1200: #009e3d !important; + --cpd-color-green-900: #02a769 !important; + --cpd-color-lime-1200: #00b300 !important; + --cpd-color-yellow-1200: #c09000 !important; + --cpd-color-orange-1200: #d4570f !important; + --cpd-color-orange-1000: #ac3300; + --cpd-color-red-1100: #a4041d !important; + --cpd-color-gray-1200: #3c4045; + --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-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-canvas-default-level-1: var(--cpd-color-gray-300); + --cpd-color-alpha-gray-500: hsla(214, 41%, 97%, 0.15); + --cpd-color-pink-1200: #c81fb7 !important; + --cpd-color-pink-300: #544352 !important; + --cpd-color-fuchsia-1200: #d538ee !important; + --cpd-color-fuchsia-300: #52424f !important; + --cpd-color-purple-1200: #9a30fd !important; + --cpd-color-purple-300: #443f4c !important; + --cpd-color-blue-1200: #006aff !important; + --cpd-color-blue-300: #414852 !important; + --cpd-color-cyan-1200: #08eaff !important; + --cpd-color-cyan-300: #374445 !important; + --cpd-color-green-1200: #00ff80 !important; + --cpd-color-green-900: #00ff80 !important; + --cpd-color-green-400: #3f4d46 !important; + --cpd-color-green-300: #3f4d46 !important; + --cpd-color-lime-1200: #48ff00 !important; + --cpd-color-lime-300: #414d3c !important; + --cpd-color-yellow-1200: #ffe100 !important; + --cpd-color-yellow-300: #514e3e !important; + --cpd-color-orange-1200: #ff7700 !important; + --cpd-color-orange-1000: #eb7a12; + --cpd-color-orange-300: #574c42 !important; + --cpd-color-red-1200: #ff2600 !important; + --cpd-color-red-300: #51423f !important; + --cpd-color-gray-1200: #bdc3cc; +} + +.mx_HomePage_title { + font-size: 38px; + font-weight: 700; + line-height: 45px; + letter-spacing: 0em; + text-align: left; + color: #1b1d22; + margin-bottom: 24px; + align-items: center; + display: flex; + align-items: center; +} + +@media (max-width: 1250px) { + .mx_HomePage_title { + flex-direction: column; + } +} + +.cpd-theme-dark .mx_HomePage_title { + color: #ffffff; +} + +.mx_HomePage_title svg { + height: 44px; + width: 173.99px; + margin-right: 8px; +} + +.mx_HomePage_default_wrapper .chat_screen_shot { + max-width: 70%; +} + +.mx_HomePage_default_buttons_title { + font-size: 30px; +} + +.cpd-theme-dark .mx_HomePage_default_buttons_title { + color: rgba(255, 255, 255, 0.7); +} + +.cpd-theme-dark .mx_HomePage_default_buttons_title span { + color: rgba(255, 255, 255, 1); +} + +.mx_HomePage_default .mx_HomePage_default_buttons { + margin: 10px auto 0 !important; +} + +.cpd-theme-dark .mx_HomePage_default .mx_HomePage_default_buttons.browsers .mx_HomePage_button_custom { + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.mx_HomePage_default .mx_HomePage_default_buttons .mx_AccessibleButton.mx_HomePage_button_custom { + width: auto !important; + min-height: auto !important; + padding: 16px 30px !important; + align-items: center; + display: flex; + flex-direction: column; +} +.mx_HomePage_default .mx_HomePage_default_buttons .mx_AccessibleButton.mx_HomePage_button_custom svg { + width: 50px !important; + height: 50px !important; + display: block !important; + margin-bottom: 4px; +} + +.mx_HomePage_default .mx_HomePage_default_buttons .mx_AccessibleButton.mx_HomePage_button_custom::before { + display: none !important; +} + +.mx_RoomGeneralContextMenu_iconSignOut::before { + -webkit-mask-image: url("../../themes/superhero/img/icons/leave.svg") !important; + mask-image: url("../../themes/superhero/img/icons/leave.svg") !important; +} + +.mx_RoomTile_contextMenu .mx_RoomTile_iconSignOut::before { + -webkit-mask-image: url("../../themes/superhero/img/icons/leave.svg") !important; + mask-image: url("../../themes/superhero/img/icons/leave.svg") !important; +} + +.mx_IconizedContextMenu .mx_IconizedContextMenu_option_red .mx_IconizedContextMenu_icon::before { + -webkit-mask-image: url("../../themes/superhero/img/icons/leave.svg") !important; + mask-image: url("../../themes/superhero/img/icons/leave.svg") !important; +} + +.cpd-theme-dark svg.light_logo { + display: none !important; +} + +.cpd-theme-dark svg.dark_logo { + display: block !important; +} + +.cpd-theme-light svg.dark_logo { + display: none !important; +} + +.cpd-theme-light svg.light_logo { + display: block !important; +} diff --git a/res/css/superhero/onboarding.css b/res/css/superhero/onboarding.css new file mode 100644 index 00000000000..ab04c369fe4 --- /dev/null +++ b/res/css/superhero/onboarding.css @@ -0,0 +1,184 @@ +/** + * Bot Icon + */ +.cpd-theme-dark .lightChatBot { + display: none !important; +} + +.cpd-theme-dark .darkChatBot { + display: block !important; +} + +.cpd-theme-light .darkChatBot { + display: none !important; +} + +.cpd-theme-light .lightChatBot { + display: block !important; +} + +.sh_userOnboarding_bot_art { + min-width: 150px; + max-width: 100%; +} + +.sh_userOnboarding { + flex: 1; + background: var(--cpd-color-gray-300); + border-radius: 16px; + max-width: 90%; +} + +.sh_userOnboarding_container { + flex: 1; + display: flex; + flex-direction: column; + padding-left: 32px; + border-radius: 16px; + background: linear-gradient(180deg, rgba(97, 71, 255, 0.15) 0%, transparent 100%); +} +.cpd-theme-light .sh_userOnboarding_container { + background: linear-gradient(180deg, rgba(37, 40, 45, 0.15) 0%, transparent 100%); + +} + +.sh_userOnboarding_bot_art { + flex: 0.3; + display: flex; + margin-top: -180px; + border-top-right-radius: 16px; + background: transparent; +} + +.sh_userOnboarding_bot_art img { + width: 100%; + height: 100%; +} +.sh_userOnboarding_text { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 70%; + margin-top: 32px; +} + +.sh_userOnboarding_content { + display: flex; + flex-direction: row-reverse; + flex-wrap: wrap; +} +.sh_userOnboarding h1 { + font-weight: clamp(510, 0.8rem + 2.5vw, 700); + font-size: clamp(1.375rem, 0.375rem + 2.5vw, 2.625rem); + line-height: clamp(1.5rem, 0.375rem + 2.5vw, 2.625rem); + margin: 0px; + padding: 0px; +} + +.sh_userOnboarding h3 { + font-size: clamp(1rem, 2.5vw, 2rem); + margin: 0px; + padding: 0px; +} + +.sh_userOnboarding p { + opacity: 70%; + font-weight: clamp(400, 0.8rem + 1.5vw, 510); + font-size: clamp(0.8rem, 0.6rem + 0.5vw, 1rem); + line-height: clamp(1.25rem, 0.8rem + 0.5vw, 1.5rem); + margin: 0px; + padding: 0px; +} + +.sh_userOnboarding_left { + flex: 0.7; + display: flex; + flex-direction: column; + gap: 8px; + margin-right: 20px; + /* margin-top: 20px; */ + padding-bottom: 32px; +} +.sh_userOnboarding_left_content { + display: flex; + flex-direction: column; + gap: 25px; +} + +.sh_userOnboarding_download { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sh_userOnboarding_download_link { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 24px; +} + +.sh_userOnboarding_download_option { + display: flex; + flex-direction: row; + background-color: rgba(58, 61, 66, 1); + border-radius: 8px; + padding: 7px 18px; + align-items: center; + justify-content: center; + align-items: center; + gap: 8px; + font-weight: 510; + font-size: 16px; + line-height: 22px; + min-width: 248px; + max-width: 100%; +} + +.cpd-theme-light .sh_userOnboarding_download_option { + color: #FFF; +} + +.sh_userOnboarding_btn { + border-radius: 8px; + padding: 7px 18px; + margin-top: 5px; +} + +@media (max-width: 1025px) { + .sh_userOnboarding_text { + max-width: 100%; + } + .sh_userOnboarding_bot_art { + background: linear-gradient( + 180deg, + transparent 0%, + rgba(97, 71, 255, 0.15) 100% + ); + border-bottom-right-radius: 16px; + border-top-right-radius: 0px; + } + + .sh_userOnboarding_container { + background: transparent; + } + .sh_userOnboarding_bot_art { + margin-top: -30px; + } + +} + +@media (max-width: 960px) { + .sh_userOnboarding_text { + max-width: 70%; + } +} + +@media (max-width: 770px) { + .sh_userOnboarding_text { + max-width: 100%; + } + .sh_userOnboarding_bot_art { + margin-top: -30px; + } +} diff --git a/res/manifest.json b/res/manifest.json index f6f1e91bf47..fbc3b6805f2 100644 --- a/res/manifest.json +++ b/res/manifest.json @@ -84,7 +84,7 @@ }, { "platform": "itunes", - "url": "https://apps.apple.com/app/vector/id1083446067" + "url": "https://apps.apple.com/app/superhero-chat/id6466815315" } ] } diff --git a/res/themes/superhero/img/arts/chat-bot-dark.png b/res/themes/superhero/img/arts/chat-bot-dark.png new file mode 100644 index 00000000000..dfe994be930 Binary files /dev/null and b/res/themes/superhero/img/arts/chat-bot-dark.png differ diff --git a/res/themes/superhero/img/arts/chat-bot.png b/res/themes/superhero/img/arts/chat-bot.png new file mode 100644 index 00000000000..1720fc0cb1f Binary files /dev/null and b/res/themes/superhero/img/arts/chat-bot.png differ diff --git a/res/themes/superhero/img/arts/chat-screenshot.svg b/res/themes/superhero/img/arts/chat-screenshot.svg new file mode 100644 index 00000000000..5638ab7ede0 --- /dev/null +++ b/res/themes/superhero/img/arts/chat-screenshot.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/backgrounds/gradient-bg.svg b/res/themes/superhero/img/backgrounds/gradient-bg.svg new file mode 100644 index 00000000000..24839ef6b67 --- /dev/null +++ b/res/themes/superhero/img/backgrounds/gradient-bg.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/download/apple.svg b/res/themes/superhero/img/download/apple.svg new file mode 100644 index 00000000000..9de39edc8f3 --- /dev/null +++ b/res/themes/superhero/img/download/apple.svg @@ -0,0 +1,97 @@ + + Download on the App Store. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/download/fdroid.svg b/res/themes/superhero/img/download/fdroid.svg new file mode 100644 index 00000000000..847196f54b2 --- /dev/null +++ b/res/themes/superhero/img/download/fdroid.svg @@ -0,0 +1,135 @@ + + Get it on F-Droid. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/download/google.svg b/res/themes/superhero/img/download/google.svg new file mode 100644 index 00000000000..d54aca167c6 --- /dev/null +++ b/res/themes/superhero/img/download/google.svg @@ -0,0 +1,70 @@ + + Download on the Google Play Store. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/icons/chrome.svg b/res/themes/superhero/img/icons/chrome.svg new file mode 100644 index 00000000000..e88225dcdee --- /dev/null +++ b/res/themes/superhero/img/icons/chrome.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/res/themes/superhero/img/icons/community-room.svg b/res/themes/superhero/img/icons/community-room.svg new file mode 100644 index 00000000000..6a7f08679ea --- /dev/null +++ b/res/themes/superhero/img/icons/community-room.svg @@ -0,0 +1,8 @@ + + + + diff --git a/res/themes/superhero/img/icons/diamond.svg b/res/themes/superhero/img/icons/diamond.svg new file mode 100644 index 00000000000..605a7794cf4 --- /dev/null +++ b/res/themes/superhero/img/icons/diamond.svg @@ -0,0 +1,5 @@ + + + diff --git a/res/themes/superhero/img/icons/firefox.svg b/res/themes/superhero/img/icons/firefox.svg new file mode 100644 index 00000000000..758bb739785 --- /dev/null +++ b/res/themes/superhero/img/icons/firefox.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/icons/leave.svg b/res/themes/superhero/img/icons/leave.svg new file mode 100644 index 00000000000..1434c924df2 --- /dev/null +++ b/res/themes/superhero/img/icons/leave.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/themes/superhero/img/icons/send.svg b/res/themes/superhero/img/icons/send.svg new file mode 100644 index 00000000000..e938bf88aab --- /dev/null +++ b/res/themes/superhero/img/icons/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/superhero/img/icons/tokengated-room.svg b/res/themes/superhero/img/icons/tokengated-room.svg new file mode 100644 index 00000000000..61437ce3701 --- /dev/null +++ b/res/themes/superhero/img/icons/tokengated-room.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/res/themes/superhero/img/icons/verified.svg b/res/themes/superhero/img/icons/verified.svg new file mode 100644 index 00000000000..a8b27cbc3ea --- /dev/null +++ b/res/themes/superhero/img/icons/verified.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/themes/superhero/img/logos/dark-logo.svg b/res/themes/superhero/img/logos/dark-logo.svg new file mode 100644 index 00000000000..6f7ef829720 --- /dev/null +++ b/res/themes/superhero/img/logos/dark-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/themes/superhero/img/logos/logo-small.svg b/res/themes/superhero/img/logos/logo-small.svg new file mode 100644 index 00000000000..84dc8a0097d --- /dev/null +++ b/res/themes/superhero/img/logos/logo-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/superhero/img/logos/superhero-dark.svg b/res/themes/superhero/img/logos/superhero-dark.svg new file mode 100644 index 00000000000..72fcc8609b2 --- /dev/null +++ b/res/themes/superhero/img/logos/superhero-dark.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/logos/superhero-light.svg b/res/themes/superhero/img/logos/superhero-light.svg new file mode 100644 index 00000000000..186befc10ed --- /dev/null +++ b/res/themes/superhero/img/logos/superhero-light.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/superhero/img/logos/superhero-logo.svg b/res/themes/superhero/img/logos/superhero-logo.svg new file mode 100644 index 00000000000..f87ee958943 --- /dev/null +++ b/res/themes/superhero/img/logos/superhero-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/res/vector-icons/1024.png b/res/vector-icons/1024.png index 83d6a16b113..3a1c0ddaa42 100644 Binary files a/res/vector-icons/1024.png and b/res/vector-icons/1024.png differ diff --git a/res/vector-icons/120.png b/res/vector-icons/120.png index 48b8ecf91cb..fd6b36db7d5 100644 Binary files a/res/vector-icons/120.png and b/res/vector-icons/120.png differ diff --git a/res/vector-icons/1240x600.png b/res/vector-icons/1240x600.png index aeaa12de425..0b2e4efaf86 100644 Binary files a/res/vector-icons/1240x600.png and b/res/vector-icons/1240x600.png differ diff --git a/res/vector-icons/150.png b/res/vector-icons/150.png index 14d589a0263..9b471ad90af 100644 Binary files a/res/vector-icons/150.png and b/res/vector-icons/150.png differ diff --git a/res/vector-icons/152.png b/res/vector-icons/152.png index 65d42b33e96..5b0900a3a62 100644 Binary files a/res/vector-icons/152.png and b/res/vector-icons/152.png differ diff --git a/res/vector-icons/180.png b/res/vector-icons/180.png index 7de76f75e3f..49aab55be76 100644 Binary files a/res/vector-icons/180.png and b/res/vector-icons/180.png differ diff --git a/res/vector-icons/24.png b/res/vector-icons/24.png index 87355fe9bb9..52b4b0a6e23 100644 Binary files a/res/vector-icons/24.png and b/res/vector-icons/24.png differ diff --git a/res/vector-icons/300.png b/res/vector-icons/300.png index 1f1b42f3527..c2b08f318de 100644 Binary files a/res/vector-icons/300.png and b/res/vector-icons/300.png differ diff --git a/res/vector-icons/44.png b/res/vector-icons/44.png index a60d3a02f29..025e84e74a7 100644 Binary files a/res/vector-icons/44.png and b/res/vector-icons/44.png differ diff --git a/res/vector-icons/50.png b/res/vector-icons/50.png index 23e1eaa3976..5b3fc57c37d 100644 Binary files a/res/vector-icons/50.png and b/res/vector-icons/50.png differ diff --git a/res/vector-icons/620x300.png b/res/vector-icons/620x300.png index 2bf3805f573..bf7a1183cb7 100644 Binary files a/res/vector-icons/620x300.png and b/res/vector-icons/620x300.png differ diff --git a/res/vector-icons/76.png b/res/vector-icons/76.png index e44a47fc08a..09c2ad280b7 100644 Binary files a/res/vector-icons/76.png and b/res/vector-icons/76.png differ diff --git a/res/vector-icons/88.png b/res/vector-icons/88.png index 98e44a9176e..fd3619217e4 100644 Binary files a/res/vector-icons/88.png and b/res/vector-icons/88.png differ diff --git a/res/vector-icons/apple-touch-icon-114.png b/res/vector-icons/apple-touch-icon-114.png index 16b0b289d51..244920f784e 100644 Binary files a/res/vector-icons/apple-touch-icon-114.png and b/res/vector-icons/apple-touch-icon-114.png differ diff --git a/res/vector-icons/apple-touch-icon-120.png b/res/vector-icons/apple-touch-icon-120.png index 1c91a55ef10..fd6b36db7d5 100644 Binary files a/res/vector-icons/apple-touch-icon-120.png and b/res/vector-icons/apple-touch-icon-120.png differ diff --git a/res/vector-icons/apple-touch-icon-144.png b/res/vector-icons/apple-touch-icon-144.png index 354259f5d78..2a79af5528c 100644 Binary files a/res/vector-icons/apple-touch-icon-144.png and b/res/vector-icons/apple-touch-icon-144.png differ diff --git a/res/vector-icons/apple-touch-icon-152.png b/res/vector-icons/apple-touch-icon-152.png index dace2c8bc29..5b0900a3a62 100644 Binary files a/res/vector-icons/apple-touch-icon-152.png and b/res/vector-icons/apple-touch-icon-152.png differ diff --git a/res/vector-icons/apple-touch-icon-180.png b/res/vector-icons/apple-touch-icon-180.png index e9409c84b94..49aab55be76 100644 Binary files a/res/vector-icons/apple-touch-icon-180.png and b/res/vector-icons/apple-touch-icon-180.png differ diff --git a/res/vector-icons/apple-touch-icon-57.png b/res/vector-icons/apple-touch-icon-57.png index d2e5a516b80..ac840a78e44 100644 Binary files a/res/vector-icons/apple-touch-icon-57.png and b/res/vector-icons/apple-touch-icon-57.png differ diff --git a/res/vector-icons/apple-touch-icon-60.png b/res/vector-icons/apple-touch-icon-60.png index 7c37f2ec313..566f0e41016 100644 Binary files a/res/vector-icons/apple-touch-icon-60.png and b/res/vector-icons/apple-touch-icon-60.png differ diff --git a/res/vector-icons/apple-touch-icon-72.png b/res/vector-icons/apple-touch-icon-72.png index b580cc52c01..166b5331340 100644 Binary files a/res/vector-icons/apple-touch-icon-72.png and b/res/vector-icons/apple-touch-icon-72.png differ diff --git a/res/vector-icons/apple-touch-icon-76.png b/res/vector-icons/apple-touch-icon-76.png index e37586cc991..09c2ad280b7 100644 Binary files a/res/vector-icons/apple-touch-icon-76.png and b/res/vector-icons/apple-touch-icon-76.png differ diff --git a/res/vector-icons/favicon.ico b/res/vector-icons/favicon.ico index 6ad5fb08651..07faac2e015 100644 Binary files a/res/vector-icons/favicon.ico and b/res/vector-icons/favicon.ico differ diff --git a/res/vector-icons/mstile-150.png b/res/vector-icons/mstile-150.png index 79fe7a1bd19..9b471ad90af 100644 Binary files a/res/vector-icons/mstile-150.png and b/res/vector-icons/mstile-150.png differ diff --git a/res/vector-icons/mstile-310.png b/res/vector-icons/mstile-310.png index cfafd744d77..8d2d8eb3a54 100644 Binary files a/res/vector-icons/mstile-310.png and b/res/vector-icons/mstile-310.png differ diff --git a/res/vector-icons/mstile-310x150.png b/res/vector-icons/mstile-310x150.png index bd001159ecc..39303eb547a 100644 Binary files a/res/vector-icons/mstile-310x150.png and b/res/vector-icons/mstile-310x150.png differ diff --git a/res/vector-icons/mstile-70.png b/res/vector-icons/mstile-70.png index 7470321851c..bb2aaeb20c9 100644 Binary files a/res/vector-icons/mstile-70.png and b/res/vector-icons/mstile-70.png differ diff --git a/res/welcome.html b/res/welcome.html index ef2d43bd8ff..7d812da14b6 100644 --- a/res/welcome.html +++ b/res/welcome.html @@ -116,7 +116,7 @@ } .mx_ButtonCreateAccount { - background-color: #0dbd8b; + background-color: #1161fe; color: white !important; } @@ -166,12 +166,11 @@
- + -

_t("welcome_to_element")

+

_t("welcome_to_superhero")

-

_t("powered_by_matrix_with_logo")

diff --git a/scripts/layered.sh b/scripts/layered.sh index 3622d411816..fdc54fb06df 100755 --- a/scripts/layered.sh +++ b/scripts/layered.sh @@ -17,7 +17,7 @@ set -ex yarn install --frozen-lockfile # Pass appropriate repo to fetchdep.sh -export PR_ORG=element-hq +export PR_ORG=superhero-com export PR_REPO=element-web # Set up the js-sdk first diff --git a/src/async-components/structures/CompatibilityView.tsx b/src/async-components/structures/CompatibilityView.tsx index a9f1cce51af..7b248eb27a5 100644 --- a/src/async-components/structures/CompatibilityView.tsx +++ b/src/async-components/structures/CompatibilityView.tsx @@ -136,8 +136,8 @@ const CompatibilityView: React.FC = ({ onAccept }) => {

- - {_t("go_to_element_io")} + + {_t("go_to_chat_superhero_com")}

diff --git a/src/async-components/structures/ErrorView.tsx b/src/async-components/structures/ErrorView.tsx index 25ce1465c08..5d4db716699 100644 --- a/src/async-components/structures/ErrorView.tsx +++ b/src/async-components/structures/ErrorView.tsx @@ -34,7 +34,7 @@ const ErrorView: React.FC = ({ title, messages }) => {
- Element + Superhero

{_t("failed_to_start")}

@@ -48,8 +48,8 @@ const ErrorView: React.FC = ({ title, messages }) => {

- - {_t("go_to_element_io")} + + {_t("go_to_chat_superhero_com")}

diff --git a/src/atoms.ts b/src/atoms.ts new file mode 100644 index 00000000000..2d87f295a2d --- /dev/null +++ b/src/atoms.ts @@ -0,0 +1,27 @@ +import { atomWithStorage } from "jotai/utils"; +import { getDefaultStore } from "jotai/index"; + +type TokenThreshold = { + threshold: string; + symbol: string; +}; + +export type BareUser = { + userId: string; + rawDisplayName: string; +}; + +type BotAccounts = { + communityBot: string; + superheroBot: string; + blockchainBot: string; +}; + +export const verifiedAccountsAtom = atomWithStorage>("VERIFIED_ACCOUNTS", {}); +export const botAccountsAtom = atomWithStorage("BOT_ACCOUNTS", null); +export const minimumTokenThresholdAtom = atomWithStorage>("TOKEN_THRESHOLD", {}); + +export function getBotAccountData(): BotAccounts | null { + const defaultStore = getDefaultStore(); + return defaultStore.get(botAccountsAtom) as BotAccounts | null; +} diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts new file mode 100644 index 00000000000..de058fe38e7 --- /dev/null +++ b/src/autocomplete/Autocompleter.ts @@ -0,0 +1,117 @@ +/* +Copyright 2016 Aviral Dasgupta +Copyright 2017, 2018 New Vector Ltd + +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 { Room } from "matrix-js-sdk/src/matrix"; +import AutocompleteProvider, { ICommand } from "matrix-react-sdk/src/autocomplete/AutocompleteProvider"; +import EmojiProvider from "matrix-react-sdk/src/autocomplete/EmojiProvider"; +import NotifProvider from "matrix-react-sdk/src/autocomplete/NotifProvider"; +import RoomProvider from "matrix-react-sdk/src/autocomplete/RoomProvider"; +import SpaceProvider from "matrix-react-sdk/src/autocomplete/SpaceProvider"; +import UserProvider from "matrix-react-sdk/src/autocomplete/UserProvider"; +import { TimelineRenderingType } from "matrix-react-sdk/src/contexts/RoomContext"; +import { filterBoolean } from "matrix-react-sdk/src/utils/arrays"; +import { timeout } from "matrix-react-sdk/src/utils/promise"; +import { ReactElement } from "react"; + +export interface ISelectionRange { + beginning?: boolean; // whether the selection is in the first block of the editor or not + start: number; // byte offset relative to the start anchor of the current editor selection. + end: number; // byte offset relative to the end anchor of the current editor selection. +} + +export interface ICompletion { + type?: "at-room" | "command" | "community" | "room" | "user"; + completion: string; + completionId?: string; + component: ReactElement; + range: ISelectionRange; + command?: string; + suffix?: string; + // If provided, apply a LINK entity to the completion with the + // data = { url: href }. + href?: string; +} + +const PROVIDERS = [UserProvider, RoomProvider, EmojiProvider, NotifProvider, SpaceProvider]; + +// Providers will get rejected if they take longer than this. +const PROVIDER_COMPLETION_TIMEOUT = 3000; + +export interface IProviderCompletions { + completions: ICompletion[]; + provider: AutocompleteProvider; + command: Partial; +} + +export default class Autocompleter { + public room: Room; + public providers: AutocompleteProvider[]; + + public constructor(room: Room, renderingType: TimelineRenderingType = TimelineRenderingType.Room) { + this.room = room; + this.providers = PROVIDERS.map((Prov) => { + return new Prov(room, renderingType); + }); + } + + public destroy(): void { + this.providers.forEach((p) => { + p.destroy(); + }); + } + + public async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { + /* Note: This intentionally waits for all providers to return, + otherwise, we run into a condition where new completions are displayed + while the user is interacting with the list, which makes it difficult + to predict whether an action will actually do what is intended + */ + // list of results from each provider, each being a list of completions or null if it times out + const completionsList: Array = await Promise.all( + this.providers.map(async (provider): Promise => { + return timeout( + provider.getCompletions(query, selection, force, limit), + null, + PROVIDER_COMPLETION_TIMEOUT, + ); + }), + ); + + // map then filter to maintain the index for the map-operation, for this.providers to line up + return filterBoolean( + completionsList.map((completions, i) => { + if (!completions || !completions.length) return; + + return { + completions, + provider: this.providers[i], + + /* the currently matched "command" the completer tried to complete + * we pass this through so that Autocomplete can figure out when to + * re-show itself once hidden. + */ + command: this.providers[i].getCurrentCommand(query, selection, force), + }; + }), + ); + } +} diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx new file mode 100644 index 00000000000..b10b4a30943 --- /dev/null +++ b/src/components/structures/HomePage.tsx @@ -0,0 +1,53 @@ +/* +Copyright 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 SdkConfig from "matrix-react-sdk/src/SdkConfig"; +import AutoHideScrollbar from "matrix-react-sdk/src/components/structures/AutoHideScrollbar"; +import EmbeddedPage from "matrix-react-sdk/src/components/structures/EmbeddedPage"; +import { useMatrixClientContext } from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import { getHomePageUrl } from "matrix-react-sdk/src/utils/pages"; +import { useUserOnboardingTasks } from "matrix-react-sdk/src/hooks/useUserOnboardingTasks"; +import { UserOnboardingList } from "matrix-react-sdk/src/components/views/user-onboarding/UserOnboardingList"; +import { useUserOnboardingContext } from "matrix-react-sdk/src/hooks/useUserOnboardingContext"; +import * as React from "react"; + +import { UserOnboardingHeader } from "../views/user-onboarding/UserOnboardingHeader"; + +interface IProps { + justRegistered?: boolean; +} + +const HomePage: React.FC = () => { + const cli = useMatrixClientContext(); + const config: any = SdkConfig.get(); + const pageUrl = getHomePageUrl(config, cli); + + const context = useUserOnboardingContext(); + const tasks = useUserOnboardingTasks(context); + + if (pageUrl) { + return ; + } + + return ( + + + + + ); +}; + +export default HomePage; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx new file mode 100644 index 00000000000..bb8ac814b38 --- /dev/null +++ b/src/components/structures/LeftPanel.tsx @@ -0,0 +1,446 @@ +/* +Copyright 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 * as React from "react"; +import { createRef } from "react"; +import classNames from "classnames"; +import dis from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import LegacyCallHandler from "matrix-react-sdk/src/LegacyCallHandler"; +import { HEADER_HEIGHT } from "matrix-react-sdk/src/components/views/rooms/RoomSublist"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import RoomSearch from "matrix-react-sdk/src/components/structures/RoomSearch"; +import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier"; +import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton"; +import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore"; +import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "matrix-react-sdk/src/stores/spaces"; +import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager"; +import UIStore from "matrix-react-sdk/src/stores/UIStore"; +import { IState as IRovingTabIndexState } from "matrix-react-sdk/src/accessibility/RovingTabIndex"; +import RoomListHeader from "matrix-react-sdk/src/components/views/rooms/RoomListHeader"; +import { BreadcrumbsStore } from "matrix-react-sdk/src/stores/BreadcrumbsStore"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "matrix-react-sdk/src/stores/room-list/RoomListStore"; +import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore"; +import IndicatorScrollbar from "matrix-react-sdk/src/components/structures/IndicatorScrollbar"; +import RoomBreadcrumbs from "matrix-react-sdk/src/components/views/rooms/RoomBreadcrumbs"; +import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts"; +import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents"; +import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature"; +import { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers"; +import PageType from "matrix-react-sdk/src/PageTypes"; +import { UserOnboardingButton } from "matrix-react-sdk/src/components/views/user-onboarding/UserOnboardingButton"; + +import RoomList from "../views/rooms/RoomList"; +import { Icon as SuperheroDark } from "../../../res/themes/superhero/img/logos/superhero-dark.svg"; +import { Icon as SuperheroLight } from "../../../res/themes/superhero/img/logos/superhero-light.svg"; + +interface IProps { + isMinimized: boolean; + pageType: PageType; + resizeNotifier: ResizeNotifier; +} + +enum BreadcrumbsMode { + Disabled, + Legacy, +} + +interface IState { + showBreadcrumbs: BreadcrumbsMode; + activeSpace: SpaceKey; +} + +export default class LeftPanel extends React.Component { + private listContainerRef = createRef(); + private roomListRef = createRef(); + private focusedElement: Element | null = null; + private isDoingStickyHeaders = false; + + public constructor(props: IProps) { + super(props); + + this.state = { + activeSpace: SpaceStore.instance.activeSpace, + showBreadcrumbs: LeftPanel.breadcrumbsMode, + }; + + BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); + RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + } + + private static get breadcrumbsMode(): BreadcrumbsMode { + return !BreadcrumbsStore.instance.visible ? BreadcrumbsMode.Disabled : BreadcrumbsMode.Legacy; + } + + public componentDidMount(): void { + if (this.listContainerRef.current) { + UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); + // Using the passive option to not block the main thread + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners + this.listContainerRef.current.addEventListener("scroll", this.onScroll, { passive: true }); + } + UIStore.instance.on("ListContainer", this.refreshStickyHeaders); + } + + public componentWillUnmount(): void { + BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); + RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); + UIStore.instance.stopTrackingElementDimensions("ListContainer"); + UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); + this.listContainerRef.current?.removeEventListener("scroll", this.onScroll); + } + + public componentDidUpdate(prevProps: IProps, prevState: IState): void { + if (prevState.activeSpace !== this.state.activeSpace) { + this.refreshStickyHeaders(); + } + } + + private updateActiveSpace = (activeSpace: SpaceKey): void => { + this.setState({ activeSpace }); + }; + + private onDialPad = (): void => { + dis.fire(Action.OpenDialPad); + }; + + private onExplore = (ev: ButtonEvent): void => { + dis.fire(Action.ViewRoomDirectory); + PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev); + }; + + private refreshStickyHeaders = (): void => { + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); + }; + + private onBreadcrumbsUpdate = (): void => { + const newVal = LeftPanel.breadcrumbsMode; + if (newVal !== this.state.showBreadcrumbs) { + this.setState({ showBreadcrumbs: newVal }); + + // Update the sticky headers too as the breadcrumbs will be popping in or out. + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); + } + }; + + private handleStickyHeaders(list: HTMLDivElement): void { + if (this.isDoingStickyHeaders) return; + this.isDoingStickyHeaders = true; + window.requestAnimationFrame(() => { + this.doStickyHeaders(list); + this.isDoingStickyHeaders = false; + }); + } + + private doStickyHeaders(list: HTMLDivElement): void { + if (!list.parentElement) return; + const topEdge = list.scrollTop; + const bottomEdge = list.offsetHeight + list.scrollTop; + const sublists = list.querySelectorAll(".mx_RoomSublist:not(.mx_RoomSublist_hidden)"); + + // We track which styles we want on a target before making the changes to avoid + // excessive layout updates. + const targetStyles = new Map< + HTMLDivElement, + { + stickyTop?: boolean; + stickyBottom?: boolean; + makeInvisible?: boolean; + } + >(); + + let lastTopHeader: HTMLDivElement | undefined; + let firstBottomHeader: HTMLDivElement | undefined; + for (const sublist of sublists) { + const header = sublist.querySelector(".mx_RoomSublist_stickable"); + if (!header) continue; // this should never occur + header.style.removeProperty("display"); // always clear display:none first + + // When an element is <=40% off screen, make it take over + const offScreenFactor = 0.4; + const isOffTop = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT <= topEdge; + const isOffBottom = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT >= bottomEdge; + + if (isOffTop || sublist === sublists[0]) { + targetStyles.set(header, { stickyTop: true }); + if (lastTopHeader) { + lastTopHeader.style.display = "none"; + targetStyles.set(lastTopHeader, { makeInvisible: true }); + } + lastTopHeader = header; + } else if (isOffBottom && !firstBottomHeader) { + targetStyles.set(header, { stickyBottom: true }); + firstBottomHeader = header; + } else { + targetStyles.set(header, {}); // nothing == clear + } + } + + // Run over the style changes and make them reality. We check to see if we're about to + // cause a no-op update, as adding/removing properties that are/aren't there cause + // layout updates. + for (const header of targetStyles.keys()) { + const style = targetStyles.get(header)!; + + if (style.makeInvisible) { + // we will have already removed the 'display: none', so add it back. + header.style.display = "none"; + continue; // nothing else to do, even if sticky somehow + } + + if (style.stickyTop) { + if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) { + header.classList.add("mx_RoomSublist_headerContainer_stickyTop"); + } + + const newTop = `${list.parentElement.offsetTop}px`; + if (header.style.top !== newTop) { + header.style.top = newTop; + } + } else { + if (header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) { + header.classList.remove("mx_RoomSublist_headerContainer_stickyTop"); + } + if (header.style.top) { + header.style.removeProperty("top"); + } + } + + if (style.stickyBottom) { + if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { + header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); + } + + const offset = + UIStore.instance.windowHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); + const newBottom = `${offset}px`; + if (header.style.bottom !== newBottom) { + header.style.bottom = newBottom; + } + } else { + if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { + header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom"); + } + if (header.style.bottom) { + header.style.removeProperty("bottom"); + } + } + + if (style.stickyTop || style.stickyBottom) { + if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { + header.classList.add("mx_RoomSublist_headerContainer_sticky"); + } + + const listDimensions = UIStore.instance.getElementDimensions("ListContainer"); + if (listDimensions) { + const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles + const headerStickyWidth = listDimensions.width - headerRightMargin; + const newWidth = `${headerStickyWidth}px`; + if (header.style.width !== newWidth) { + header.style.width = newWidth; + } + } + } else if (!style.stickyTop && !style.stickyBottom) { + if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { + header.classList.remove("mx_RoomSublist_headerContainer_sticky"); + } + + if (header.style.width) { + header.style.removeProperty("width"); + } + } + } + + // add appropriate sticky classes to wrapper so it has + // the necessary top/bottom padding to put the sticky header in + const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper + if (!listWrapper) return; + if (lastTopHeader) { + listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop"); + } else { + listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop"); + } + if (firstBottomHeader) { + listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom"); + } else { + listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom"); + } + } + + private onScroll = (ev: Event): void => { + const list = ev.target as HTMLDivElement; + this.handleStickyHeaders(list); + }; + + private onFocus = (ev: React.FocusEvent): void => { + this.focusedElement = ev.target; + }; + + private onBlur = (): void => { + this.focusedElement = null; + }; + + private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState): void => { + if (!this.focusedElement) return; + + const action = getKeyBindingsManager().getRoomListAction(ev); + switch (action) { + case KeyBindingAction.NextRoom: + if (!state) { + ev.stopPropagation(); + ev.preventDefault(); + this.roomListRef.current?.focus(); + } + break; + } + }; + + private renderBreadcrumbs(): React.ReactNode { + if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) { + return ( + + + + ); + } + } + + private renderSearchDialExplore(): React.ReactNode { + let dialPadButton: JSX.Element | undefined; + + // If we have dialer support, show a button to bring up the dial pad + // to start a new call + if (LegacyCallHandler.instance.getSupportsPstnProtocol()) { + dialPadButton = ( + + ); + } + + let rightButton: JSX.Element | undefined; + if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) { + rightButton = ( + + ); + } + + return ( +
+ + + {dialPadButton} + {rightButton} +
+ ); + } + + public render(): React.ReactNode { + console.log("render changedd"); + const roomList = ( + + ); + + const containerClasses = classNames({ + mx_LeftPanel: true, + mx_LeftPanel_minimized: this.props.isMinimized, + }); + + const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar"); + + return ( +
+
+
+ + +
+ + {shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()} + {this.renderBreadcrumbs()} + {!this.props.isMinimized && } + + +
+
+ ); + } +} diff --git a/src/components/views/auth/VectorAuthFooter.tsx b/src/components/views/auth/VectorAuthFooter.tsx index aab9b542619..d170ca3ca4d 100644 --- a/src/components/views/auth/VectorAuthFooter.tsx +++ b/src/components/views/auth/VectorAuthFooter.tsx @@ -23,9 +23,9 @@ import { _t } from "../../../languageHandler"; const VectorAuthFooter = (): ReactElement => { const brandingConfig = SdkConfig.getObject("branding"); const links = brandingConfig?.get("auth_footer_links") ?? [ - { text: "Blog", url: "https://element.io/blog" }, - { text: "Twitter", url: "https://twitter.com/element_hq" }, - { text: "GitHub", url: "https://github.com/element-hq/element-web" }, + { text: "Blog", url: "https://superhero.com" }, + { text: "Twitter", url: "https://twitter.com/superhero" }, + { text: "GitHub", url: "https://github.com/superhero-com/element-web" }, ]; const authFooterLinks: JSX.Element[] = []; diff --git a/src/components/views/auth/VectorAuthPage.tsx b/src/components/views/auth/VectorAuthPage.tsx index e04dfcefd74..5636816f93b 100644 --- a/src/components/views/auth/VectorAuthPage.tsx +++ b/src/components/views/auth/VectorAuthPage.tsx @@ -27,7 +27,7 @@ export default class VectorAuthPage extends React.PureComponent { if (VectorAuthPage.welcomeBackgroundUrl) return VectorAuthPage.welcomeBackgroundUrl; const brandingConfig = SdkConfig.getObject("branding"); - VectorAuthPage.welcomeBackgroundUrl = "themes/element/img/backgrounds/lake.jpg"; + VectorAuthPage.welcomeBackgroundUrl = "themes/superhero/img/backgrounds/gradient-bg.svg"; const configuredUrl = brandingConfig?.get("welcome_background_url"); if (configuredUrl) { diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx new file mode 100644 index 00000000000..11c17d0115e --- /dev/null +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -0,0 +1,150 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector 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, { useCallback, useContext, useEffect, useState } from "react"; +import classNames from "classnames"; +import { ClientEvent } from "matrix-js-sdk/src/matrix"; +import { Avatar } from "@vector-im/compound-web"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import RoomContext from "matrix-react-sdk/src/contexts/RoomContext"; +import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import { useTypedEventEmitter } from "matrix-react-sdk/src/hooks/useEventEmitter"; +import { _t } from "matrix-react-sdk/src/languageHandler"; + +import { getSafeRoomName } from "../../../hooks/useRoomName"; + +interface IProps { + name?: React.ComponentProps["name"]; // The name (first initial used as default) + idName?: React.ComponentProps["id"]; // ID for generating hash colours + title?: string; // onHover title text + url?: string | null; // highest priority of them all, shortcut to set in urls[0] + urls?: string[]; // [highest_priority, ... , lowest_priority] + type?: React.ComponentProps["type"]; + size: string; + onClick?: (ev: ButtonEvent) => void; + inputRef?: React.RefObject; + className?: string; + tabIndex?: number; + altText?: string; +} + +const calculateUrls = (url?: string | null, urls?: string[], lowBandwidth = false): string[] => { + // work out the full set of urls to try to load. This is formed like so: + // imageUrls: [ props.url, ...props.urls ] + + let _urls: string[] = []; + if (!lowBandwidth) { + _urls = urls || []; + + if (url) { + // copy urls and put url first + _urls = [url, ..._urls]; + } + } + + // deduplicate URLs + return Array.from(new Set(_urls)); +}; + +const useImageUrl = ({ url, urls }: { url?: string | null; urls?: string[] }): [string, () => void] => { + // Since this is a hot code path and the settings store can be slow, we + // use the cached lowBandwidth value from the room context if it exists + const roomContext = useContext(RoomContext); + const lowBandwidth = roomContext ? roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth"); + + const [imageUrls, setUrls] = useState(calculateUrls(url, urls, lowBandwidth)); + const [urlsIndex, setIndex] = useState(0); + + const onError = useCallback(() => { + setIndex((i) => i + 1); // try the next one + }, []); + + useEffect(() => { + setUrls(calculateUrls(url, urls, lowBandwidth)); + setIndex(0); + }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps + + const cli = useContext(MatrixClientContext); + const onClientSync = useCallback((syncState, prevState) => { + // Consider the client reconnected if there is no error with syncing. + // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. + const reconnected = syncState !== "ERROR" && prevState !== syncState; + if (reconnected) { + setIndex(0); + } + }, []); + useTypedEventEmitter(cli, ClientEvent.Sync, onClientSync); + + const imageUrl = imageUrls[urlsIndex]; + return [imageUrl, onError]; +}; + +const BaseAvatar: React.FC = (props) => { + const { + name, + idName, + title, + url, + urls, + size = "40px", + onClick, + inputRef, + className, + type = "round", + altText = _t("common|avatar"), + ...otherProps + } = props; + + const [imageUrl, onError] = useImageUrl({ url, urls }); + + const extraProps: Partial> = {}; + + if (onClick) { + extraProps["aria-live"] = "off"; + extraProps["role"] = "button"; + } else if (!imageUrl) { + extraProps["role"] = "presentation"; + extraProps["aria-label"] = undefined; + } else { + extraProps["role"] = undefined; + } + + return ( + + ); +}; + +export default BaseAvatar; +export type BaseAvatarType = React.FC; diff --git a/src/components/views/dialogs/AppDownloadDialog.tsx b/src/components/views/dialogs/AppDownloadDialog.tsx new file mode 100644 index 00000000000..e8e23e51b15 --- /dev/null +++ b/src/components/views/dialogs/AppDownloadDialog.tsx @@ -0,0 +1,128 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC } from "react"; +import { Icon as FDroidBadge } from "matrix-react-sdk/res/img/badges/f-droid.svg"; +import { Icon as GooglePlayBadge } from "matrix-react-sdk/res/img/badges/google-play.svg"; +import { Icon as IOSBadge } from "matrix-react-sdk/res/img/badges/ios.svg"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import SdkConfig from "matrix-react-sdk/src/SdkConfig"; +import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import QRCode from "matrix-react-sdk/src/components/views/elements/QRCode"; +import Heading from "matrix-react-sdk/src/components/views/typography/Heading"; +import BaseDialog from "matrix-react-sdk/src/components/views/dialogs/BaseDialog"; + +interface Props { + onFinished(): void; +} + +export const showAppDownloadDialogPrompt = (): boolean => { + const desktopBuilds = SdkConfig.getObject("desktop_builds"); + const mobileBuilds = SdkConfig.getObject("mobile_builds"); + + return ( + !!desktopBuilds?.get("available") || + !!mobileBuilds?.get("ios") || + !!mobileBuilds?.get("android") || + !!mobileBuilds?.get("fdroid") + ); +}; + +export const AppDownloadDialog: FC = ({ onFinished }) => { + const brand = SdkConfig.get("brand"); + const mobileBuilds = SdkConfig.getObject("mobile_builds"); + + const urlAppStore = mobileBuilds?.get("ios"); + + const urlGooglePlay = mobileBuilds?.get("android"); + const urlFDroid = mobileBuilds?.get("fdroid"); + const urlAndroid = urlGooglePlay ?? urlFDroid; + + return ( + +
+ {urlAppStore && ( +
+ {_t("common|ios")} + +
+ {_t("onboarding|qr_or_app_links", { + appLinks: "", + qrCode: "", + })} +
+
+ {}} + > + + +
+
+ )} + {urlAndroid && ( +
+ {_t("common|android")} + +
+ {_t("onboarding|qr_or_app_links", { + appLinks: "", + qrCode: "", + })} +
+
+ {urlGooglePlay && ( + {}} + > + + + )} + {urlFDroid && ( + {}} + > + + + )} +
+
+ )} +
+
+

{_t("onboarding|apple_trademarks")}

+

{_t("onboarding|google_trademarks")}

+
+
+ ); +}; diff --git a/src/components/views/dialogs/FeedbackDialog.tsx b/src/components/views/dialogs/FeedbackDialog.tsx new file mode 100644 index 00000000000..f461e2c50e4 --- /dev/null +++ b/src/components/views/dialogs/FeedbackDialog.tsx @@ -0,0 +1,125 @@ +/* +Copyright 2018 New Vector Ltd + +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 Modal from "matrix-react-sdk/src/Modal"; +import SdkConfig from "matrix-react-sdk/src/SdkConfig"; +import BugReportDialog from "matrix-react-sdk/src/components/views/dialogs/BugReportDialog"; +import QuestionDialog from "matrix-react-sdk/src/components/views/dialogs/QuestionDialog"; +import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import ExternalLink from "matrix-react-sdk/src/components/views/elements/ExternalLink"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import React from "react"; +interface IProps { + feature?: string; + onFinished(): void; +} + +const FeedbackDialog: React.FC = (props: IProps) => { + const onDebugLogsLinkClick = (): void => { + props.onFinished(); + Modal.createDialog(BugReportDialog, {}); + }; + + const hasFeedback = !!SdkConfig.get().bug_report_endpoint_url; + const supportChannelRoomId = (SdkConfig.get() as any).support_channel_room_id; + + const onFinished = async (sendFeedback: boolean): Promise => { + if (hasFeedback && sendFeedback) { + window.open(`#/room/${supportChannelRoomId}`, "_self"); + } + props.onFinished(); + }; + + let feedbackSection: JSX.Element | undefined; + if (hasFeedback) { + feedbackSection = ( +
+

{_t("feedback|comment_label")}

+ +

Ready to make a difference? Drop a message to share your feedback, thoughts, or suggestions.

+
+ ); + } + + let bugReports: JSX.Element | undefined; + if (hasFeedback) { + bugReports = ( +

+ {_t( + "feedback|pro_type", + {}, + { + debugLogsLink: (sub) => ( + + {sub} + + ), + }, + )} +

+ ); + } + + const existingIssuesUrl = SdkConfig.getObject("feedback").get("existing_issues_url"); + const newIssueUrl = SdkConfig.getObject("feedback").get("new_issue_url"); + + return ( + +
+

{_t("common|report_a_bug")}

+

+ {_t( + "feedback|existing_issue_link", + {}, + { + existingIssuesLink: (sub) => { + return ( + + {sub} + + ); + }, + newIssueLink: (sub) => { + return ( + + {sub} + + ); + }, + }, + )} +

+ {bugReports} +
+ {feedbackSection} + + } + button={hasFeedback ? _t("feedback|send_feedback_action") : _t("action|go_back")} + onFinished={onFinished} + /> + ); +}; + +export default FeedbackDialog; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx new file mode 100644 index 00000000000..cf0f876c24f --- /dev/null +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -0,0 +1,1582 @@ +/* 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 * 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"; +import { BotVerifiedBadge } from "../elements/BotVerifiedBadge"; + +// 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()!]); + 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/dialogs/spotlight/PublicRoomResultDetails.tsx b/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx new file mode 100644 index 00000000000..5ff12ffa723 --- /dev/null +++ b/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx @@ -0,0 +1,71 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IPublicRoomsChunkRoom } from "matrix-js-sdk/src/matrix"; +import { linkifyAndSanitizeHtml } from "matrix-react-sdk/src/HtmlUtils"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import React from "react"; + +import RoomName from "../../elements/RoomName"; + +const MAX_NAME_LENGTH = 80; +const MAX_TOPIC_LENGTH = 800; + +interface Props { + room: IPublicRoomsChunkRoom; + labelId: string; + descriptionId: string; + detailsId: string; +} + +export function PublicRoomResultDetails({ room, labelId, descriptionId, detailsId }: Props): JSX.Element { + let topic = room.topic || ""; + // Additional truncation based on line numbers is done via CSS, + // but to ensure that the DOM is not polluted with a huge string + // we give it a hard limit before rendering. + if (topic.length > MAX_TOPIC_LENGTH) { + topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; + } + + return ( +
+
+ + + + + {room.canonical_alias ?? room.room_id} + +
+
+ + {_t("spotlight_dialog|count_of_members", { + count: room.num_joined_members, + })} + + {topic && ( + <> +  ·  + + + )} +
+
+ ); +} diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx new file mode 100644 index 00000000000..1a59d97591d --- /dev/null +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -0,0 +1,1303 @@ +/* +Copyright 2021 - 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 { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch"; +import classNames from "classnames"; +import { capitalize, sum } from "lodash"; +import { + IPublicRoomsChunkRoom, + MatrixClient, + RoomMember, + RoomType, + Room, + HierarchyRoom, + JoinRule, +} from "matrix-js-sdk/src/matrix"; +import { normalize } from "matrix-js-sdk/src/utils"; +import React, { ChangeEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import sanitizeHtml from "sanitize-html"; +import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts"; +import { + findSiblingElement, + RovingTabIndexContext, + RovingTabIndexProvider, + Type, +} from "matrix-react-sdk/src/accessibility/RovingTabIndex"; +import { mediaFromMxc } from "matrix-react-sdk/src/customisations/Media"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { useDebouncedCallback } from "matrix-react-sdk/src/hooks/spotlight/useDebouncedCallback"; +import { useRecentSearches } from "matrix-react-sdk/src/hooks/spotlight/useRecentSearches"; +import { useProfileInfo } from "matrix-react-sdk/src/hooks/useProfileInfo"; +import { usePublicRoomDirectory } from "matrix-react-sdk/src/hooks/usePublicRoomDirectory"; +import { useSpaceResults } from "matrix-react-sdk/src/hooks/useSpaceResults"; +import { useUserDirectory } from "matrix-react-sdk/src/hooks/useUserDirectory"; +import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import { PosthogAnalytics } from "matrix-react-sdk/src/PosthogAnalytics"; +import { getCachedRoomIDForAlias } from "matrix-react-sdk/src/RoomAliasCache"; +import { showStartChatInviteDialog } from "matrix-react-sdk/src/RoomInvite"; +import { SettingLevel } from "matrix-react-sdk/src/settings/SettingLevel"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { BreadcrumbsStore } from "matrix-react-sdk/src/stores/BreadcrumbsStore"; +import { RoomNotificationState } from "matrix-react-sdk/src/stores/notifications/RoomNotificationState"; +import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore"; +import { RecentAlgorithm } from "matrix-react-sdk/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext"; +import { getMetaSpaceName } from "matrix-react-sdk/src/stores/spaces"; +import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore"; +import { DirectoryMember, Member, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; +import DMRoomMap from "matrix-react-sdk/src/utils/DMRoomMap"; +import { makeUserPermalink } from "matrix-react-sdk/src/utils/permalinks/Permalinks"; +import { buildActivityScores, buildMemberScores, compareMembers } from "matrix-react-sdk/src/utils/SortMembers"; +import { copyPlaintext } from "matrix-react-sdk/src/utils/strings"; +import BaseAvatar from "matrix-react-sdk/src/components/views/avatars/BaseAvatar"; +import DecoratedRoomAvatar from "matrix-react-sdk/src/components/views/avatars/DecoratedRoomAvatar"; +import { SearchResultAvatar } from "matrix-react-sdk/src/components/views/avatars/SearchResultAvatar"; +import { NetworkDropdown } from "matrix-react-sdk/src/components/views/directory/NetworkDropdown"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import Spinner from "matrix-react-sdk/src/components/views/elements/Spinner"; +import NotificationBadge from "matrix-react-sdk/src/components/views/rooms/NotificationBadge"; +import BaseDialog from "matrix-react-sdk/src/components/views/dialogs/BaseDialog"; +import { Option } from "matrix-react-sdk/src/components/views/dialogs/spotlight/Option"; +import { PublicRoomResultDetails } from "matrix-react-sdk/src/components/views/dialogs/spotlight/PublicRoomResultDetails"; +import { RoomResultContextMenus } from "matrix-react-sdk/src/components/views/dialogs/spotlight/RoomResultContextMenus"; +import { RoomContextDetails } from "matrix-react-sdk/src/components/views/rooms/RoomContextDetails"; +import { TooltipOption } from "matrix-react-sdk/src/components/views/dialogs/spotlight/TooltipOption"; +import { isLocalRoom } from "matrix-react-sdk/src/utils/localRoom/isLocalRoom"; +import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar"; +import { useFeatureEnabled } from "matrix-react-sdk/src/hooks/useSettings"; +import { filterBoolean } from "matrix-react-sdk/src/utils/arrays"; +import { transformSearchTerm } from "matrix-react-sdk/src/utils/SearchInput"; +import { Filter } from "matrix-react-sdk/src/components/views/dialogs/spotlight/Filter"; + +import RoomName from "../../elements/RoomName"; + +const MAX_RECENT_SEARCHES = 10; +const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons +const AVATAR_SIZE = "24px"; + +interface IProps { + initialText?: string; + initialFilter?: Filter; + onFinished(): void; +} + +function refIsForRecentlyViewed(ref?: RefObject): boolean { + return ref?.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true; +} + +function getRoomTypes(filter: Filter | null): Set { + const roomTypes = new Set(); + + if (filter === Filter.PublicRooms) roomTypes.add(null); + if (filter === Filter.PublicSpaces) roomTypes.add(RoomType.Space); + + return roomTypes; +} + +enum Section { + People, + Rooms, + Spaces, + Suggestions, + PublicRoomsAndSpaces, +} + +function filterToLabel(filter: Filter): string { + switch (filter) { + case Filter.People: + return _t("common|people"); + case Filter.PublicRooms: + return _t("spotlight_dialog|public_rooms_label"); + case Filter.PublicSpaces: + return _t("spotlight_dialog|public_spaces_label"); + } +} + +interface IBaseResult { + section: Section; + filter: Filter[]; + query?: string[]; // extra fields to query match, stored as lowercase +} + +interface IPublicRoomResult extends IBaseResult { + publicRoom: IPublicRoomsChunkRoom; +} + +interface IRoomResult extends IBaseResult { + room: Room; +} + +interface IMemberResult extends IBaseResult { + member: Member | RoomMember; + /** + * If the result is from a filtered server API then we set true here to avoid locally culling it in our own filters + */ + alreadyFiltered: boolean; +} + +interface IResult extends IBaseResult { + avatar: JSX.Element; + name: string; + description?: string; + onClick?(): void; +} + +type Result = IRoomResult | IPublicRoomResult | IMemberResult | IResult; + +const isRoomResult = (result: any): result is IRoomResult => !!result?.room; +const isPublicRoomResult = (result: any): result is IPublicRoomResult => !!result?.publicRoom; +const isMemberResult = (result: any): result is IMemberResult => !!result?.member; + +const toPublicRoomResult = (publicRoom: IPublicRoomsChunkRoom): IPublicRoomResult => ({ + publicRoom, + section: Section.PublicRoomsAndSpaces, + filter: [Filter.PublicRooms, Filter.PublicSpaces], + query: filterBoolean([ + publicRoom.room_id.toLowerCase(), + publicRoom.canonical_alias?.toLowerCase(), + publicRoom.name?.toLowerCase(), + sanitizeHtml(publicRoom.topic?.toLowerCase() ?? "", { allowedTags: [] }), + ...(publicRoom.aliases?.map((it) => it.toLowerCase()) || []), + ]), +}); + +const toRoomResult = (room: Room): IRoomResult => { + const myUserId = MatrixClientPeg.safeGet().getUserId(); + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + + if (otherUserId) { + const otherMembers = room.getMembers().filter((it) => it.userId !== myUserId); + const query = [ + ...otherMembers.map((it) => it.name.toLowerCase()), + ...otherMembers.map((it) => it.userId.toLowerCase()), + ].filter(Boolean); + return { + room, + section: Section.People, + filter: [Filter.People], + query, + }; + } else if (room.isSpaceRoom()) { + return { + room, + section: Section.Spaces, + filter: [], + }; + } else { + return { + room, + section: Section.Rooms, + filter: [], + }; + } +}; + +const toMemberResult = (member: Member | RoomMember, alreadyFiltered: boolean): IMemberResult => ({ + alreadyFiltered, + member, + section: Section.Suggestions, + filter: [Filter.People], + query: [member.userId.toLowerCase(), member.name.toLowerCase()].filter(Boolean), +}); + +const recentAlgorithm = new RecentAlgorithm(); + +export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => { + useEffect(() => { + if (!queryLength) return; + + // send metrics after a 1s debounce + const timeoutId = window.setTimeout(() => { + PosthogAnalytics.instance.trackEvent({ + eventName: "WebSearch", + viaSpotlight, + numResults, + queryLength, + }); + }, 1000); + + return () => { + clearTimeout(timeoutId); + }; + }, [numResults, queryLength, viaSpotlight]); +}; + +const findVisibleRooms = (cli: MatrixClient, msc3946ProcessDynamicPredecessor: boolean): Room[] => { + return cli.getVisibleRooms(msc3946ProcessDynamicPredecessor).filter((room) => { + // Do not show local rooms + if (isLocalRoom(room)) return false; + + // TODO we may want to put invites in their own list + return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; + }); +}; + +const findVisibleRoomMembers = (visibleRooms: Room[], cli: MatrixClient, filterDMs = true): RoomMember[] => { + return Object.values( + visibleRooms + .filter((room) => !filterDMs || !DMRoomMap.shared().getUserIdForRoomId(room.roomId)) + .reduce( + (members, room) => { + for (const member of room.getJoinedMembers()) { + members[member.userId] = member; + } + return members; + }, + {} as Record, + ), + ).filter((it) => it.userId !== cli.getUserId()); +}; + +const roomAriaUnreadLabel = (room: Room, notification: RoomNotificationState): string | undefined => { + if (notification.hasMentions) { + return _t("a11y|n_unread_messages_mentions", { + count: notification.count, + }); + } else if (notification.hasUnreadCount) { + return _t("a11y|n_unread_messages", { + count: notification.count, + }); + } else if (notification.isUnread) { + return _t("a11y|unread_messages"); + } else { + return undefined; + } +}; + +const canAskToJoin = (joinRule?: JoinRule): boolean => { + return SettingsStore.getValue("feature_ask_to_join") && JoinRule.Knock === joinRule; +}; + +interface IDirectoryOpts { + limit: number; + query: string; +} + +const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = null, onFinished }) => { + const inputRef = useRef(null); + const scrollContainerRef = useRef(null); + const cli = MatrixClientPeg.safeGet(); + const rovingContext = useContext(RovingTabIndexContext); + const [query, _setQuery] = useState(initialText); + const [recentSearches, clearRecentSearches] = useRecentSearches(); + const [filter, setFilterInternal] = useState(initialFilter); + const setFilter = useCallback((filter: Filter | null) => { + setFilterInternal(filter); + inputRef.current?.focus(); + scrollContainerRef.current?.scrollTo?.({ top: 0 }); + }, []); + const memberComparator = useMemo(() => { + const activityScores = buildActivityScores(cli); + const memberScores = buildMemberScores(cli); + return compareMembers(activityScores, memberScores); + }, [cli]); + const msc3946ProcessDynamicPredecessor = useFeatureEnabled("feature_dynamic_room_predecessors"); + + const ownInviteLink = makeUserPermalink(cli.getUserId()!); + const [inviteLinkCopied, setInviteLinkCopied] = useState(false); + const trimmedQuery = useMemo(() => query.trim(), [query]); + + const [supportsSpaceFiltering, setSupportsSpaceFiltering] = useState(true); // assume it does until we find out it doesn't + useEffect(() => { + cli.isVersionSupported("v1.4") + .then((supported) => { + return supported || cli.doesServerSupportUnstableFeature("org.matrix.msc3827.stable"); + }) + .then((supported) => { + setSupportsSpaceFiltering(supported); + }); + }, [cli]); + + const { + loading: publicRoomsLoading, + publicRooms, + protocols, + config, + setConfig, + search: searchPublicRooms, + error: publicRoomsError, + } = usePublicRoomDirectory(); + const { loading: peopleLoading, users: userDirectorySearchResults, search: searchPeople } = useUserDirectory(); + const { loading: profileLoading, profile, search: searchProfileInfo } = useProfileInfo(); + const searchParams: [IDirectoryOpts] = useMemo( + () => [ + { + query: trimmedQuery, + roomTypes: getRoomTypes(filter), + limit: SECTION_LIMIT, + }, + ], + [trimmedQuery, filter], + ); + useDebouncedCallback( + filter === Filter.PublicRooms || filter === Filter.PublicSpaces, + searchPublicRooms, + searchParams, + ); + useDebouncedCallback(filter === Filter.People, searchPeople, searchParams); + useDebouncedCallback(filter === Filter.People, searchProfileInfo, searchParams); + + const possibleResults = useMemo(() => { + const visibleRooms = findVisibleRooms(cli, msc3946ProcessDynamicPredecessor); + const roomResults = visibleRooms.map(toRoomResult); + const userResults: IMemberResult[] = []; + + // If we already have a DM with the user we're looking for, we will show that DM instead of the user themselves + const alreadyAddedUserIds = roomResults.reduce((userIds, result) => { + const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId); + if (!userId) return userIds; + if (result.room.getJoinedMemberCount() > 2) return userIds; + userIds.set(userId, result); + return userIds; + }, new Map()); + + function addUserResults(users: Array, alreadyFiltered: boolean): void { + for (const user of users) { + // Make sure we don't have any user more than once + if (alreadyAddedUserIds.has(user.userId)) { + const result = alreadyAddedUserIds.get(user.userId)!; + if (alreadyFiltered && isMemberResult(result) && !result.alreadyFiltered) { + // But if they were added as not yet filtered then mark them as already filtered to avoid + // culling this result based on local filtering. + result.alreadyFiltered = true; + } + continue; + } + const result = toMemberResult(user, alreadyFiltered); + alreadyAddedUserIds.set(user.userId, result); + userResults.push(result); + } + } + addUserResults(findVisibleRoomMembers(visibleRooms, cli), false); + addUserResults(userDirectorySearchResults, true); + if (profile) { + addUserResults([new DirectoryMember(profile)], true); + } + + return [ + ...SpaceStore.instance.enabledMetaSpaces.map((spaceKey) => ({ + section: Section.Spaces, + filter: [] as Filter[], + avatar: ( +
+ ), + name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome), + onClick(): void { + SpaceStore.instance.setActiveSpace(spaceKey); + }, + })), + ...roomResults, + ...userResults, + ...publicRooms.map(toPublicRoomResult), + ].filter((result) => filter === null || result.filter.includes(filter)); + }, [cli, userDirectorySearchResults, profile, publicRooms, filter, msc3946ProcessDynamicPredecessor]); + + const results = useMemo>(() => { + const results: Record = { + [Section.People]: [], + [Section.Rooms]: [], + [Section.Spaces]: [], + [Section.Suggestions]: [], + [Section.PublicRoomsAndSpaces]: [], + }; + + // Group results in their respective sections + if (trimmedQuery) { + const lcQuery = trimmedQuery.toLowerCase(); + const normalizedQuery = normalize(trimmedQuery); + + possibleResults.forEach((entry) => { + if (isRoomResult(entry)) { + // If the room is a DM with a user that is part of the user directory search results, + // we can assume the user is a relevant result, so include the DM with them too. + const userId = DMRoomMap.shared().getUserIdForRoomId(entry.room.roomId); + if (!userDirectorySearchResults.some((user) => user.userId === userId)) { + if ( + !entry.room.normalizedName?.includes(normalizedQuery) && + !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && + !entry.query?.some((q) => q.includes(lcQuery)) + ) { + return; // bail, does not match query + } + } + } else if (isMemberResult(entry)) { + if (!entry.alreadyFiltered && !entry.query?.some((q) => q.includes(lcQuery))) return; // bail, does not match query + } else if (isPublicRoomResult(entry)) { + if (!entry.query?.some((q) => q.includes(lcQuery))) return; // bail, does not match query + } else { + if (!entry.name.toLowerCase().includes(lcQuery) && !entry.query?.some((q) => q.includes(lcQuery))) + return; // bail, does not match query + } + + results[entry.section].push(entry); + }); + } else if (filter === Filter.PublicRooms || filter === Filter.PublicSpaces) { + // return all results for public rooms if no query is given + possibleResults.forEach((entry) => { + if (isPublicRoomResult(entry)) { + results[entry.section].push(entry); + } + }); + } else if (filter === Filter.People) { + // return all results for people if no query is given + possibleResults.forEach((entry) => { + if (isMemberResult(entry)) { + results[entry.section].push(entry); + } + }); + } + + // Sort results by most recent activity + + const myUserId = cli.getSafeUserId(); + for (const resultArray of Object.values(results)) { + resultArray.sort((a: Result, b: Result) => { + if (isRoomResult(a) || isRoomResult(b)) { + // Room results should appear at the top of the list + if (!isRoomResult(b)) return -1; + if (!isRoomResult(a)) return -1; + + return recentAlgorithm.getLastTs(b.room, myUserId) - recentAlgorithm.getLastTs(a.room, myUserId); + } else if (isMemberResult(a) || isMemberResult(b)) { + // Member results should appear just after room results + if (!isMemberResult(b)) return -1; + if (!isMemberResult(a)) return -1; + + return memberComparator(a.member, b.member); + } + return 0; + }); + } + + return results; + }, [trimmedQuery, filter, cli, possibleResults, userDirectorySearchResults, memberComparator]); + + const numResults = sum(Object.values(results).map((it) => it.length)); + useWebSearchMetrics(numResults, query.length, true); + + const activeSpace = SpaceStore.instance.activeSpaceRoom; + const [spaceResults, spaceResultsLoading] = useSpaceResults(activeSpace ?? undefined, query); + + const setQuery = (e: ChangeEvent): void => { + const newQuery = transformSearchTerm(e.currentTarget.value); + _setQuery(newQuery); + }; + useEffect(() => { + setImmediate(() => { + const ref = rovingContext.state.refs[0]; + if (ref) { + rovingContext.dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + ref.current?.scrollIntoView?.({ + block: "nearest", + }); + } + }); + // we intentionally ignore changes to the rovingContext for the purpose of this hook + // we only want to reset the focus whenever the results or filters change + // eslint-disable-next-line + }, [results, filter]); + + const viewRoom = ( + room: { + roomId: string; + roomAlias?: string; + autoJoin?: boolean; + shouldPeek?: boolean; + viaServers?: string[]; + joinRule?: IPublicRoomsChunkRoom["join_rule"]; + }, + persist = false, + viaKeyboard = false, + ): void => { + if (persist) { + const recents = new Set(SettingsStore.getValue("SpotlightSearch.recentSearches", null).reverse()); + // remove & add the room to put it at the end + recents.delete(room.roomId); + recents.add(room.roomId); + + SettingsStore.setValue( + "SpotlightSearch.recentSearches", + null, + SettingLevel.ACCOUNT, + Array.from(recents).reverse().slice(0, MAX_RECENT_SEARCHES), + ); + } + + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + metricsTrigger: "WebUnifiedSearch", + metricsViaKeyboard: viaKeyboard, + room_id: room.roomId, + room_alias: room.roomAlias, + auto_join: room.autoJoin && !canAskToJoin(room.joinRule), + should_peek: room.shouldPeek, + via_servers: room.viaServers, + }); + + if (canAskToJoin(room.joinRule)) { + defaultDispatcher.dispatch({ action: Action.PromptAskToJoin }); + } + + onFinished(); + }; + + let otherSearchesSection: JSX.Element | undefined; + if (trimmedQuery || (filter !== Filter.PublicRooms && filter !== Filter.PublicSpaces)) { + otherSearchesSection = ( +
+

+ {trimmedQuery + ? _t("spotlight_dialog|heading_with_query", { query }) + : _t("spotlight_dialog|heading_without_query")} +

+
+ {filter !== Filter.PublicSpaces && supportsSpaceFiltering && ( + + )} + {filter !== Filter.PublicRooms && ( + + )} + {filter !== Filter.People && ( + + )} +
+
+ ); + } + + let content: JSX.Element; + if (trimmedQuery || filter !== null) { + const resultMapper = (result: Result): JSX.Element => { + if (isRoomResult(result)) { + const notification = RoomNotificationStateStore.instance.getRoomState(result.room); + const unreadLabel = roomAriaUnreadLabel(result.room, notification); + const ariaProperties = { + "aria-label": unreadLabel ? `${result.room.name} ${unreadLabel}` : result.room.name, + "aria-describedby": `mx_SpotlightDialog_button_result_${result.room.roomId}_details`, + }; + return ( + + ); + } + if (isMemberResult(result)) { + return ( + + ); + } + if (isPublicRoomResult(result)) { + const clientRoom = cli.getRoom(result.publicRoom.room_id); + const joinRule = result.publicRoom.join_rule; + // Element Web currently does not allow guests to join rooms, so we + // instead show them view buttons for all rooms. If the room is not + // world readable, a modal will appear asking you to register first. If + // it is readable, the preview appears as normal. + const showViewButton = + clientRoom?.getMyMembership() === "join" || + (result.publicRoom.world_readable && !canAskToJoin(joinRule)) || + cli.isGuest(); + + const listener = (ev: ButtonEvent): void => { + ev.stopPropagation(); + + const { publicRoom } = result; + viewRoom( + { + roomAlias: publicRoom.canonical_alias || publicRoom.aliases?.[0], + roomId: publicRoom.room_id, + autoJoin: !result.publicRoom.world_readable && !cli.isGuest(), + shouldPeek: result.publicRoom.world_readable || cli.isGuest(), + viaServers: config ? [config.roomServer] : undefined, + joinRule, + }, + true, + ev.type !== "click", + ); + }; + + let buttonLabel; + if (showViewButton) { + buttonLabel = _t("action|view"); + } else { + buttonLabel = canAskToJoin(joinRule) ? _t("action|ask_to_join") : _t("action|join"); + } + + return ( + + ); + } + + // IResult case + return ( + + ); + }; + + let peopleSection: JSX.Element | undefined; + if (results[Section.People].length) { + peopleSection = ( +
+

{_t("invite|recents_section")}

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

{_t("common|suggestions")}

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

{_t("common|rooms")}

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

{_t("spotlight_dialog|spaces_title")}

+
{results[Section.Spaces].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ ); + } + + let publicRoomsSection: JSX.Element | undefined; + if (filter === Filter.PublicRooms || filter === Filter.PublicSpaces) { + let content: JSX.Element | JSX.Element[]; + if (publicRoomsError) { + content = ( +
+ {filter === Filter.PublicRooms + ? _t("spotlight_dialog|failed_querying_public_rooms") + : _t("spotlight_dialog|failed_querying_public_spaces")} +
+ ); + } else { + content = results[Section.PublicRoomsAndSpaces].slice(0, SECTION_LIMIT).map(resultMapper); + } + + publicRoomsSection = ( +
+
+

{_t("common|suggestions")}

+
+ +
+
+
{content}
+
+ ); + } + + let spaceRoomsSection: JSX.Element | undefined; + if (spaceResults.length && activeSpace && filter === null) { + spaceRoomsSection = ( +
+

+ {_t("spotlight_dialog|other_rooms_in_space", { spaceName: activeSpace.name })} +

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

{_t("spotlight_dialog|result_may_be_hidden_privacy_warning")}

+
+ {_t("spotlight_dialog|cant_find_person_helpful_hint")} +
+ { + setInviteLinkCopied(true); + copyPlaintext(ownInviteLink); + }} + onHideTooltip={() => setInviteLinkCopied(false)} + title={inviteLinkCopied ? _t("common|copied") : _t("action|copy")} + > + + {_t("spotlight_dialog|copy_link_text")} + + +
+ ); + } else if (trimmedQuery && (filter === Filter.PublicRooms || filter === Filter.PublicSpaces)) { + hiddenResultsSection = ( +
+

{_t("spotlight_dialog|result_may_be_hidden_warning")}

+
+ {_t("spotlight_dialog|cant_find_room_helpful_hint")} +
+ +
+ ); + } + + let groupChatSection: JSX.Element | undefined; + if (filter === Filter.People) { + groupChatSection = ( +
+

{_t("spotlight_dialog|group_chat_section_title")}

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

+ {_t("spotlight_dialog|message_search_section_title")} +

+
+ {_t( + "spotlight_dialog|search_messages_hint", + {}, + { icon: () =>
}, + )} +
+
+ ); + } + + content = ( + <> + {peopleSection} + {suggestionsSection} + {roomsSection} + {spacesSection} + {spaceRoomsSection} + {publicRoomsSection} + {joinRoomSection} + {hiddenResultsSection} + {otherSearchesSection} + {groupChatSection} + {messageSearchSection} + + ); + } else { + let recentSearchesSection: JSX.Element | undefined; + if (recentSearches.length) { + recentSearchesSection = ( +
+

+ + {_t("spotlight_dialog|recent_searches_section_title")} + + + {_t("action|clear")} + +

+
+ {recentSearches.map((room) => { + const notification = RoomNotificationStateStore.instance.getRoomState(room); + const unreadLabel = roomAriaUnreadLabel(room, notification); + const ariaProperties = { + "aria-label": unreadLabel ? `${room.name} ${unreadLabel}` : room.name, + "aria-describedby": `mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`, + }; + return ( + + ); + })} +
+
+ ); + } + + content = ( + <> +
+

+ {_t("spotlight_dialog|recently_viewed_section_title")} +

+
+ {BreadcrumbsStore.instance.rooms + .filter((r) => r.roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) + .map((room) => ( + { + viewRoom({ roomId: room.roomId }, false, ev.type !== "click"); + }} + > + + + + ))} +
+
+ + {recentSearchesSection} + {otherSearchesSection} + + ); + } + + const onDialogKeyDown = (ev: KeyboardEvent | React.KeyboardEvent): void => { + const navigationAction = getKeyBindingsManager().getNavigationAction(ev); + switch (navigationAction) { + case KeyBindingAction.FilterRooms: + ev.stopPropagation(); + ev.preventDefault(); + onFinished(); + break; + } + + let ref: RefObject | undefined; + const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev); + switch (accessibilityAction) { + case KeyBindingAction.Escape: + ev.stopPropagation(); + ev.preventDefault(); + onFinished(); + break; + case KeyBindingAction.ArrowUp: + case KeyBindingAction.ArrowDown: + ev.stopPropagation(); + ev.preventDefault(); + + if (rovingContext.state.activeRef && rovingContext.state.refs.length > 0) { + let refs = rovingContext.state.refs; + if (!query && !filter !== null) { + // If the current selection is not in the recently viewed row then only include the + // first recently viewed so that is the target when the user is switching into recently viewed. + const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef) + ? rovingContext.state.activeRef + : refs.find(refIsForRecentlyViewed); + // exclude all other recently viewed items from the list so up/down arrows skip them + refs = refs.filter((ref) => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref)); + } + + const idx = refs.indexOf(rovingContext.state.activeRef); + ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1)); + } + break; + + case KeyBindingAction.ArrowLeft: + case KeyBindingAction.ArrowRight: + // only handle these keys when we are in the recently viewed row of options + if ( + !query && + !filter !== null && + rovingContext.state.activeRef && + rovingContext.state.refs.length > 0 && + refIsForRecentlyViewed(rovingContext.state.activeRef) + ) { + // we only intercept left/right arrows when the field is empty, and they'd do nothing anyway + ev.stopPropagation(); + ev.preventDefault(); + + const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed); + const idx = refs.indexOf(rovingContext.state.activeRef); + ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1)); + } + break; + } + + if (ref) { + rovingContext.dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + ref.current?.scrollIntoView({ + block: "nearest", + }); + } + }; + + const onKeyDown = (ev: React.KeyboardEvent): void => { + const action = getKeyBindingsManager().getAccessibilityAction(ev); + + switch (action) { + case KeyBindingAction.Backspace: + if (!query && filter !== null) { + ev.stopPropagation(); + ev.preventDefault(); + setFilter(null); + } + break; + case KeyBindingAction.Enter: + ev.stopPropagation(); + ev.preventDefault(); + rovingContext.state.activeRef?.current?.click(); + break; + } + }; + + const activeDescendant = rovingContext.state.activeRef?.current?.id; + + return ( + <> +
+ {_t( + "spotlight_dialog|keyboard_scroll_hint", + {}, + { + arrows: () => ( + <> + + + {!filter !== null && !query && } + {!filter !== null && !query && } + + ), + }, + )} +
+ + +
+ {filter !== null && ( +
+ {filterToLabel(filter)} + setFilter(null)} + /> +
+ )} + + {(publicRoomsLoading || peopleLoading || profileLoading) && } +
+ +
+ {content} +
+
+ + ); +}; + +const RovingSpotlightDialog: React.FC = (props) => { + return {() => }; +}; + +export default RovingSpotlightDialog; diff --git a/src/components/views/elements/BotVerifiedBadge.tsx b/src/components/views/elements/BotVerifiedBadge.tsx new file mode 100644 index 00000000000..e9426fa5482 --- /dev/null +++ b/src/components/views/elements/BotVerifiedBadge.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import { useVerifiedBot } from "../../../hooks/useVerifiedBot"; + +export interface UserVerifiedBadgeProps { + userId: string; +} + +export const BotVerifiedBadge = ({ userId }: UserVerifiedBadgeProps): JSX.Element => { + const isVerifiedBot = useVerifiedBot(userId); + + return ( + <> + {isVerifiedBot && ( +
+ Verified Bot +
+ )} + + ); +}; diff --git a/src/components/views/elements/CommunityRoomPeekMessage.tsx b/src/components/views/elements/CommunityRoomPeekMessage.tsx new file mode 100644 index 00000000000..3f10662d4f9 --- /dev/null +++ b/src/components/views/elements/CommunityRoomPeekMessage.tsx @@ -0,0 +1,20 @@ +import { useAtom } from "jotai"; +import React, { ReactElement } from "react"; + +import { minimumTokenThresholdAtom } from "../../../atoms"; +import { _t } from "../../../languageHandler"; +import { cleanRoomName } from "../../../hooks/useVerifiedRoom"; + +export function CommunityRoomPeekMessage({ roomName }: { roomName: string }): ReactElement { + const [allTokens] = useAtom(minimumTokenThresholdAtom); + const cleanedRoomName = cleanRoomName(roomName); + + const tokenThreshold = allTokens[cleanedRoomName]; + + return ( +

+ {_t("room|no_peek_join_prompt_community", { roomName: cleanedRoomName })}{" "} + {tokenThreshold ? _t("room|no_peek_join_prompt_community_threshold", tokenThreshold) : ""} +

+ ); +} diff --git a/src/components/views/elements/DisabledMessageField.tsx b/src/components/views/elements/DisabledMessageField.tsx new file mode 100644 index 00000000000..a51575e9b3a --- /dev/null +++ b/src/components/views/elements/DisabledMessageField.tsx @@ -0,0 +1,44 @@ +import { useAtom } from "jotai"; +import React from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; + +import { minimumTokenThresholdAtom } from "../../../atoms"; +import { _t } from "../../../languageHandler"; +import { useVerifiedRoom } from "../../../hooks/useVerifiedRoom"; +import { MessageCommunityBotButton } from "./MessageButton"; + +export function DisabledMessageField({ room }: { room: Room }): JSX.Element { + const [allTokens] = useAtom(minimumTokenThresholdAtom); + const { isTokenGatedRoom, isCommunityRoom } = useVerifiedRoom(room); + + let tokenThreshold = allTokens[room.name]; + if (!tokenThreshold) { + const tokenName = room.name.match(/\[TG] (.*) \(ct_.*\)/)?.[1]; + if (isTokenGatedRoom && tokenName) { + tokenThreshold = { + threshold: "1", + symbol: tokenName, + }; + } + } + + if (tokenThreshold) { + return ( +
+ {_t("composer|no_perms_token_notice", tokenThreshold)} + {isCommunityRoom ? ( + <> + + + + ) : null} +
+ ); + } else { + return ( +
+ {_t("composer|no_perms_notice")} +
+ ); + } +} diff --git a/src/components/views/elements/MessageButton.tsx b/src/components/views/elements/MessageButton.tsx new file mode 100644 index 00000000000..6d4b272cb90 --- /dev/null +++ b/src/components/views/elements/MessageButton.tsx @@ -0,0 +1,52 @@ +import React, { useContext, useState } from "react"; +import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import AccessibleButton from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import { useAtom } from "jotai"; + +import { Member } from "../right_panel/UserInfo"; +import { Icon as SendMessage } from "../../../../res/themes/superhero/img/icons/send.svg"; +import { BareUser, botAccountsAtom } from "../../../atoms"; +import { openDmForUser } from "../../../utils"; + +/** + * Converts the member to a DirectoryMember and starts a DM with them. + */ + +export const MessageButton = ({ + member, + text = "Send Message", +}: { + member: Member | BareUser; + text?: string; +}): 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} + > + + {text} + + ); +}; + +export const MessageCommunityBotButton = ({ text = "Send Message" }: { text?: string }): JSX.Element => { + const [botAccounts] = useAtom(botAccountsAtom); + + const botUser = { + userId: botAccounts?.communityBot || "", + rawDisplayName: "Community Bot", + } as Member; + + return ; +}; diff --git a/src/components/views/elements/Pill.tsx b/src/components/views/elements/Pill.tsx new file mode 100644 index 00000000000..8ddcde2e198 --- /dev/null +++ b/src/components/views/elements/Pill.tsx @@ -0,0 +1,194 @@ +/* +Copyright 2017 - 2019, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ReactElement } from "react"; +import classNames from "classnames"; +import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext"; +import { usePermalink } from "matrix-react-sdk/src/hooks/usePermalink"; +import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar"; +import MemberAvatar from "matrix-react-sdk/src/components/views/avatars/MemberAvatar"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import { Icon as LinkIcon } from "matrix-react-sdk/res/img/element-icons/room/composer/link.svg"; +import { Icon as UserIcon } from "matrix-react-sdk/res/img/compound/user.svg"; + +import { Icon as TokenGatedRoomIcon } from "../../../../res/themes/superhero/img/icons/tokengated-room.svg"; +import { isVerifiedRoom } from "../../../hooks/useVerifiedRoom"; +import { Icon as CommunityRoomIcon } from "../../../../res/themes/superhero/img/icons/community-room.svg"; +import { getSafeRoomName } from "../../../hooks/useRoomName"; + +export enum PillType { + UserMention = "TYPE_USER_MENTION", + RoomMention = "TYPE_ROOM_MENTION", + AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention + EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM", + EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM", +} + +export const pillRoomNotifPos = (text: string | null): number => { + return text?.indexOf("@room") ?? -1; +}; + +export const pillRoomNotifLen = (): number => { + return "@room".length; +}; + +const linkIcon = ; + +const PillRoomAvatar: React.FC<{ + shouldShowPillAvatar: boolean; + room: Room | null; +}> = ({ shouldShowPillAvatar, room }) => { + if (!shouldShowPillAvatar) { + return null; + } + + if (room) { + return