diff --git a/.github/workflows/layered-build.yaml b/.github/workflows/layered-build.yaml new file mode 100644 index 0000000000..7235b4020f --- /dev/null +++ b/.github/workflows/layered-build.yaml @@ -0,0 +1,19 @@ +name: Layered Preview Build +on: + pull_request: + branches: [develop] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build + run: scripts/ci/layered.sh && cd element-web && cp element.io/develop/config.json config.json && CI_PACKAGE=true yarn build + - name: Upload Artifact + uses: actions/upload-artifact@v2 + with: + name: previewbuild + path: element-web/webapp + # We'll only use this in a triggered job, then we're done with it + retention-days: 1 + diff --git a/.github/workflows/netflify.yaml b/.github/workflows/netflify.yaml new file mode 100644 index 0000000000..9d65dd5926 --- /dev/null +++ b/.github/workflows/netflify.yaml @@ -0,0 +1,50 @@ +name: Upload Preview Build to Netlify +on: + workflow_run: + workflows: ["Layered Preview Build"] + types: + - completed +jobs: + build: + runs-on: ubuntu-latest + if: > + ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + # There's a 'download artifact' action but it hasn't been updated for the + # workflow_run action (https://github.com/actions/download-artifact/issues/60) + # so instead we get this mess: + - name: 'Download artifact' + uses: actions/github-script@v3.1.0 + with: + script: | + var artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "previewbuild" + })[0]; + var download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data)); + - run: unzip previewbuild.zip && rm previewbuild.zip + - name: Deploy to Netlify + uses: nwtgck/actions-netlify@v1.2 + with: + publish-dir: . + github-token: ${{ secrets.GITHUB_TOKEN }} + deploy-message: "Deploy from GitHub Actions" + enable-pull-request-comment: true + enable-commit-comment: false + overwrites-pull-request-comment: true + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + timeout-minutes: 1 + diff --git a/.github/workflows/preview_changelog.yaml b/.github/workflows/preview_changelog.yaml new file mode 100644 index 0000000000..d68d19361d --- /dev/null +++ b/.github/workflows/preview_changelog.yaml @@ -0,0 +1,12 @@ +name: Preview Changelog +on: + pull_request_target: + types: [ opened, edited, labeled ] +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - name: Preview Changelog + uses: matrix-org/allchange@main + with: + ghToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/res/css/_components.scss b/res/css/_components.scss index 652b317655..cb7ec5ba0a 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -241,6 +241,7 @@ @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; +@import "./views/settings/_LayoutSwitcher.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @import "./views/settings/_ProfileSettings.scss"; @@ -274,6 +275,7 @@ @import "./views/voip/_CallPreview.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; +@import "./views/voip/_CallViewHeader.scss"; @import "./views/voip/_CallViewSidebar.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; diff --git a/res/css/views/elements/_DesktopCapturerSourcePicker.scss b/res/css/views/elements/_DesktopCapturerSourcePicker.scss index bd81aafef3..b4a2c69b86 100644 --- a/res/css/views/elements/_DesktopCapturerSourcePicker.scss +++ b/res/css/views/elements/_DesktopCapturerSourcePicker.scss @@ -24,33 +24,33 @@ limitations under the License. align-items: flex-start; height: 500px; overflow: overlay; - } - .mx_desktopCapturerSourcePicker_source { - display: flex; - flex-direction: column; - margin: 8px; - } + .mx_desktopCapturerSourcePicker_source { + width: 50%; + display: flex; + flex-direction: column; - .mx_desktopCapturerSourcePicker_source_thumbnail { - margin: 4px; - padding: 4px; - border-width: 2px; - border-radius: 8px; - border-style: solid; - border-color: transparent; + .mx_desktopCapturerSourcePicker_source_thumbnail { + margin: 4px; + padding: 4px; + border-width: 2px; + border-radius: 8px; + border-style: solid; + border-color: transparent; - &.mx_desktopCapturerSourcePicker_source_thumbnail_selected, - &:hover, - &:focus { - border-color: $accent-color; + &.mx_desktopCapturerSourcePicker_source_thumbnail_selected, + &:hover, + &:focus { + border-color: $accent-color; + } + } + + .mx_desktopCapturerSourcePicker_source_name { + margin: 0 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } } } - - .mx_desktopCapturerSourcePicker_source_name { - margin: 0 4px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } } diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index 5d7e733213..8196d5c67a 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -20,7 +20,7 @@ limitations under the License. height: 28px; border: 2px solid $voice-record-stop-border-color; border-radius: 32px; - margin-right: 16px; // between us and the send button + margin-right: 8px; // between us and the waveform component position: relative; &::after { @@ -64,6 +64,10 @@ limitations under the License. .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer { // Note: remaining class properties are in the PlayerContainer CSS. + // fixed height to reduce layout jumps with the play button appearing + // https://github.com/vector-im/element-web/issues/18431 + height: 32px; + margin: 6px; // force the composer area to put a gutter around us margin-right: 12px; // isolate from stop/send button @@ -83,7 +87,7 @@ limitations under the License. height: 10px; position: absolute; left: 12px; // 12px from the left edge for container padding - top: 16px; // vertically center (middle align with clock) + top: 17px; // vertically center (middle align with clock) border-radius: 10px; } } diff --git a/res/css/views/settings/_LayoutSwitcher.scss b/res/css/views/settings/_LayoutSwitcher.scss new file mode 100644 index 0000000000..924fe5ae1b --- /dev/null +++ b/res/css/views/settings/_LayoutSwitcher.scss @@ -0,0 +1,91 @@ +/* +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LayoutSwitcher { + .mx_LayoutSwitcher_RadioButtons { + display: flex; + flex-direction: row; + gap: 24px; + + color: $primary-fg-color; + + > .mx_LayoutSwitcher_RadioButton { + flex-grow: 0; + flex-shrink: 1; + display: flex; + flex-direction: column; + + width: 300px; + + border: 1px solid $appearance-tab-border-color; + border-radius: 10px; + + .mx_EventTile_msgOption, + .mx_MessageActionBar { + display: none; + } + + .mx_LayoutSwitcher_RadioButton_preview { + flex-grow: 1; + display: flex; + align-items: center; + padding: 10px; + pointer-events: none; + } + + .mx_RadioButton { + flex-grow: 0; + padding: 10px; + } + + .mx_EventTile_content { + margin-right: 0; + } + + &.mx_LayoutSwitcher_RadioButton_selected { + border-color: $accent-color; + } + } + + .mx_RadioButton { + border-top: 1px solid $appearance-tab-border-color; + + > input + div { + border-color: rgba($muted-fg-color, 0.2); + } + } + + .mx_RadioButton_checked { + background-color: rgba($accent-color, 0.08); + } + + .mx_EventTile { + margin: 0; + &[data-layout=bubble] { + margin-right: 40px; + } + &[data-layout=irc] { + > a { + display: none; + } + } + .mx_EventTile_line { + max-width: 90%; + } + } + } +} diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index ca5a6f0a66..d8e617a40d 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 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. @@ -155,79 +155,6 @@ limitations under the License. margin-left: calc($font-16px + 10px); } -.mx_AppearanceUserSettingsTab_Layout_RadioButtons { - display: flex; - flex-direction: row; - gap: 24px; - - color: $primary-fg-color; - - > .mx_AppearanceUserSettingsTab_Layout_RadioButton { - flex-grow: 0; - flex-shrink: 1; - display: flex; - flex-direction: column; - - width: 300px; - - border: 1px solid $appearance-tab-border-color; - border-radius: 10px; - - .mx_EventTile_msgOption, - .mx_MessageActionBar { - display: none; - } - - .mx_AppearanceUserSettingsTab_Layout_RadioButton_preview { - flex-grow: 1; - display: flex; - align-items: center; - padding: 10px; - pointer-events: none; - } - - .mx_RadioButton { - flex-grow: 0; - padding: 10px; - } - - .mx_EventTile_content { - margin-right: 0; - } - - &.mx_AppearanceUserSettingsTab_Layout_RadioButton_selected { - border-color: $accent-color; - } - } - - .mx_RadioButton { - border-top: 1px solid $appearance-tab-border-color; - - > input + div { - border-color: rgba($muted-fg-color, 0.2); - } - } - - .mx_RadioButton_checked { - background-color: rgba($accent-color, 0.08); - } - - .mx_EventTile { - margin: 0; - &[data-layout=bubble] { - margin-right: 40px; - } - &[data-layout=irc] { - > a { - display: none; - } - } - .mx_EventTile_line { - max-width: 90%; - } - } -} - .mx_AppearanceUserSettingsTab_Advanced { color: $primary-fg-color; diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss index 0f879d209e..fbbe9909e7 100644 --- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss @@ -28,28 +28,32 @@ limitations under the License. user-select: all; } -.mx_HelpUserSettingsTab_accessToken { +.mx_HelpUserSettingsTab_copy { display: flex; - justify-content: space-between; border-radius: 5px; border: solid 1px $light-fg-color; margin-bottom: 10px; margin-top: 10px; padding: 10px; -} + width: max-content; -.mx_HelpUserSettingsTab_accessToken_copy { - flex-shrink: 0; - cursor: pointer; - margin-left: 20px; - display: inherit; -} + .mx_HelpUserSettingsTab_copyButton { + flex-shrink: 0; + width: 20px; + height: 20px; + cursor: pointer; + margin-left: 20px; + display: block; -.mx_HelpUserSettingsTab_accessToken_copy > div { - mask-image: url($copy-button-url); - background-color: $message-action-bar-fg-color; - margin-left: 5px; - width: 20px; - height: 20px; - background-repeat: no-repeat; + &::before { + content: ""; + + mask-image: url($copy-button-url); + background-color: $message-action-bar-fg-color; + width: 20px; + height: 20px; + display: block; + background-repeat: no-repeat; + } + } } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 8d8b68efd0..7752edddfa 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -199,120 +199,6 @@ limitations under the License. } } -.mx_CallView_header { - height: 44px; - display: flex; - flex-direction: row; - align-items: center; - justify-content: left; - flex-shrink: 0; - cursor: pointer; -} - -.mx_CallView_header_callType { - font-size: 1.2rem; - font-weight: bold; - vertical-align: middle; -} - -.mx_CallView_header_secondaryCallInfo { - &::before { - content: '·'; - margin-left: 6px; - margin-right: 6px; - } -} - -.mx_CallView_header_controls { - margin-left: auto; -} - -.mx_CallView_header_button { - display: inline-block; - vertical-align: middle; - cursor: pointer; - - &::before { - content: ''; - display: inline-block; - height: 20px; - width: 20px; - vertical-align: middle; - background-color: $secondary-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } -} - -.mx_CallView_header_button_fullscreen { - &::before { - mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); - } -} - -.mx_CallView_header_button_expand { - &::before { - mask-image: url('$(res)/img/element-icons/call/expand.svg'); - } -} - -.mx_CallView_header_callInfo { - margin-left: 12px; - margin-right: 16px; -} - -.mx_CallView_header_roomName { - font-weight: bold; - font-size: 12px; - line-height: initial; - height: 15px; -} - -.mx_CallView_secondaryCall_roomName { - margin-left: 4px; -} - -.mx_CallView_header_callTypeSmall { - font-size: 12px; - color: $secondary-fg-color; - line-height: initial; - height: 15px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 240px; -} - -.mx_CallView_header_callTypeIcon { - display: inline-block; - margin-right: 6px; - height: 16px; - width: 16px; - vertical-align: middle; - - &::before { - content: ''; - display: inline-block; - vertical-align: top; - - height: 16px; - width: 16px; - background-color: $secondary-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - } - - &.mx_CallView_header_callTypeIcon_voice::before { - mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); - } - - &.mx_CallView_header_callTypeIcon_video::before { - mask-image: url('$(res)/img/element-icons/call/video-call.svg'); - } -} - .mx_CallView_callControls { position: absolute; display: flex; diff --git a/res/css/views/voip/_CallViewHeader.scss b/res/css/views/voip/_CallViewHeader.scss new file mode 100644 index 0000000000..014cfce478 --- /dev/null +++ b/res/css/views/voip/_CallViewHeader.scss @@ -0,0 +1,129 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CallViewHeader { + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: left; + flex-shrink: 0; + cursor: pointer; +} + +.mx_CallViewHeader_callType { + font-size: 1.2rem; + font-weight: bold; + vertical-align: middle; +} + +.mx_CallViewHeader_secondaryCallInfo { + &::before { + content: '·'; + margin-left: 6px; + margin-right: 6px; + } +} + +.mx_CallViewHeader_controls { + margin-left: auto; +} + +.mx_CallViewHeader_button { + display: inline-block; + vertical-align: middle; + cursor: pointer; + + &::before { + content: ''; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: middle; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } +} + +.mx_CallViewHeader_button_fullscreen { + &::before { + mask-image: url('$(res)/img/element-icons/call/fullscreen.svg'); + } +} + +.mx_CallViewHeader_button_expand { + &::before { + mask-image: url('$(res)/img/element-icons/call/expand.svg'); + } +} + +.mx_CallViewHeader_callInfo { + margin-left: 12px; + margin-right: 16px; +} + +.mx_CallViewHeader_roomName { + font-weight: bold; + font-size: 12px; + line-height: initial; + height: 15px; +} + +.mx_CallView_secondaryCall_roomName { + margin-left: 4px; +} + +.mx_CallViewHeader_callTypeSmall { + font-size: 12px; + color: $secondary-fg-color; + line-height: initial; + height: 15px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 240px; +} + +.mx_CallViewHeader_callTypeIcon { + display: inline-block; + margin-right: 6px; + height: 16px; + width: 16px; + vertical-align: middle; + + &::before { + content: ''; + display: inline-block; + vertical-align: top; + + height: 16px; + width: 16px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + + &.mx_CallViewHeader_callTypeIcon_voice::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + + &.mx_CallViewHeader_callTypeIcon_video::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } +} diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 064b532bb0..b9429318ac 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -222,6 +222,13 @@ $appearance-tab-border-color: $room-highlight-color; $composer-shadow-color: tranparent; +// Bubble tiles +$eventbubble-self-bg: #14322E; +$eventbubble-others-bg: $event-selected-color; +$eventbubble-bg-hover: #1C2026; +$eventbubble-avatar-outline: $bg-color; +$eventbubble-reply-color: #C1C6CD; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index 1b9254d100..6c37351414 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -140,3 +140,10 @@ $event-highlight-bg-color: var(--timeline-highlights-color); // // redirect some variables away from their hardcoded values in the light theme $settings-grey-fg-color: $primary-fg-color; + +// --eventbubble colors +$eventbubble-self-bg: var(--eventbubble-self-bg, $eventbubble-self-bg); +$eventbubble-others-bg: var(--eventbubble-others-bg, $eventbubble-others-bg); +$eventbubble-bg-hover: var(--eventbubble-bg-hover, $eventbubble-bg-hover); +$eventbubble-avatar-outline: var(--eventbubble-avatar-outline, $eventbubble-avatar-outline); +$eventbubble-reply-color: var(--eventbubble-reply-color, $eventbubble-reply-color); diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 77569711df..41571666c3 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -509,13 +509,17 @@ export default class CallHandler extends EventEmitter { this.removeCallForRoom(mappedRoomId); if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) { this.play(AudioID.Busy); + + // Don't show a modal when we got rejected/the call was hung up + if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break; + let title; let description; // TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...) if (call.hangupReason === CallErrorCode.UserBusy) { title = _t("User Busy"); description = _t("The user you called is busy."); - } else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) { + } else { title = _t("Call Failed"); description = _t("The call could not be established"); } diff --git a/src/DateUtils.ts b/src/DateUtils.ts index e4a1175d88..e8b81ca315 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -123,6 +123,19 @@ export function formatTime(date: Date, showTwelveHour = false): string { return pad(date.getHours()) + ':' + pad(date.getMinutes()); } +export function formatCallTime(delta: Date): string { + const hours = delta.getUTCHours(); + const minutes = delta.getUTCMinutes(); + const seconds = delta.getUTCSeconds(); + + let output = ""; + if (hours) output += `${hours}h `; + if (minutes || output) output += `${minutes}m `; + if (seconds || output) output += `${seconds}s`; + + return output; +} + const MILLIS_IN_DAY = 86400000; export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { if (!nextEventDate || !prevEventDate) { diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts index ce3b530858..b48bb32efe 100644 --- a/src/components/structures/CallEventGrouper.ts +++ b/src/components/structures/CallEventGrouper.ts @@ -27,9 +27,15 @@ export enum CallEventGrouperEvent { SilencedChanged = "silenced_changed", } +const CONNECTING_STATES = [ + CallState.Connecting, + CallState.WaitLocalMedia, + CallState.CreateOffer, + CallState.CreateAnswer, +]; + const SUPPORTED_STATES = [ CallState.Connected, - CallState.Connecting, CallState.Ringing, ]; @@ -61,6 +67,10 @@ export default class CallEventGrouper extends EventEmitter { return [...this.events].find((event) => event.getType() === EventType.CallReject); } + private get selectAnswer(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer); + } + public get isVoice(): boolean { const invite = this.invite; if (!invite) return; @@ -82,6 +92,11 @@ export default class CallEventGrouper extends EventEmitter { return Boolean(this.reject); } + public get duration(): Date { + if (!this.hangup || !this.selectAnswer) return; + return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime()); + } + /** * Returns true if there are only events from the other side - we missed the call */ @@ -127,7 +142,9 @@ export default class CallEventGrouper extends EventEmitter { } private setState = () => { - if (SUPPORTED_STATES.includes(this.call?.state)) { + if (CONNECTING_STATES.includes(this.call?.state)) { + this.state = CallState.Connecting; + } else if (SUPPORTED_STATES.includes(this.call?.state)) { this.state = this.call.state; } else { if (this.callWasMissed) this.state = CustomCallState.Missed; diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 698a36127b..1691d90651 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; -const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl]; +const groupedEvents = [ + EventType.RoomMember, + EventType.RoomThirdPartyInvite, + EventType.RoomServerAcl, + EventType.RoomPinnedEvents, +]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL @@ -1234,7 +1239,7 @@ class RedactionGrouper extends BaseGrouper { // Wrap consecutive member events in a ListSummary, ignore if redacted class MemberGrouper extends BaseGrouper { static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { - return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType); + return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType); }; constructor( @@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper { if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { return false; } - return membershipTypes.includes(ev.getType() as EventType); + return groupedEvents.includes(ev.getType() as EventType); } public add(ev: MatrixEvent, showHiddenEvents?: boolean): void { diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 0899b1c72a..f62676a4fc 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -757,16 +757,20 @@ class TimelinePanel extends React.Component<IProps, IState> { } this.lastRMSentEventId = this.state.readMarkerEventId; + const roomId = this.props.timelineSet.room.roomId; + const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId); + debuglog('TimelinePanel: Sending Read Markers for ', this.props.timelineSet.room.roomId, 'rm', this.state.readMarkerEventId, lastReadEvent ? 'rr ' + lastReadEvent.getId() : '', + ' hidden:' + hiddenRR, ); MatrixClientPeg.get().setRoomReadMarkers( - this.props.timelineSet.room.roomId, + roomId, this.state.readMarkerEventId, lastReadEvent, // Could be null, in which case no RR is sent - {}, + { hidden: hiddenRR }, ).catch((e) => { // /read_markers API is not implemented on this HS, fallback to just RR if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { diff --git a/src/components/views/elements/AppPermission.tsx b/src/components/views/elements/AppPermission.tsx index 8dc874381a..c0543eb363 100644 --- a/src/components/views/elements/AppPermission.tsx +++ b/src/components/views/elements/AppPermission.tsx @@ -62,10 +62,10 @@ export default class AppPermission extends React.Component<IProps, IState> { // Set all this into the initial state this.state = { - ...urlInfo, - roomMember, - isWrapped: null, widgetDomain: null, + isWrapped: null, + roomMember, + ...urlInfo, }; } diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index d66319ca73..cbb0e17b42 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -34,7 +34,7 @@ interface IProps { // The list of room members for which to show avatars next to the summary summaryMembers?: RoomMember[]; // The text to show as the summary of this event list - summaryText?: string; + summaryText?: string | JSX.Element; // An array of EventTiles to render when expanded children: ReactNode[]; // Called when the event list expansion is toggled diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx index 604e6c63b0..0722cb872a 100644 --- a/src/components/views/elements/MemberEventListSummary.tsx +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { isValid3pidInvite } from "../../../RoomInvite"; import EventListSummary from "./EventListSummary"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import defaultDispatcher from '../../../dispatcher/dispatcher'; +import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; +import { Action } from '../../../dispatcher/actions'; +import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload'; +import { jsxJoin } from '../../../utils/ReactUtils'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; import { Layout } from '../../../settings/Layout'; +const onPinnedMessagesClick = (): void => { + defaultDispatcher.dispatch<SetRightPanelPhasePayload>({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.PinnedMessages, + allowClose: false, + }); +}; + +const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents]; + interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> { // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" summaryLength?: number; @@ -60,6 +76,7 @@ enum TransitionType { ChangedAvatar = "changed_avatar", NoChange = "no_change", ServerAcl = "server_acl", + ChangedPins = "pinned_messages" } const SEP = ","; @@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component<IProps> { * `Object.keys(eventAggregates)`. * @returns {string} the textual summary of the aggregated events that occurred. */ - private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) { + private generateSummary( + eventAggregates: Record<string, string[]>, + orderedTransitionSequences: string[], + ): string | JSX.Element { const summaries = orderedTransitionSequences.map((transitions) => { const userNames = eventAggregates[transitions]; const nameList = this.renderNameList(userNames); @@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component<IProps> { return null; } - return summaries.join(", "); + return jsxJoin(summaries, ", "); } /** @@ -216,7 +236,11 @@ export default class MemberEventListSummary extends React.Component<IProps> { * @param {number} repeats the number of times the transition was repeated in a row. * @returns {string} the written Human Readable equivalent of the transition. */ - private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) { + private static getDescriptionForTransition( + t: TransitionType, + userCount: number, + repeats: number, + ): string | JSX.Element { // The empty interpolations 'severalUsers' and 'oneUser' // are there only to show translators to non-English languages // that the verb is conjugated to plural or singular Subject. @@ -299,6 +323,15 @@ export default class MemberEventListSummary extends React.Component<IProps> { { severalUsers: "", count: repeats }) : _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats }); break; + case "pinned_messages": + res = (userCount > 1) + ? _t("%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.", + { severalUsers: "", count: repeats }, + { "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> }) + : _t("%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.", + { oneUser: "", count: repeats }, + { "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> }); + break; } return res; @@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component<IProps> { * if a transition is not recognised. */ private static getTransition(e: IUserEvents): TransitionType { - if (e.mxEvent.getType() === 'm.room.third_party_invite') { + const type = e.mxEvent.getType(); + + if (type === EventType.RoomThirdPartyInvite) { // Handle 3pid invites the same as invites so they get bundled together if (!isValid3pidInvite(e.mxEvent)) { return TransitionType.InviteWithdrawal; } return TransitionType.Invited; - } - - if (e.mxEvent.getType() === 'm.room.server_acl') { + } else if (type === EventType.RoomServerAcl) { return TransitionType.ServerAcl; + } else if (type === EventType.RoomPinnedEvents) { + return TransitionType.ChangedPins; } switch (e.mxEvent.getContent().membership) { @@ -415,22 +450,23 @@ export default class MemberEventListSummary extends React.Component<IProps> { // Object mapping user IDs to an array of IUserEvents const userEvents: Record<string, IUserEvents[]> = {}; eventsToRender.forEach((e, index) => { - const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey(); + const type = e.getType(); + const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey(); // Initialise a user's events if (!userEvents[userId]) { userEvents[userId] = []; } - if (e.getType() === 'm.room.server_acl') { + if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { latestUserAvatarMember.set(userId, e.sender); } else if (e.target) { latestUserAvatarMember.set(userId, e.target); } let displayName = userId; - if (e.getType() === 'm.room.third_party_invite') { + if (type === EventType.RoomThirdPartyInvite) { displayName = e.getContent().display_name; - } else if (e.getType() === 'm.room.server_acl') { + } else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { displayName = e.sender.name; } else if (e.target) { displayName = e.target.name; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index a204907caa..bc868c35b3 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -25,6 +25,7 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call'; import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip'; import classNames from 'classnames'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; +import { formatCallTime } from "../../../DateUtils"; interface IProps { mxEvent: MatrixEvent; @@ -131,9 +132,14 @@ export default class CallEvent extends React.Component<IProps, IState> { // https://github.com/vector-im/riot-android/issues/2623 // Also the correct hangup code as of VoIP v1 (with underscore) // Also, if we don't have a reason + const duration = this.props.callEventGrouper.duration; + let text = _t("Call ended"); + if (duration) { + text += " • " + formatCallTime(duration); + } return ( <div className="mx_CallEvent_content"> - { _t("Call ended") } + { text } </div> ); } else if (hangupReason === CallErrorCode.InviteTimeout) { diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 969caccaee..83fe7f5a3d 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -136,7 +136,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> { private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void { // Calculate how many percent does the pre element take up. // If it's less than 30% we don't add the expansion button. - const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100; + // We also round the number as it sometimes can be 29.99... + const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100); if (percentageOfViewport < 30) return; const button = document.createElement("span"); diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 55baf9cb73..db3d3eee5e 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 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. @@ -725,7 +724,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState> <MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} /> <div className={classes} - contentEditable="true" + contentEditable={this.props.disabled ? null : true} tabIndex={0} onBlur={this.onBlur} onFocus={this.onFocus} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 0bc3779f80..8455e9aa11 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -58,6 +58,7 @@ function ComposerAvatar(props: IComposerAvatarProps) { interface ISendButtonProps { onClick: () => void; + title?: string; // defaults to something generic } function SendButton(props: ISendButtonProps) { @@ -65,7 +66,7 @@ function SendButton(props: ISendButtonProps) { <AccessibleTooltipButton className="mx_MessageComposer_sendMessage" onClick={props.onClick} - title={_t('Send message')} + title={props.title ?? _t('Send message')} /> ); } @@ -401,7 +402,11 @@ export default class MessageComposer extends React.Component<IProps, IState> { if (!this.state.isComposerEmpty || this.state.haveRecording) { controls.push( - <SendButton key="controls_send" onClick={this.sendMessage} />, + <SendButton + key="controls_send" + onClick={this.sendMessage} + title={this.state.haveRecording ? _t("Send voice message") : undefined} + />, ); } } else if (this.state.tombstone) { diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx index 16a7141bd7..8c0e09c76c 100644 --- a/src/components/views/rooms/ReplyTile.tsx +++ b/src/components/views/rooms/ReplyTile.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { createRef } from 'react'; import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; @@ -38,6 +38,8 @@ interface IProps { @replaceableComponent("views.rooms.ReplyTile") export default class ReplyTile extends React.PureComponent<IProps> { + private anchorElement = createRef<HTMLAnchorElement>(); + static defaultProps = { onHeightChanged: () => {}, }; @@ -71,7 +73,11 @@ export default class ReplyTile extends React.PureComponent<IProps> { // Following a link within a reply should not dispatch the `view_room` action // so that the browser can direct the user to the correct location // The exception being the link wrapping the reply - if (clickTarget.tagName.toLowerCase() !== "a" || clickTarget.closest("a") === null) { + if ( + clickTarget.tagName.toLowerCase() !== "a" || + clickTarget.closest("a") === null || + clickTarget === this.anchorElement.current + ) { // This allows the permalink to be opened in a new tab/window or copied as // matrix.to, but also for it to enable routing within Riot when clicked. e.preventDefault(); @@ -141,7 +147,7 @@ export default class ReplyTile extends React.PureComponent<IProps> { return ( <div className={classes}> - <a href={permalink} onClick={this.onClick}> + <a href={permalink} onClick={this.onClick} ref={this.anchorElement}> { sender } <EventTileType ref="tile" diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 1b583444a3..e8befb90fa 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -215,7 +215,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps, } public render(): ReactNode { - let recordingInfo; + let stopOrRecordBtn; let deleteButton; if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) { const classes = classNames({ @@ -229,7 +229,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps, tooltip = _t("Stop recording"); } - let stopOrRecordBtn = <AccessibleTooltipButton + stopOrRecordBtn = <AccessibleTooltipButton className={classes} onClick={this.onRecordStartEndClick} title={tooltip} @@ -237,8 +237,6 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps, if (this.state.recorder && !this.state.recorder?.isRecording) { stopOrRecordBtn = null; } - - recordingInfo = stopOrRecordBtn; } if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) { @@ -266,11 +264,14 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps, </span>; } + // The record button (mic icon) is meant to be on the right edge, but we also want the + // stop button to be left of the waveform area. Luckily, none of the surrounding UI is + // rendered when we're not recording, so the record button ends up in the correct spot. return (<> { uploadIndicator } { deleteButton } + { stopOrRecordBtn } { this.renderWaveformArea() } - { recordingInfo } </>); } } diff --git a/src/components/views/settings/LayoutSwitcher.tsx b/src/components/views/settings/LayoutSwitcher.tsx new file mode 100644 index 0000000000..dd7accf9a8 --- /dev/null +++ b/src/components/views/settings/LayoutSwitcher.tsx @@ -0,0 +1,133 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import classNames from "classnames"; +import SettingsStore from "../../../settings/SettingsStore"; +import EventTilePreview from "../elements/EventTilePreview"; +import StyledRadioButton from "../elements/StyledRadioButton"; +import { _t } from "../../../languageHandler"; +import { Layout } from "../../../settings/Layout"; +import { SettingLevel } from "../../../settings/SettingLevel"; + +interface IProps { + userId: string; + displayName: string; + avatarUrl: string; + messagePreviewText: string; + onLayoutChanged?: (layout: Layout) => void; +} + +interface IState { + layout: Layout; +} + +export default class LayoutSwitcher extends React.Component<IProps, IState> { + constructor(props: IProps) { + super(props); + + this.state = { + layout: SettingsStore.getValue("layout"), + }; + } + + private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const layout = e.target.value as Layout; + + this.setState({ layout: layout }); + SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout); + this.props.onLayoutChanged(layout); + }; + + public render(): JSX.Element { + const ircClasses = classNames("mx_LayoutSwitcher_RadioButton", { + mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.IRC, + }); + const groupClasses = classNames("mx_LayoutSwitcher_RadioButton", { + mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.Group, + }); + const bubbleClasses = classNames("mx_LayoutSwitcher_RadioButton", { + mx_LayoutSwitcher_RadioButton_selected: this.state.layout === Layout.Bubble, + }); + + return ( + <div className="mx_SettingsTab_section mx_LayoutSwitcher"> + <span className="mx_SettingsTab_subheading"> + { _t("Message layout") } + </span> + + <div className="mx_LayoutSwitcher_RadioButtons"> + <label className={ircClasses}> + <EventTilePreview + className="mx_LayoutSwitcher_RadioButton_preview" + message={this.props.messagePreviewText} + layout={Layout.IRC} + userId={this.props.userId} + displayName={this.props.displayName} + avatarUrl={this.props.avatarUrl} + /> + <StyledRadioButton + name="layout" + value={Layout.IRC} + checked={this.state.layout === Layout.IRC} + onChange={this.onLayoutChange} + > + { _t("IRC") } + </StyledRadioButton> + </label> + <label className={groupClasses}> + <EventTilePreview + className="mx_LayoutSwitcher_RadioButton_preview" + message={this.props.messagePreviewText} + layout={Layout.Group} + userId={this.props.userId} + displayName={this.props.displayName} + avatarUrl={this.props.avatarUrl} + /> + <StyledRadioButton + name="layout" + value={Layout.Group} + checked={this.state.layout == Layout.Group} + onChange={this.onLayoutChange} + > + { _t("Modern") } + </StyledRadioButton> + </label> + <label className={bubbleClasses}> + <EventTilePreview + className="mx_LayoutSwitcher_RadioButton_preview" + message={this.props.messagePreviewText} + layout={Layout.Bubble} + userId={this.props.userId} + displayName={this.props.displayName} + avatarUrl={this.props.avatarUrl} + /> + <StyledRadioButton + name="layout" + value={Layout.Bubble} + checked={this.state.layout == Layout.Bubble} + onChange={this.onLayoutChange} + > + { _t("Message bubbles") } + </StyledRadioButton> + </label> + </div> + </div> + ); + } +} diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 44873816dc..cbf0b7916c 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -1,6 +1,6 @@ /* Copyright 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 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. @@ -37,10 +37,9 @@ import StyledRadioGroup from "../../../elements/StyledRadioGroup"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { UIFeature } from "../../../../../settings/UIFeature"; import { Layout } from "../../../../../settings/Layout"; -import classNames from 'classnames'; -import StyledRadioButton from '../../../elements/StyledRadioButton'; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { compare } from "../../../../../utils/strings"; +import LayoutSwitcher from "../../LayoutSwitcher"; interface IProps { } @@ -243,17 +242,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I this.setState({ customThemeUrl: e.target.value }); }; - private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => { - let layout; - switch (e.target.value) { - case "irc": layout = Layout.IRC; break; - case "group": layout = Layout.Group; break; - case "bubble": layout = Layout.Bubble; break; - } - + private onLayoutChanged = (layout: Layout): void => { this.setState({ layout: layout }); - - SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout); }; private onIRCLayoutChange = (enabled: boolean) => { @@ -391,75 +381,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I </div>; } - private renderLayoutSection = () => { - return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout"> - <span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span> - - <div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons"> - <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { - mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC, - })}> - <EventTilePreview - className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview" - message={this.MESSAGE_PREVIEW_TEXT} - layout={Layout.IRC} - userId={this.state.userId} - displayName={this.state.displayName} - avatarUrl={this.state.avatarUrl} - /> - <StyledRadioButton - name="layout" - value="irc" - checked={this.state.layout === Layout.IRC} - onChange={this.onLayoutChange} - > - { _t("IRC") } - </StyledRadioButton> - </label> - <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { - mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group, - })}> - <EventTilePreview - className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview" - message={this.MESSAGE_PREVIEW_TEXT} - layout={Layout.Group} - userId={this.state.userId} - displayName={this.state.displayName} - avatarUrl={this.state.avatarUrl} - /> - <StyledRadioButton - name="layout" - value="group" - checked={this.state.layout == Layout.Group} - onChange={this.onLayoutChange} - > - { _t("Modern") } - </StyledRadioButton> - </label> - <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", { - mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble, - })}> - <EventTilePreview - className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview" - message={this.MESSAGE_PREVIEW_TEXT} - layout={Layout.Bubble} - userId={this.state.userId} - displayName={this.state.displayName} - avatarUrl={this.state.avatarUrl} - /> - <StyledRadioButton - name="layout" - value="bubble" - checked={this.state.layout == Layout.Bubble} - onChange={this.onLayoutChange} - > - { _t("Message bubbles") } - </StyledRadioButton> - </label> - </div> - </div>; - }; - private renderAdvancedSection() { if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null; @@ -527,6 +448,19 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I render() { const brand = SdkConfig.get().brand; + let layoutSection; + if (SettingsStore.getValue("feature_new_layout_switcher")) { + layoutSection = ( + <LayoutSwitcher + userId={this.state.userId} + displayName={this.state.displayName} + avatarUrl={this.state.avatarUrl} + messagePreviewText={this.MESSAGE_PREVIEW_TEXT} + onLayoutChanged={this.onLayoutChanged} + /> + ); + } + return ( <div className="mx_SettingsTab mx_AppearanceUserSettingsTab"> <div className="mx_SettingsTab_heading">{ _t("Customise your appearance") }</div> @@ -534,7 +468,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I { _t("Appearance Settings only affect this %(brand)s session.", { brand }) } </div> { this.renderThemeSection() } - { SettingsStore.getValue("feature_new_layout_switcher") ? this.renderLayoutSection() : null } + { layoutSection } { this.renderFontSection() } { this.renderAdvancedSection() } </div> diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index eaf52e6062..904fdf0914 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -15,9 +15,9 @@ limitations under the License. */ import React from 'react'; +import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton"; import { _t, getCurrentLanguage } from "../../../../../languageHandler"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton'; import SdkConfig from "../../../../../SdkConfig"; import createRoom from "../../../../../createRoom"; @@ -69,6 +69,18 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState> if (this.closeCopiedTooltip) this.closeCopiedTooltip(); } + private getVersionInfo(): { appVersion: string, olmVersion: string } { + const brand = SdkConfig.get().brand; + const appVersion = this.state.appVersion || 'unknown'; + let olmVersion = MatrixClientPeg.get().olmVersion; + olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>'; + + return { + appVersion: `${_t("%(brand)s version:", { brand })} ${appVersion}`, + olmVersion: `${_t("Olm version:")} ${olmVersion}`, + }; + } + private onClearCacheAndReload = (e) => { if (!PlatformPeg.get()) return; @@ -173,17 +185,26 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState> ); } - onAccessTokenCopyClick = async (e) => { + private async copy(text: string, e: ButtonEvent) { e.preventDefault(); - const target = e.target; // copy target before we go async and React throws it away + const target = e.target as HTMLDivElement; // copy target before we go async and React throws it away - const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken()); + const successful = await copyPlaintext(text); const buttonRect = target.getBoundingClientRect(); const { close } = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), }); this.closeCopiedTooltip = target.onmouseleave = close; + } + + private onAccessTokenCopyClick = (e: ButtonEvent) => { + this.copy(MatrixClientPeg.get().getAccessToken(), e); + }; + + private onCopyVersionClicked = (e: ButtonEvent) => { + const { appVersion, olmVersion } = this.getVersionInfo(); + this.copy(`${appVersion}\n${olmVersion}`, e); }; render() { @@ -232,11 +253,6 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState> ); } - const appVersion = this.state.appVersion || 'unknown'; - - let olmVersion = MatrixClientPeg.get().olmVersion; - olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>'; - let updateButton = null; if (this.state.canUpdate) { updateButton = <UpdateCheckButton />; @@ -275,6 +291,8 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState> ); } + const { appVersion, olmVersion } = this.getVersionInfo(); + return ( <div className="mx_SettingsTab mx_HelpUserSettingsTab"> <div className="mx_SettingsTab_heading">{ _t("Help & About") }</div> @@ -291,8 +309,15 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState> <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'> <span className='mx_SettingsTab_subheading'>{ _t("Versions") }</span> <div className='mx_SettingsTab_subsectionText'> - { _t("%(brand)s version:", { brand }) } { appVersion }<br /> - { _t("olm version:") } { olmVersion }<br /> + <div className="mx_HelpUserSettingsTab_copy"> + { appVersion }<br /> + { olmVersion }<br /> + <AccessibleTooltipButton + title={_t("Copy")} + onClick={this.onCopyVersionClicked} + className="mx_HelpUserSettingsTab_copyButton" + /> + </div> { updateButton } </div> </div> @@ -308,12 +333,12 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState> <summary>{ _t("Access Token") }</summary><br /> <b>{ _t("Your access token gives full access to your account." + " Do not share it with anyone." ) }</b> - <div className="mx_HelpUserSettingsTab_accessToken"> + <div className="mx_HelpUserSettingsTab_copy"> <code>{ MatrixClientPeg.get().getAccessToken() }</code> <AccessibleTooltipButton title={_t("Copy")} onClick={this.onAccessTokenCopyClick} - className="mx_HelpUserSettingsTab_accessToken_copy" + className="mx_HelpUserSettingsTab_copyButton" /> </div> </details><br /> diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js index fa854fc4d8..19a97151d6 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js @@ -19,11 +19,12 @@ import { _t } from "../../../../../languageHandler"; import PropTypes from "prop-types"; import SettingsStore from "../../../../../settings/SettingsStore"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; -import * as sdk from "../../../../../index"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import SdkConfig from "../../../../../SdkConfig"; import BetaCard from "../../../beta/BetaCard"; +import SettingsFlag from '../../../elements/SettingsFlag'; +import { MatrixClientPeg } from '../../../../../MatrixClientPeg'; export class LabsSettingToggle extends React.Component { static propTypes = { @@ -47,6 +48,14 @@ export class LabsSettingToggle extends React.Component { export default class LabsUserSettingsTab extends React.Component { constructor() { super(); + + MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285").then((showHiddenReadReceipts) => { + this.setState({ showHiddenReadReceipts }); + }); + + this.state = { + showHiddenReadReceipts: false, + }; } render() { @@ -65,15 +74,22 @@ export default class LabsUserSettingsTab extends React.Component { let labsSection; if (SdkConfig.get()['showLabsSettings']) { - const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); const flags = labs.map(f => <LabsSettingToggle featureId={f} key={f} />); + let hiddenReadReceipts; + if (this.state.showHiddenReadReceipts) { + hiddenReadReceipts = ( + <SettingsFlag name="feature_hidden_read_receipts" level={SettingLevel.ACCOUNT} /> + ); + } + labsSection = <div className="mx_SettingsTab_section"> { flags } <SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} /> <SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} /> <SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} /> <SettingsFlag name="advancedRoomListLogging" level={SettingLevel.DEVICE} /> + { hiddenReadReceipts } </div>; } diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 46ff8ca838..2aa3080e60 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React from 'react'; import CallView from "./CallView"; import RoomViewStore from '../../../stores/RoomViewStore'; @@ -27,23 +27,8 @@ import SettingsStore from "../../../settings/SettingsStore"; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import UIStore from '../../../stores/UIStore'; -import { lerp } from '../../../utils/AnimationUtils'; -import { MarkedExecution } from '../../../utils/MarkedExecution'; import { EventSubscription } from 'fbemitter'; - -const PIP_VIEW_WIDTH = 336; -const PIP_VIEW_HEIGHT = 232; - -const MOVING_AMT = 0.2; -const SNAPPING_AMT = 0.1; - -const PADDING = { - top: 58, - bottom: 58, - left: 76, - right: 8, -}; +import PictureInPictureDragger from './PictureInPictureDragger'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -66,10 +51,6 @@ interface IState { // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms // they belong to secondaryCall: MatrixCall; - - // Position of the CallPreview - translationX: number; - translationY: number; } // Splits a list of calls into one 'primary' one and a list @@ -112,16 +93,6 @@ export default class CallPreview extends React.Component<IProps, IState> { private roomStoreToken: EventSubscription; private dispatcherRef: string; private settingsWatcherRef: string; - private callViewWrapper = createRef<HTMLDivElement>(); - private initX = 0; - private initY = 0; - private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH; - private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH; - private moving = false; - private scheduledUpdate = new MarkedExecution( - () => this.animationCallback(), - () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), - ); constructor(props: IProps) { super(props); @@ -136,17 +107,12 @@ export default class CallPreview extends React.Component<IProps, IState> { roomId, primaryCall: primaryCall, secondaryCall: secondaryCalls[0], - translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH, - translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH, }; } public componentDidMount() { CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); - document.addEventListener("mousemove", this.onMoving); - document.addEventListener("mouseup", this.onEndMoving); - window.addEventListener("resize", this.onResize); this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); } @@ -154,9 +120,6 @@ export default class CallPreview extends React.Component<IProps, IState> { public componentWillUnmount() { CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - document.removeEventListener("mousemove", this.onMoving); - document.removeEventListener("mouseup", this.onEndMoving); - window.removeEventListener("resize", this.onResize); if (this.roomStoreToken) { this.roomStoreToken.remove(); } @@ -164,94 +127,6 @@ export default class CallPreview extends React.Component<IProps, IState> { SettingsStore.unwatchSetting(this.settingsWatcherRef); } - private onResize = (): void => { - this.snap(false); - }; - - private animationCallback = () => { - // If the PiP isn't being dragged and there is only a tiny difference in - // the desiredTranslation and translation, quit the animationCallback - // loop. If that is the case, it means the PiP has snapped into its - // position and there is nothing to do. Not doing this would cause an - // infinite loop - if ( - !this.moving && - Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 && - Math.abs(this.state.translationY - this.desiredTranslationY) <= 1 - ) return; - - const amt = this.moving ? MOVING_AMT : SNAPPING_AMT; - this.setState({ - translationX: lerp(this.state.translationX, this.desiredTranslationX, amt), - translationY: lerp(this.state.translationY, this.desiredTranslationY, amt), - }); - this.scheduledUpdate.mark(); - }; - - private setTranslation(inTranslationX: number, inTranslationY: number) { - const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH; - const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT; - - // Avoid overflow on the x axis - if (inTranslationX + width >= UIStore.instance.windowWidth) { - this.desiredTranslationX = UIStore.instance.windowWidth - width; - } else if (inTranslationX <= 0) { - this.desiredTranslationX = 0; - } else { - this.desiredTranslationX = inTranslationX; - } - - // Avoid overflow on the y axis - if (inTranslationY + height >= UIStore.instance.windowHeight) { - this.desiredTranslationY = UIStore.instance.windowHeight - height; - } else if (inTranslationY <= 0) { - this.desiredTranslationY = 0; - } else { - this.desiredTranslationY = inTranslationY; - } - } - - private snap(animate?: boolean): void { - const translationX = this.desiredTranslationX; - const translationY = this.desiredTranslationY; - // We subtract the PiP size from the window size in order to calculate - // the position to snap to from the PiP center and not its top-left - // corner - const windowWidth = ( - UIStore.instance.windowWidth - - (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH) - ); - const windowHeight = ( - UIStore.instance.windowHeight - - (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT) - ); - - if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) { - this.desiredTranslationX = windowWidth - PADDING.right; - this.desiredTranslationY = windowHeight - PADDING.bottom; - } else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) { - this.desiredTranslationX = windowWidth - PADDING.right; - this.desiredTranslationY = PADDING.top; - } else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) { - this.desiredTranslationX = PADDING.left; - this.desiredTranslationY = windowHeight - PADDING.bottom; - } else { - this.desiredTranslationX = PADDING.left; - this.desiredTranslationY = PADDING.top; - } - - if (animate) { - // We start animating here because we want the PiP to move when we're - // resizing the window - this.scheduledUpdate.mark(); - } else { - this.setState({ - translationX: this.desiredTranslationX, - translationY: this.desiredTranslationY, - }); - } - } - private onRoomViewStoreUpdate = () => { if (RoomViewStore.getRoomId() === this.state.roomId) return; @@ -269,9 +144,10 @@ export default class CallPreview extends React.Component<IProps, IState> { private onAction = (payload: ActionPayload) => { switch (payload.action) { - // listen for call state changes to prod the render method, which - // may hide the global CallView if the call it is tracking is dead case 'call_state': { + // listen for call state changes to prod the render method, which + // may hide the global CallView if the call it is tracking is dead + this.updateCalls(); break; } @@ -300,57 +176,26 @@ export default class CallPreview extends React.Component<IProps, IState> { }); }; - private onStartMoving = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - this.moving = true; - this.initX = event.pageX - this.desiredTranslationX; - this.initY = event.pageY - this.desiredTranslationY; - this.scheduledUpdate.mark(); - }; - - private onMoving = (event: React.MouseEvent | MouseEvent) => { - if (!this.moving) return; - - event.preventDefault(); - event.stopPropagation(); - - this.setTranslation(event.pageX - this.initX, event.pageY - this.initY); - }; - - private onEndMoving = () => { - this.moving = false; - this.snap(true); - }; - public render() { + const pipMode = true; if (this.state.primaryCall) { - const translatePixelsX = this.state.translationX + "px"; - const translatePixelsY = this.state.translationY + "px"; - const style = { - transform: `translateX(${translatePixelsX}) - translateY(${translatePixelsY})`, - }; - return ( - <div + <PictureInPictureDragger className="mx_CallPreview" - style={style} - ref={this.callViewWrapper} + draggable={pipMode} > - <CallView + { ({ onStartMoving, onResize }) => <CallView + onMouseDownOnHeader={onStartMoving} call={this.state.primaryCall} secondaryCall={this.state.secondaryCall} - pipMode={true} - onMouseDownOnHeader={this.onStartMoving} - onResize={this.onResize} - /> - </div> + pipMode={pipMode} + onResize={onResize} + /> } + </PictureInPictureDragger> + ); } return <PersistentApp />; } } - diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 570d49f715..76e6a43ca5 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -42,28 +42,29 @@ import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker import Modal from '../../../Modal'; import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes'; import CallViewSidebar from './CallViewSidebar'; +import CallViewHeader from './CallView/CallViewHeader'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { Alignment } from "../elements/Tooltip"; interface IProps { - // The call for us to display - call: MatrixCall; + // The call for us to display + call: MatrixCall; - // Another ongoing call to display information about - secondaryCall?: MatrixCall; + // Another ongoing call to display information about + secondaryCall?: MatrixCall; - // a callback which is called when the content in the CallView changes - // in a way that is likely to cause a resize. - onResize?: any; + // a callback which is called when the content in the CallView changes + // in a way that is likely to cause a resize. + onResize?: (event: Event) => void; - // Whether this call view is for picture-in-picture mode - // otherwise, it's the larger call view when viewing the room the call is in. - // This is sort of a proxy for a number of things but we currently have no - // need to control those things separately, so this is simpler. - pipMode?: boolean; + // Whether this call view is for picture-in-picture mode + // otherwise, it's the larger call view when viewing the room the call is in. + // This is sort of a proxy for a number of things but we currently have no + // need to control those things separately, so this is simpler. + pipMode?: boolean; - // Used for dragging the PiP CallView - onMouseDownOnHeader?: (event: React.MouseEvent) => void; + // Used for dragging the PiP CallView + onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void; } interface IState { @@ -152,6 +153,7 @@ export default class CallView extends React.Component<IProps, IState> { public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); document.addEventListener('keydown', this.onNativeKeyDown); + this.showControls(); } public componentWillUnmount() { @@ -239,21 +241,6 @@ export default class CallView extends React.Component<IProps, IState> { }); }; - private onFullscreenClick = () => { - dis.dispatch({ - action: 'video_fullscreen', - fullscreen: true, - }); - }; - - private onExpandClick = () => { - const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); - dis.dispatch({ - action: 'view_room', - room_id: userFacingRoomId, - }); - }; - private onControlsHideTimer = () => { if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return; this.controlsHideTimer = null; @@ -397,23 +384,6 @@ export default class CallView extends React.Component<IProps, IState> { this.setState({ hoveringControls: false }); }; - private onRoomAvatarClick = (): void => { - const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); - dis.dispatch({ - action: 'view_room', - room_id: userFacingRoomId, - }); - }; - - private onSecondaryRoomAvatarClick = (): void => { - const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall); - - dis.dispatch({ - action: 'view_room', - room_id: userFacingRoomId, - }); - }; - private onCallResumeClick = (): void => { const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId); @@ -726,7 +696,7 @@ export default class CallView extends React.Component<IProps, IState> { let onHoldBackground = null; const backgroundStyle: CSSProperties = {}; const backgroundAvatarUrl = avatarUrlForMember( - // is it worth getting the size of the div to pass here? + // is it worth getting the size of the div to pass here? this.props.call.getOpponentMember(), 1024, 1024, 'crop', ); backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')'; @@ -746,7 +716,7 @@ export default class CallView extends React.Component<IProps, IState> { mx_CallView_voice_hold: isOnHold, }); - contentView =( + contentView = ( <div className={classes} onMouseMove={this.onMouseMove}> <div className="mx_CallView_voice_avatarsContainer"> <div @@ -848,83 +818,15 @@ export default class CallView extends React.Component<IProps, IState> { ); } - const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call"); - let myClassName; - - let fullScreenButton; - if (!this.props.pipMode) { - fullScreenButton = ( - <AccessibleTooltipButton - className="mx_CallView_header_button mx_CallView_header_button_fullscreen" - onClick={this.onFullscreenClick} - title={_t("Fill Screen")} - /> - ); - } - - let expandButton; - if (this.props.pipMode) { - expandButton = <AccessibleTooltipButton - className="mx_CallView_header_button mx_CallView_header_button_expand" - onClick={this.onExpandClick} - title={_t("Return to call")} - />; - } - - const headerControls = <div className="mx_CallView_header_controls"> - { fullScreenButton } - { expandButton } - </div>; - - const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", { - "mx_CallView_header_callTypeIcon_voice": !isVideoCall, - "mx_CallView_header_callTypeIcon_video": isVideoCall, - }); - - let header: React.ReactNode; - if (!this.props.pipMode) { - header = <div className="mx_CallView_header"> - <div className={callTypeIconClassName} /> - <span className="mx_CallView_header_callType">{ callTypeText }</span> - { headerControls } - </div>; - myClassName = 'mx_CallView_large'; - } else { - let secondaryCallInfo; - if (this.props.secondaryCall) { - secondaryCallInfo = <span className="mx_CallView_header_secondaryCallInfo"> - <AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}> - <RoomAvatar room={secCallRoom} height={16} width={16} /> - <span className="mx_CallView_secondaryCall_roomName"> - { _t("%(name)s on hold", { name: secCallRoom.name }) } - </span> - </AccessibleButton> - </span>; - } - - header = ( - <div - className="mx_CallView_header" - onMouseDown={this.props.onMouseDownOnHeader} - > - <AccessibleButton onClick={this.onRoomAvatarClick}> - <RoomAvatar room={callRoom} height={32} width={32} /> - </AccessibleButton> - <div className="mx_CallView_header_callInfo"> - <div className="mx_CallView_header_roomName">{ callRoom.name }</div> - <div className="mx_CallView_header_callTypeSmall"> - { callTypeText } - { secondaryCallInfo } - </div> - </div> - { headerControls } - </div> - ); - myClassName = 'mx_CallView_pip'; - } + const myClassName = this.props.pipMode ? 'mx_CallView_pip' : 'mx_CallView_large'; return <div className={"mx_CallView " + myClassName}> - { header } + <CallViewHeader + onPipMouseDown={this.props.onMouseDownOnHeader} + pipMode={this.props.pipMode} + type={this.props.call.type} + callRooms={[callRoom, secCallRoom]} + /> { contentView } </div>; } diff --git a/src/components/views/voip/CallView/CallViewHeader.tsx b/src/components/views/voip/CallView/CallViewHeader.tsx new file mode 100644 index 0000000000..d9a49e5010 --- /dev/null +++ b/src/components/views/voip/CallView/CallViewHeader.tsx @@ -0,0 +1,135 @@ +/* +Copyright 2021 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 { CallType } from 'matrix-js-sdk/src/webrtc/call'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import React from 'react'; +import { _t, _td } from '../../../../languageHandler'; +import RoomAvatar from '../../avatars/RoomAvatar'; +import AccessibleButton from '../../elements/AccessibleButton'; +import dis from '../../../../dispatcher/dispatcher'; +import classNames from 'classnames'; +import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton'; + +const callTypeTranslationByType: Record<CallType, string> = { + [CallType.Video]: _td("Video Call"), + [CallType.Voice]: _td("Voice Call"), +}; + +interface CallViewHeaderProps { + pipMode: boolean; + type: CallType; + callRooms?: Room[]; + onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void; +} + +const onRoomAvatarClick = (roomId: string) => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); +}; + +const onFullscreenClick = () => { + dis.dispatch({ + action: 'video_fullscreen', + fullscreen: true, + }); +}; + +const onExpandClick = (roomId: string) => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); +}; + +type CallControlsProps = Pick<CallViewHeaderProps, 'pipMode' | 'type'> & { + roomId: string; +}; +const CallViewHeaderControls: React.FC<CallControlsProps> = ({ pipMode = false, type, roomId }) => { + return <div className="mx_CallViewHeader_controls"> + { !pipMode && <AccessibleTooltipButton + className="mx_CallViewHeader_button mx_CallViewHeader_button_fullscreen" + onClick={onFullscreenClick} + title={_t("Fill Screen")} + /> } + { pipMode && <AccessibleTooltipButton + className="mx_CallViewHeader_button mx_CallViewHeader_button_expand" + onClick={() => onExpandClick(roomId)} + title={_t("Return to call")} + /> } + </div>; +}; +const SecondaryCallInfo: React.FC<{ callRoom: Room }> = ({ callRoom }) => { + return <span className="mx_CallViewHeader_secondaryCallInfo"> + <AccessibleButton element='span' onClick={() => onRoomAvatarClick(callRoom.roomId)}> + <RoomAvatar room={callRoom} height={16} width={16} /> + <span className="mx_CallView_secondaryCall_roomName"> + { _t("%(name)s on hold", { name: callRoom.name }) } + </span> + </AccessibleButton> + </span>; +}; + +const CallTypeIcon: React.FC<{ type: CallType }> = ({ type }) => { + const classes = classNames({ + 'mx_CallViewHeader_callTypeIcon': true, + 'mx_CallViewHeader_callTypeIcon_video': type === CallType.Video, + 'mx_CallViewHeader_callTypeIcon_voice': type === CallType.Voice, + }); + return <div className={classes} />; +}; + +const CallViewHeader: React.FC<CallViewHeaderProps> = ({ + type, + pipMode = false, + callRooms = [], + onPipMouseDown, +}) => { + const [callRoom, onHoldCallRoom] = callRooms; + const callTypeText = _t(callTypeTranslationByType[type]); + const callRoomName = callRoom.name; + const { roomId } = callRoom; + + if (!pipMode) { + return <div className="mx_CallViewHeader"> + <CallTypeIcon type={type} /> + <span className="mx_CallViewHeader_callType">{ callTypeText }</span> + <CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} /> + </div>; + } + return ( + <div + className="mx_CallViewHeader" + onMouseDown={onPipMouseDown} + > + <AccessibleButton onClick={() => onRoomAvatarClick(roomId)}> + <RoomAvatar room={callRoom} height={32} width={32} /> + </AccessibleButton> + <div className="mx_CallViewHeader_callInfo"> + <div className="mx_CallViewHeader_roomName">{ callRoomName }</div> + <div className="mx_CallViewHeader_callTypeSmall"> + { callTypeText } + { onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> } + </div> + </div> + <CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} /> + </div> + ); +}; + +export default CallViewHeader; diff --git a/src/components/views/voip/PictureInPictureDragger.tsx b/src/components/views/voip/PictureInPictureDragger.tsx new file mode 100644 index 0000000000..23a09b20d8 --- /dev/null +++ b/src/components/views/voip/PictureInPictureDragger.tsx @@ -0,0 +1,229 @@ +/* +Copyright 2021 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 React, { createRef } from 'react'; +import UIStore from '../../../stores/UIStore'; +import { lerp } from '../../../utils/AnimationUtils'; +import { MarkedExecution } from '../../../utils/MarkedExecution'; +import { replaceableComponent } from '../../../utils/replaceableComponent'; + +const PIP_VIEW_WIDTH = 336; +const PIP_VIEW_HEIGHT = 232; + +const MOVING_AMT = 0.2; +const SNAPPING_AMT = 0.1; + +const PADDING = { + top: 58, + bottom: 58, + left: 76, + right: 8, +}; + +interface IChildrenOptions { + onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void; + onResize: (event: Event) => void; +} + +interface IProps { + className?: string; + children: ({ onStartMoving, onResize }: IChildrenOptions) => React.ReactNode; + draggable: boolean; +} + +interface IState { + // Position of the PictureInPictureDragger + translationX: number; + translationY: number; +} + +/** + * PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture' + * (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing. + */ +@replaceableComponent("views.voip.PictureInPictureDragger") +export default class PictureInPictureDragger extends React.Component<IProps, IState> { + private callViewWrapper = createRef<HTMLDivElement>(); + private initX = 0; + private initY = 0; + private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH; + private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT; + private moving = false; + private scheduledUpdate = new MarkedExecution( + () => this.animationCallback(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); + + constructor(props: IProps) { + super(props); + + this.state = { + translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH, + translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT, + }; + } + + public componentDidMount() { + document.addEventListener("mousemove", this.onMoving); + document.addEventListener("mouseup", this.onEndMoving); + window.addEventListener("resize", this.onResize); + } + + public componentWillUnmount() { + document.removeEventListener("mousemove", this.onMoving); + document.removeEventListener("mouseup", this.onEndMoving); + window.removeEventListener("resize", this.onResize); + } + + private animationCallback = () => { + // If the PiP isn't being dragged and there is only a tiny difference in + // the desiredTranslation and translation, quit the animationCallback + // loop. If that is the case, it means the PiP has snapped into its + // position and there is nothing to do. Not doing this would cause an + // infinite loop + if ( + !this.moving && + Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 && + Math.abs(this.state.translationY - this.desiredTranslationY) <= 1 + ) return; + + const amt = this.moving ? MOVING_AMT : SNAPPING_AMT; + this.setState({ + translationX: lerp(this.state.translationX, this.desiredTranslationX, amt), + translationY: lerp(this.state.translationY, this.desiredTranslationY, amt), + }); + this.scheduledUpdate.mark(); + }; + + private setTranslation(inTranslationX: number, inTranslationY: number) { + const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH; + const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT; + + // Avoid overflow on the x axis + if (inTranslationX + width >= UIStore.instance.windowWidth) { + this.desiredTranslationX = UIStore.instance.windowWidth - width; + } else if (inTranslationX <= 0) { + this.desiredTranslationX = 0; + } else { + this.desiredTranslationX = inTranslationX; + } + + // Avoid overflow on the y axis + if (inTranslationY + height >= UIStore.instance.windowHeight) { + this.desiredTranslationY = UIStore.instance.windowHeight - height; + } else if (inTranslationY <= 0) { + this.desiredTranslationY = 0; + } else { + this.desiredTranslationY = inTranslationY; + } + } + + private onResize = (): void => { + this.snap(false); + }; + + private snap = (animate = false) => { + const translationX = this.desiredTranslationX; + const translationY = this.desiredTranslationY; + // We subtract the PiP size from the window size in order to calculate + // the position to snap to from the PiP center and not its top-left + // corner + const windowWidth = ( + UIStore.instance.windowWidth - + (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH) + ); + const windowHeight = ( + UIStore.instance.windowHeight - + (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT) + ); + + if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) { + this.desiredTranslationX = windowWidth - PADDING.right; + this.desiredTranslationY = windowHeight - PADDING.bottom; + } else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) { + this.desiredTranslationX = windowWidth - PADDING.right; + this.desiredTranslationY = PADDING.top; + } else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) { + this.desiredTranslationX = PADDING.left; + this.desiredTranslationY = windowHeight - PADDING.bottom; + } else { + this.desiredTranslationX = PADDING.left; + this.desiredTranslationY = PADDING.top; + } + + // We start animating here because we want the PiP to move when we're + // resizing the window + this.scheduledUpdate.mark(); + + if (animate) { + // We start animating here because we want the PiP to move when we're + // resizing the window + this.scheduledUpdate.mark(); + } else { + this.setState({ + translationX: this.desiredTranslationX, + translationY: this.desiredTranslationY, + }); + } + }; + + private onStartMoving = (event: React.MouseEvent | MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + this.moving = true; + this.initX = event.pageX - this.desiredTranslationX; + this.initY = event.pageY - this.desiredTranslationY; + this.scheduledUpdate.mark(); + }; + + private onMoving = (event: React.MouseEvent | MouseEvent) => { + if (!this.moving) return; + + event.preventDefault(); + event.stopPropagation(); + + this.setTranslation(event.pageX - this.initX, event.pageY - this.initY); + }; + + private onEndMoving = () => { + this.moving = false; + this.snap(true); + }; + + public render() { + const translatePixelsX = this.state.translationX + "px"; + const translatePixelsY = this.state.translationY + "px"; + const style = { + transform: `translateX(${translatePixelsX}) + translateY(${translatePixelsY})`, + }; + return ( + <div + className={this.props.className} + style={this.props.draggable ? style : undefined} + ref={this.callViewWrapper} + > + <> + { this.props.children({ + onStartMoving: this.onStartMoving, + onResize: this.onResize, + }) } + </> + </div> + ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e0b458dd8e..008cbabac3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -822,6 +822,7 @@ "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", "Show info about bridges in room settings": "Show info about bridges in room settings", "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)", + "Don't send read receipts": "Don't send read receipts", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -1140,6 +1141,10 @@ "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", + "Message layout": "Message layout", + "IRC": "IRC", + "Modern": "Modern", + "Message bubbles": "Message bubbles", "Messages containing keywords": "Messages containing keywords", "Error saving notification preferences": "Error saving notification preferences", "An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.", @@ -1252,10 +1257,6 @@ "Custom theme URL": "Custom theme URL", "Add theme": "Add theme", "Theme": "Theme", - "Message layout": "Message layout", - "IRC": "IRC", - "Modern": "Modern", - "Message bubbles": "Message bubbles", "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.", "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout", "Customise your appearance": "Customise your appearance", @@ -1276,6 +1277,8 @@ "Deactivate Account": "Deactivate Account", "Deactivate account": "Deactivate account", "Discovery": "Discovery", + "%(brand)s version:": "%(brand)s version:", + "Olm version:": "Olm version:", "Legal": "Legal", "Credits": "Credits", "For help with using %(brand)s, click <a>here</a>.": "For help with using %(brand)s, click <a>here</a>.", @@ -1289,13 +1292,11 @@ "FAQ": "FAQ", "Keyboard Shortcuts": "Keyboard Shortcuts", "Versions": "Versions", - "%(brand)s version:": "%(brand)s version:", - "olm version:": "olm version:", + "Copy": "Copy", "Homeserver is": "Homeserver is", "Identity server is": "Identity server is", "Access Token": "Access Token", "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.", - "Copy": "Copy", "Clear cache and reload": "Clear cache and reload", "Labs": "Labs", "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.", @@ -1546,6 +1547,7 @@ "Send a reply…": "Send a reply…", "Send an encrypted message…": "Send an encrypted message…", "Send a message…": "Send a message…", + "Send voice message": "Send voice message", "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "You do not have permission to post to this room": "You do not have permission to post to this room", @@ -1720,7 +1722,6 @@ "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", "No microphone found": "No microphone found", "We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.", - "Send voice message": "Send voice message", "Stop recording": "Stop recording", "Error updating main address": "Error updating main address", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", @@ -2094,6 +2095,8 @@ "%(severalUsers)schanged the server ACLs %(count)s times|one": "%(severalUsers)schanged the server ACLs", "%(oneUser)schanged the server ACLs %(count)s times|other": "%(oneUser)schanged the server ACLs %(count)s times", "%(oneUser)schanged the server ACLs %(count)s times|one": "%(oneUser)schanged the server ACLs", + "%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.", + "%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.", "Power level": "Power level", "Custom level": "Custom level", "QR Code": "QR Code", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index ca0253a838..d170f8d357 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -311,6 +311,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, }, + "feature_hidden_read_receipts": { + supportedLevels: LEVELS_FEATURE, + displayName: _td( + "Don't send read receipts", + ), + default: false, + }, "baseFontSize": { displayName: _td("Font size"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, diff --git a/src/utils/FormattingUtils.ts b/src/utils/FormattingUtils.ts index 1fe3669f26..b527ee7ea2 100644 --- a/src/utils/FormattingUtils.ts +++ b/src/utils/FormattingUtils.ts @@ -16,6 +16,7 @@ limitations under the License. */ import { _t } from '../languageHandler'; +import { jsxJoin } from './ReactUtils'; /** * formats numbers to fit into ~3 characters, suitable for badge counts @@ -103,7 +104,7 @@ export function getUserNameColorClass(userId: string): string { * @returns {string} a string constructed by joining `items` with a comma * between each item, but with the last item appended as " and [lastItem]". */ -export function formatCommaSeparatedList(items: string[], itemLimit?: number): string { +export function formatCommaSeparatedList(items: Array<string | JSX.Element>, itemLimit?: number): string | JSX.Element { const remaining = itemLimit === undefined ? 0 : Math.max( items.length - itemLimit, 0, ); @@ -113,9 +114,9 @@ export function formatCommaSeparatedList(items: string[], itemLimit?: number): s return items[0]; } else if (remaining > 0) { items = items.slice(0, itemLimit); - return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } ); + return _t("%(items)s and %(count)s others", { items: jsxJoin(items, ', '), count: remaining } ); } else { const lastItem = items.pop(); - return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); + return _t("%(items)s and %(lastItem)s", { items: jsxJoin(items, ', '), lastItem: lastItem }); } } diff --git a/src/utils/ReactUtils.tsx b/src/utils/ReactUtils.tsx new file mode 100644 index 0000000000..4cd2d750f3 --- /dev/null +++ b/src/utils/ReactUtils.tsx @@ -0,0 +1,33 @@ +/* +Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +/** + * Joins an array into one value with a joiner. E.g. join(["hello", "world"], " ") -> <span>hello world</span> + * @param array the array of element to join + * @param joiner the string/JSX.Element to join with + * @returns the joined array + */ +export function jsxJoin(array: Array<string | JSX.Element>, joiner?: string | JSX.Element): JSX.Element { + const newArray = []; + array.forEach((element, index) => { + newArray.push(element, (index === array.length - 1) ? null : joiner); + }); + return ( + <span>{ newArray }</span> + ); +} diff --git a/tsconfig.json b/tsconfig.json index 2e0131609c..02904af9d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,12 +12,6 @@ "outDir": "./lib", "declaration": true, "jsx": "react", - "types": [ - "node", - "react", - "flux", - "react-transition-group" - ], "lib": [ "es2019", "dom", @@ -27,5 +21,5 @@ "include": [ "./src/**/*.ts", "./src/**/*.tsx" - ], + ], }