Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/17044.1

 Conflicts:
	src/stores/room-list/RoomListStore.ts
This commit is contained in:
Michael Telatynski 2021-05-07 10:40:07 +01:00
commit 6137162786
50 changed files with 928 additions and 361 deletions

View file

@ -28,7 +28,7 @@ Platform Targets:
* WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox. * WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox.
* Mobile Web is not currently a target platform - instead please use the native * Mobile Web is not currently a target platform - instead please use the native
iOS (https://github.com/matrix-org/matrix-ios-kit) and Android iOS (https://github.com/matrix-org/matrix-ios-kit) and Android
(https://github.com/matrix-org/matrix-android-sdk) SDKs. (https://github.com/matrix-org/matrix-android-sdk2) SDKs.
All code lands on the `develop` branch - `master` is only used for stable releases. All code lands on the `develop` branch - `master` is only used for stable releases.
**Please file PRs against `develop`!!** **Please file PRs against `develop`!!**

View file

@ -101,7 +101,7 @@ limitations under the License.
.mx_BaseAvatar { .mx_BaseAvatar {
display: inline-flex; display: inline-flex;
margin: 5px 16px 5px 5px; margin: auto 16px auto 5px;
vertical-align: middle; vertical-align: middle;
} }
@ -160,31 +160,32 @@ limitations under the License.
} }
} }
.mx_AddExistingToSpaceDialog_errorText {
font-weight: $font-semi-bold;
font-size: $font-12px;
line-height: $font-15px;
color: $notice-primary-color;
margin-bottom: 28px;
}
.mx_AddExistingToSpace { .mx_AddExistingToSpace {
display: contents; display: contents;
} }
.mx_AddExistingToSpaceDialog_footer { .mx_AddExistingToSpaceDialog_footer {
display: flex; display: flex;
margin-top: 32px; margin-top: 20px;
> span { > span {
flex-grow: 1; flex-grow: 1;
font-size: $font-14px; font-size: $font-12px;
line-height: $font-15px; line-height: $font-15px;
font-weight: $font-semi-bold; color: $secondary-fg-color;
.mx_AccessibleButton { .mx_ProgressBar {
font-size: inherit; height: 8px;
display: inline-block; width: 100%;
@mixin ProgressBarBorderRadius 8px;
}
.mx_AddExistingToSpaceDialog_progressText {
margin-top: 8px;
font-size: $font-15px;
line-height: $font-24px;
color: $primary-fg-color;
} }
> * { > * {
@ -192,8 +193,54 @@ limitations under the License.
} }
} }
.mx_AddExistingToSpaceDialog_error {
padding-left: 12px;
> img {
align-self: center;
}
.mx_AddExistingToSpaceDialog_errorHeading {
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-18px;
color: $notice-primary-color;
}
.mx_AddExistingToSpaceDialog_errorCaption {
margin-top: 4px;
font-size: $font-12px;
line-height: $font-15px;
color: $primary-fg-color;
}
}
.mx_AccessibleButton { .mx_AccessibleButton {
display: inline-block; display: inline-block;
align-self: center;
}
.mx_AccessibleButton_kind_primary {
padding: 8px 36px;
}
.mx_AddExistingToSpaceDialog_retryButton {
margin-left: 12px;
padding-left: 24px;
position: relative;
&::before {
content: '';
position: absolute;
background-color: $primary-fg-color;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/retry.svg');
width: 18px;
height: 18px;
left: 0;
}
} }
.mx_AccessibleButton_kind_link { .mx_AccessibleButton_kind_link {

View file

@ -21,7 +21,7 @@ progress.mx_ProgressBar {
appearance: none; appearance: none;
border: none; border: none;
@mixin ProgressBarBorderRadius "6px"; @mixin ProgressBarBorderRadius 6px;
@mixin ProgressBarColour $progressbar-fg-color; @mixin ProgressBarColour $progressbar-fg-color;
@mixin ProgressBarBgColour $progressbar-bg-color; @mixin ProgressBarBgColour $progressbar-bg-color;
::-webkit-progress-value { ::-webkit-progress-value {

View file

@ -61,9 +61,9 @@ limitations under the License.
.mx_MFileBody_info { .mx_MFileBody_info {
background-color: $message-body-panel-bg-color; background-color: $message-body-panel-bg-color;
border-radius: 4px; border-radius: 12px;
width: 270px; width: 243px; // same width as a playable voice message, accounting for padding
padding: 8px; padding: 6px 12px;
color: $message-body-panel-fg-color; color: $message-body-panel-fg-color;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
@ -82,7 +82,7 @@ limitations under the License.
mask-position: center; mask-position: center;
mask-size: cover; mask-size: cover;
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
background-color: $message-body-panel-fg-color; background-color: $message-body-panel-icon-fg-color;
width: 13px; width: 13px;
height: 15px; height: 15px;

View file

@ -39,14 +39,14 @@ limitations under the License.
width: 14px; // w&h are size of icon width: 14px; // w&h are size of icon
height: 18px; height: 18px;
vertical-align: middle; vertical-align: middle;
margin-right: 7px; // distance from left edge of waveform container (container has some margin too) margin-right: 11px; // distance from left edge of waveform container (container has some margin too)
background-color: $voice-record-icon-color; background-color: $voice-record-icon-color;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: contain; mask-size: contain;
mask-image: url('$(res)/img/element-icons/trashcan.svg'); mask-image: url('$(res)/img/element-icons/trashcan.svg');
} }
.mx_VoiceMessagePrimaryContainer { .mx_VoiceRecordComposerTile_recording.mx_VoiceMessagePrimaryContainer {
// Note: remaining class properties are in the PlayerContainer CSS. // Note: remaining class properties are in the PlayerContainer CSS.
margin: 6px; // force the composer area to put a gutter around us margin: 6px; // force the composer area to put a gutter around us
@ -55,7 +55,9 @@ limitations under the License.
position: relative; // important for the live circle position: relative; // important for the live circle
&.mx_VoiceRecordComposerTile_recording { &.mx_VoiceRecordComposerTile_recording {
padding-left: 16px; // +10px for the live circle, +6px for regular padding // We are putting the circle in this padding, so we need +10px from the regular
// padding on the left side.
padding-left: 22px;
&::before { &::before {
animation: recording-pulse 2s infinite; animation: recording-pulse 2s infinite;
@ -65,8 +67,8 @@ limitations under the License.
width: 10px; width: 10px;
height: 10px; height: 10px;
position: absolute; position: absolute;
left: 8px; left: 12px; // 12px from the left edge for container padding
top: 16px; // vertically center top: 18px; // vertically center (middle align with clock)
border-radius: 10px; border-radius: 10px;
} }
} }

View file

@ -22,3 +22,34 @@ limitations under the License.
.mx_HelpUserSettingsTab span.mx_AccessibleButton { .mx_HelpUserSettingsTab span.mx_AccessibleButton {
word-break: break-word; word-break: break-word;
} }
.mx_HelpUserSettingsTab code {
word-break: break-all;
user-select: all;
}
.mx_HelpUserSettingsTab_accessToken {
display: flex;
justify-content: space-between;
border-radius: 5px;
border: solid 1px $light-fg-color;
margin-bottom: 10px;
margin-top: 10px;
padding: 10px;
}
.mx_HelpUserSettingsTab_accessToken_copy {
flex-shrink: 0;
cursor: pointer;
margin-left: 20px;
display: inherit;
}
.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;
}

View file

@ -19,8 +19,9 @@ limitations under the License.
// Container for live recording and playback controls // Container for live recording and playback controls
.mx_VoiceMessagePrimaryContainer { .mx_VoiceMessagePrimaryContainer {
padding: 6px; // makes us 4px taller than the send/stop button // 7px top and bottom for visual design. 12px left & right, but the waveform (right)
padding-right: 5px; // there's 1px from the waveform itself, so account for that // has a 1px padding on it that we want to account for.
padding: 7px 12px 7px 11px;
background-color: $voice-record-waveform-bg-color; background-color: $voice-record-waveform-bg-color;
border-radius: 12px; border-radius: 12px;
@ -30,11 +31,9 @@ limitations under the License.
color: $voice-record-waveform-fg-color; color: $voice-record-waveform-fg-color;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-24px;
.mx_Waveform { .mx_Waveform {
// We want the bars to be 2px shorter than the play/pause button in the waveform control
height: 28px; // default is 30px, so we're subtracting the 2px border off the bars
.mx_Waveform_bar { .mx_Waveform_bar {
background-color: $voice-record-waveform-incomplete-fg-color; background-color: $voice-record-waveform-incomplete-fg-color;
@ -47,8 +46,8 @@ limitations under the License.
} }
.mx_Clock { .mx_Clock {
padding-right: 4px; // isolate from waveform width: 42px; // we're not using a monospace font, so fake it
padding-left: 8px; // isolate from live circle padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended.
width: 40px; // we're not using a monospace font, so fake it padding-left: 8px; // isolate from recording circle / play control
} }
} }

View file

@ -65,14 +65,17 @@ limitations under the License.
} }
} }
.mx_CallView_voice { .mx_CallView_content {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; border-radius: 8px;
}
.mx_CallView_voice {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column;
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
border-radius: 8px;
} }
.mx_CallView_voice_avatarsContainer { .mx_CallView_voice_avatarsContainer {
@ -109,9 +112,7 @@ limitations under the License.
.mx_CallView_video { .mx_CallView_video {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
z-index: 30; z-index: 30;
border-radius: 8px;
overflow: hidden; overflow: hidden;
} }

View file

@ -14,21 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_VideoFeed_voice {
// We don't want to collide with the call controls that have 52px of height
padding-bottom: 52px;
background-color: $inverted-bg-color;
}
.mx_VideoFeed_remote { .mx_VideoFeed_remote {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex;
justify-content: center;
align-items: center;
&.mx_VideoFeed_video {
background-color: #000; background-color: #000;
z-index: 50; }
} }
.mx_VideoFeed_local { .mx_VideoFeed_local {
width: 25%; max-width: 25%;
height: 25%; max-height: 25%;
position: absolute; position: absolute;
right: 10px; right: 10px;
top: 10px; top: 10px;
z-index: 100; z-index: 100;
border-radius: 4px; border-radius: 4px;
&.mx_VideoFeed_video {
background-color: transparent;
}
} }
.mx_VideoFeed_mirror { .mx_VideoFeed_mirror {

View file

@ -9,6 +9,7 @@ $header-panel-text-primary-color: #B9BEC6;
$header-panel-text-secondary-color: #c8c8cd; $header-panel-text-secondary-color: #c8c8cd;
$text-primary-color: #ffffff; $text-primary-color: #ffffff;
$text-secondary-color: #B9BEC6; $text-secondary-color: #B9BEC6;
$quaternary-fg-color: #6F7882;
$search-bg-color: #181b21; $search-bg-color: #181b21;
$search-placeholder-color: #61708b; $search-placeholder-color: #61708b;
$room-highlight-color: #343a46; $room-highlight-color: #343a46;
@ -42,14 +43,6 @@ $preview-bar-bg-color: $header-panel-bg-color;
$groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82); $groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82);
$inverted-bg-color: $base-color; $inverted-bg-color: $base-color;
$voice-record-stop-border-color: #6F7882; // "Quarterly"
$voice-record-waveform-bg-color: #394049; // "Dark Tile"
$voice-record-waveform-fg-color: $tertiary-fg-color;
$voice-record-waveform-incomplete-fg-color: #5b646d;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $tertiary-fg-color;
$voice-playback-button-fg-color: $bg-color;
// used by AddressSelector // used by AddressSelector
$selected-color: $room-highlight-color; $selected-color: $room-highlight-color;
@ -213,9 +206,18 @@ $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #21262c82; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #8e99a4; $message-body-panel-bg-color: #394049; // "Dark Tile"
$message-body-panel-fg-color: $primary-fg-color; $message-body-panel-icon-fg-color: #21262C; // "Separator"
$message-body-panel-icon-bg-color: $tertiary-fg-color;
$voice-record-stop-border-color: $quaternary-fg-color;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $quaternary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// Appearance tab colors // Appearance tab colors
$appearance-tab-border-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;

View file

@ -124,15 +124,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
// See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882;
$voice-record-waveform-bg-color: #394049;
$voice-record-waveform-fg-color: $tertiary-fg-color;
$voice-record-waveform-incomplete-fg-color: #5b646d;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $tertiary-fg-color;
$voice-playback-button-fg-color: $bg-color;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #1A1D23; $roomtile-selected-bg-color: #1A1D23;
@ -209,9 +200,19 @@ $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #21262c82; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #8e99a4; $message-body-panel-bg-color: #394049;
$message-body-panel-fg-color: $primary-fg-color; $message-body-panel-icon-fg-color: $primary-bg-color;
$message-body-panel-icon-bg-color: $secondary-fg-color;
// See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #6F7882;
$voice-record-icon-color: #6F7882;
$voice-playback-button-bg-color: $tertiary-fg-color;
$voice-playback-button-fg-color: #21262C;
// Appearance tab colors // Appearance tab colors
$appearance-tab-border-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;

View file

@ -191,17 +191,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
// See non-legacy _light for variable information
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-live-circle-color: #ff4b55;
$voice-record-icon-color: $muted-fg-color;
$voice-playback-button-bg-color: $primary-bg-color;
$voice-playback-button-fg-color: $muted-fg-color;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #fff; $roomtile-selected-bg-color: #fff;
@ -334,9 +323,21 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #e3e8f082; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #ffffff; $message-body-panel-bg-color: #E3E8F0;
$message-body-panel-fg-color: $muted-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color;
// See non-legacy _light for variable information
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View file

@ -21,6 +21,7 @@ $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
$primary-fg-color: #2e2f32; $primary-fg-color: #2e2f32;
$secondary-fg-color: #737D8C; $secondary-fg-color: #737D8C;
$tertiary-fg-color: #8D99A5; $tertiary-fg-color: #8D99A5;
$quaternary-fg-color: #C1C6CD;
$header-panel-bg-color: #f3f8fd; $header-panel-bg-color: #f3f8fd;
// typical text (dark-on-white in light skin) // typical text (dark-on-white in light skin)
@ -182,16 +183,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes
$voice-record-icon-color: $muted-fg-color;
$voice-playback-button-bg-color: $primary-bg-color;
$voice-playback-button-fg-color: $muted-fg-color;
$roomtile-preview-color: $secondary-fg-color; $roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #FFF; $roomtile-selected-bg-color: #FFF;
@ -331,9 +322,23 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #e3e8f082; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #ffffff; $message-body-panel-bg-color: #E3E8F0; // "Separator"
$message-body-panel-fg-color: $muted-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color;
// These two don't change between themes. They are the $warning-color, but we don't
// want custom themes to affect them by accident.
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0; // "Separator"
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View file

@ -118,6 +118,16 @@ declare global {
interface HTMLAudioElement { interface HTMLAudioElement {
type?: string; type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
}
interface HTMLVideoElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
} }
interface Element { interface Element {

View file

@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room";
import DMRoomMap from './utils/DMRoomMap'; import DMRoomMap from './utils/DMRoomMap';
import {mediaFromMxc} from "./customisations/Media"; import {mediaFromMxc} from "./customisations/Media";
import SettingsStore from "./settings/SettingsStore";
export type ResizeMethod = "crop" | "scale"; export type ResizeMethod = "crop" | "scale";
@ -143,7 +144,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
} }
// space rooms cannot be DMs so skip the rest // space rooms cannot be DMs so skip the rest
if (room.isSpaceRoom()) return null; if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null;
let otherMember = null; let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);

View file

@ -85,6 +85,7 @@ import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper'; import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import EventEmitter from 'events';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom'; import { ensureDMExists, findDMForUser } from './createRoom';
@ -138,22 +139,12 @@ export enum PlaceCallType {
ScreenSharing = 'screensharing', ScreenSharing = 'screensharing',
} }
function getRemoteAudioElement(): HTMLAudioElement { export enum CallHandlerEvent {
// this needs to be somewhere at the top of the DOM which CallsChanged = "calls_changed",
// always exists to avoid audio interruptions. CallChangeRoom = "call_change_room",
// Might as well just use DOM.
const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement;
if (!remoteAudioElement) {
console.error(
"Failed to find remoteAudio element - cannot play audio!" +
"You need to add an <audio/> to the DOM.",
);
return null;
}
return remoteAudioElement;
} }
export default class CallHandler { export default class CallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another // Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one. // call with a different party to this one.
@ -514,6 +505,7 @@ export default class CallHandler {
} }
this.calls.set(mappedRoomId, newCall); this.calls.set(mappedRoomId, newCall);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(newCall); this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state); this.setCallState(newCall, newCall.state);
}); });
@ -546,10 +538,7 @@ export default class CallHandler {
this.removeCallForRoom(mappedRoomId); this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId; mappedRoomId = newMappedRoomId;
this.calls.set(mappedRoomId, call); this.calls.set(mappedRoomId, call);
dis.dispatch({ this.emit(CallHandlerEvent.CallChangeRoom, call);
action: Action.CallChangeRoom,
call,
});
} }
} }
}); });
@ -598,11 +587,6 @@ export default class CallHandler {
} }
} }
private setCallAudioElement(call: MatrixCall) {
const audioElement = getRemoteAudioElement();
if (audioElement) call.setRemoteAudioElement(audioElement);
}
private setCallState(call: MatrixCall, status: CallState) { private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
@ -619,6 +603,7 @@ export default class CallHandler {
private removeCallForRoom(roomId: string) { private removeCallForRoom(roomId: string) {
this.calls.delete(roomId); this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
} }
private showICEFallbackPrompt() { private showICEFallbackPrompt() {
@ -679,11 +664,7 @@ export default class CallHandler {
}, null, true); }, null, true);
} }
private async placeCall( private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) {
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
transferee: MatrixCall,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type); Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
@ -695,22 +676,19 @@ export default class CallHandler {
const call = MatrixClientPeg.get().createCall(mappedRoomId); const call = MatrixClientPeg.get().createCall(mappedRoomId);
this.calls.set(roomId, call); this.calls.set(roomId, call);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
if (transferee) { if (transferee) {
this.transferees[call.callId] = transferee; this.transferees[call.callId] = transferee;
} }
this.setCallListeners(call); this.setCallListeners(call);
this.setCallAudioElement(call);
this.setActiveCallRoomId(roomId); this.setActiveCallRoomId(roomId);
if (type === PlaceCallType.Voice) { if (type === PlaceCallType.Voice) {
call.placeVoiceCall(); call.placeVoiceCall();
} else if (type === 'video') { } else if (type === 'video') {
call.placeVideoCall( call.placeVideoCall();
remoteElement,
localElement,
);
} else if (type === PlaceCallType.ScreenSharing) { } else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) { if (screenCapErrorString) {
@ -724,13 +702,12 @@ export default class CallHandler {
} }
call.placeScreenSharingCall( call.placeScreenSharingCall(
remoteElement,
localElement,
async (): Promise<DesktopCapturerSource> => { async (): Promise<DesktopCapturerSource> => {
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
return source; return source;
}); },
);
} else { } else {
console.error("Unknown conf call type: " + type); console.error("Unknown conf call type: " + type);
} }
@ -787,17 +764,12 @@ export default class CallHandler {
} else if (members.length === 2) { } else if (members.length === 2) {
console.info(`Place ${payload.type} call in ${payload.room_id}`); console.info(`Place ${payload.type} call in ${payload.room_id}`);
this.placeCall( this.placeCall(payload.room_id, payload.type, payload.transferee);
payload.room_id, payload.type, payload.local_element, payload.remote_element,
payload.transferee,
);
} else { // > 2 } else { // > 2
dis.dispatch({ dis.dispatch({
action: "place_conference_call", action: "place_conference_call",
room_id: payload.room_id, room_id: payload.room_id,
type: payload.type, type: payload.type,
remote_element: payload.remote_element,
local_element: payload.local_element,
}); });
} }
} }
@ -833,6 +805,7 @@ export default class CallHandler {
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(mappedRoomId, call) this.calls.set(mappedRoomId, call)
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(call); this.setCallListeners(call);
// get ready to send encrypted events in the room, so if the user does answer // get ready to send encrypted events in the room, so if the user does answer
@ -875,7 +848,6 @@ export default class CallHandler {
const call = this.calls.get(payload.room_id); const call = this.calls.get(payload.room_id);
call.answer(); call.answer();
this.setCallAudioElement(call);
this.setActiveCallRoomId(payload.room_id); this.setActiveCallRoomId(payload.room_id);
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
dis.dispatch({ dis.dispatch({

View file

@ -16,7 +16,7 @@
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel"; import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default { export default {
hasAnyLabeledDevices: async function() { hasAnyLabeledDevices: async function() {
@ -50,18 +50,15 @@ export default {
}, },
loadDevices: function() { loadDevices: function() {
const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput");
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioOutput(audioOutDeviceId);
setMatrixCallAudioInput(audioDeviceId); setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId); setMatrixCallVideoInput(videoDeviceId);
}, },
setAudioOutput: function(deviceId) { setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioOutput(deviceId);
}, },
setAudioInput: function(deviceId) { setAudioInput: function(deviceId) {

View file

@ -59,6 +59,9 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom"; import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel"; import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -119,6 +122,7 @@ interface IState {
usageLimitEventContent?: IUsageLimit; usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number; usageLimitEventTs?: number;
useCompactLayout: boolean; useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
} }
/** /**
@ -160,6 +164,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view // use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'), useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false, usageLimitDismissed: false,
activeCalls: [],
}; };
// stash the MatrixClient in case we log out before we are unmounted // stash the MatrixClient in case we log out before we are unmounted
@ -175,6 +180,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() { componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false); document.addEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._updateServerNoticeEvents(); this._updateServerNoticeEvents();
@ -199,6 +205,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false); document.removeEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -206,6 +213,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.detach(); this.resizer.detach();
} }
private onCallsChanged = () => {
this.setState({
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
});
};
// Child components assume that the client peg will not be null, so give them some // Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy. // sort of assurance here by only allowing a re-render if the client is truthy.
// //
@ -661,6 +674,12 @@ class LoggedInView extends React.Component<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout'; bodyClasses += ' mx_MatrixChat_useCompactLayout';
} }
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
);
});
return ( return (
<MatrixClientContext.Provider value={this._matrixClient}> <MatrixClientContext.Provider value={this._matrixClient}>
<div <div
@ -685,6 +704,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<CallContainer /> <CallContainer />
<NonUrgentToastContainer /> <NonUrgentToastContainer />
<HostSignupContainer /> <HostSignupContainer />
{audioFeedArraysForCalls}
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );
} }

View file

@ -1094,7 +1094,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoomWarnings(roomId: string) { private leaveRoomWarnings(roomId: string) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = roomToLeave?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications. // Show a warning if there are additional complications.
const warnings = []; const warnings = [];
@ -1133,7 +1133,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId); const warnings = this.leaveRoomWarnings(roomId);
const isSpace = roomToLeave?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"), title: isSpace ? _t("Leave space") : _t("Leave room"),
description: ( description: (

View file

@ -35,6 +35,7 @@ import {Action} from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard"; import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import SettingsStore from "../../settings/SettingsStore";
@replaceableComponent("structures.RightPanel") @replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component { export default class RightPanel extends React.Component {
@ -85,7 +86,9 @@ export default class RightPanel extends React.Component {
return RightPanelPhases.GroupMemberList; return RightPanelPhases.GroupMemberList;
} }
return rps.groupPanelPhase; return rps.groupPanelPhase;
} else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
) {
return RightPanelPhases.SpaceMemberList; return RightPanelPhases.SpaceMemberList;
} else if (userForPanel) { } else if (userForPanel) {
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state

View file

@ -1750,7 +1750,10 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
const myMembership = this.state.room.getMyMembership(); const myMembership = this.state.room.getMyMembership();
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself if (myMembership === "invite"
// SpaceRoomView handles invites itself
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
) {
if (this.state.joining || this.state.rejecting) { if (this.state.joining || this.state.rejecting) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@ -1892,7 +1895,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room} room={this.state.room}
/> />
); );
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
{ previewBar } { previewBar }

View file

@ -39,6 +39,7 @@ import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip"; import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle"; import {useStateToggle} from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore";
interface IHierarchyProps { interface IHierarchyProps {
space: Room; space: Room;
@ -254,7 +255,11 @@ export const HierarchyLevel = ({
const space = cli.getRoom(spaceId); const space = cli.getRoom(spaceId);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key; const roomId = ev.state_key;
if (!rooms.has(roomId)) return result; if (!rooms.has(roomId)) return result;

View file

@ -52,7 +52,7 @@ import {useStateToggle} from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile"; import FacePile from "../views/elements/FacePile";
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog"; import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
import {allSettled} from "../../utils/promise"; import {sleep} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks"; import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
interface IProps { interface IProps {
@ -389,15 +389,24 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
let buttonLabel = _t("Skip for now"); let buttonLabel = _t("Skip for now");
if (selectedToAdd.size > 0) { if (selectedToAdd.size > 0) {
onClick = async () => { onClick = async () => {
// TODO rate limiting
setBusy(true); setBusy(true);
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try { try {
await allSettled(Array.from(selectedToAdd).map((room) => await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); if (e.errcode === "M_LIMIT_EXCEEDED") {
onFinished(true); await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
throw e;
});
} catch (e) { } catch (e) {
console.error("Failed to add rooms to space", e); console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space")); setError(_t("Failed to add rooms to space"));
break;
}
} }
setBusy(false); setBusy(false);
}; };

View file

@ -29,12 +29,13 @@ import RoomAvatar from "../avatars/RoomAvatar";
import {getDisplayAliasForRoom} from "../../../Rooms"; import {getDisplayAliasForRoom} from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {allSettled} from "../../../utils/promise"; import {sleep} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox"; import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -46,7 +47,11 @@ const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpace_entry"> return <label className="mx_AddExistingToSpace_entry">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span> <span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} /> <StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>; </label>;
}; };
@ -104,9 +109,9 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
key={room.roomId} key={room.roomId}
room={room} room={room}
checked={selected.has(room)} checked={selected.has(room)}
onChange={(checked) => { onChange={onChange ? (checked) => {
onChange(checked, room); onChange(checked, room);
}} } : null}
/>; />;
}) } }) }
</div> </div>
@ -120,9 +125,9 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
key={space.roomId} key={space.roomId}
room={space} room={space}
checked={selected.has(space)} checked={selected.has(space)}
onChange={(checked) => { onChange={onChange ? (checked) => {
onChange(checked, space); onChange(checked, space);
}} } : null}
/>; />;
}) } }) }
</div> </div>
@ -136,9 +141,9 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
key={room.roomId} key={room.roomId}
room={room} room={room}
checked={selected.has(room)} checked={selected.has(room)}
onChange={(checked) => { onChange={onChange ? (checked) => {
onChange(checked, room); onChange(checked, room);
}} } : null}
/>; />;
}) } }) }
</div> </div>
@ -156,8 +161,8 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>()); const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [busy, setBusy] = useState(false); const [progress, setProgress] = useState<number>(null);
const [error, setError] = useState(""); const [error, setError] = useState<Error>(null);
let spaceOptionSection; let spaceOptionSection;
if (existingSubspaces.length > 0) { if (existingSubspaces.length > 0) {
@ -197,6 +202,82 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
</div> </div>
</React.Fragment>; </React.Fragment>;
const addRooms = async () => {
setError(null);
setProgress(0);
let error;
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
throw e;
});
setProgress(i => i + 1);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(error = e);
break;
}
}
if (!error) {
onFinished(true);
}
};
const busy = progress !== null;
let footer;
if (error) {
footer = <>
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="24"
width="24"
alt=""
/>
<span className="mx_AddExistingToSpaceDialog_error">
<div className="mx_AddExistingToSpaceDialog_errorHeading">{ _t("Not all selected were added") }</div>
<div className="mx_AddExistingToSpaceDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_AddExistingToSpaceDialog_retryButton" onClick={addRooms}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else if (busy) {
footer = <span>
<ProgressBar value={progress} max={selectedToAdd.size} />
<div className="mx_AddExistingToSpaceDialog_progressText">
{ _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: selectedToAdd.size,
progress,
}) }
</div>
</span>;
} else {
footer = <>
<span>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</span>
<AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
{ _t("Add") }
</AccessibleButton>
</>;
}
return <BaseDialog return <BaseDialog
title={title} title={title}
className="mx_AddExistingToSpaceDialog" className="mx_AddExistingToSpaceDialog"
@ -204,50 +285,23 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
onFinished={onFinished} onFinished={onFinished}
fixedWidth={false} fixedWidth={false}
> >
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
<MatrixClientContext.Provider value={cli}> <MatrixClientContext.Provider value={cli}>
<AddExistingToSpace <AddExistingToSpace
space={space} space={space}
selected={selectedToAdd} selected={selectedToAdd}
onChange={(checked, room) => { onChange={!busy && !error ? (checked, room) => {
if (checked) { if (checked) {
selectedToAdd.add(room); selectedToAdd.add(room);
} else { } else {
selectedToAdd.delete(room); selectedToAdd.delete(room);
} }
setSelectedToAdd(new Set(selectedToAdd)); setSelectedToAdd(new Set(selectedToAdd));
}} } : null}
/> />
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
<div className="mx_AddExistingToSpaceDialog_footer"> <div className="mx_AddExistingToSpaceDialog_footer">
<span> { footer }
<div>{ _t("Don't want to add an existing room?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</span>
<AccessibleButton
kind="primary"
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
// TODO rate limiting
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
}}
>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
</div> </div>
</BaseDialog>; </BaseDialog>;
}; };

View file

@ -1312,7 +1312,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
goButtonFn = this._startDm; goButtonFn = this._startDm;
} else if (this.props.kind === KIND_INVITE) { } else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = room?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
title = isSpace title = isSpace
? _t("Invite to %(spaceName)s", { ? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"), spaceName: room.name || _t("Unnamed Space"),

View file

@ -114,6 +114,8 @@ export default class ImageView extends React.Component<IProps, IState> {
componentWillUnmount() { componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel); this.focusLock.current.removeEventListener('wheel', this.onWheel);
window.removeEventListener("resize", this.calculateZoom);
this.image.current.removeEventListener("load", this.calculateZoom);
} }
private calculateZoom = () => { private calculateZoom = () => {

View file

@ -440,7 +440,7 @@ const UserOptionsSection: React.FC<{
); );
}; };
const warnSelfDemote = async (isSpace) => { const warnSelfDemote = async (isSpace: boolean) => {
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
title: _t("Demote yourself?"), title: _t("Demote yourself?"),
description: description:
@ -727,7 +727,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels,
// if muting self, warn as it may be irreversible // if muting self, warn as it may be irreversible
if (target === cli.getUserId()) { if (target === cli.getUserId()) {
try { try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
return; return;
@ -816,7 +816,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= kickPowerLevel) { if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
} }
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) { if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
redactButton = ( redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} /> <RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
); );
@ -1095,7 +1095,7 @@ const PowerLevelEditor: React.FC<{
} else if (myUserId === target) { } else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse. // If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try { try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
} }
@ -1325,10 +1325,10 @@ const BasicUserInfo: React.FC<{
if (!isRoomEncrypted) { if (!isRoomEncrypted) {
if (!cryptoEnabled) { if (!cryptoEnabled) {
text = _t("This client does not support end-to-end encryption."); text = _t("This client does not support end-to-end encryption.");
} else if (room && !room.isSpaceRoom()) { } else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
text = _t("Messages in this room are not end-to-end encrypted."); text = _t("Messages in this room are not end-to-end encrypted.");
} }
} else if (!room.isSpaceRoom()) { } else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) {
text = _t("Messages in this room are end-to-end encrypted."); text = _t("Messages in this room are end-to-end encrypted.");
} }
@ -1405,7 +1405,7 @@ const BasicUserInfo: React.FC<{
canInvite={roomPermissions.canInvite} canInvite={roomPermissions.canInvite}
isIgnored={isIgnored} isIgnored={isIgnored}
member={member} member={member}
isSpace={room?.isSpaceRoom()} isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()}
/> />
{ adminToolsContainer } { adminToolsContainer }
@ -1567,7 +1567,7 @@ const UserInfo: React.FC<Props> = ({
previousPhase = RightPanelPhases.RoomMemberInfo; previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = {member: member}; refireParams = {member: member};
} else if (room) { } else if (room) {
previousPhase = previousPhase = room.isSpaceRoom() previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList ? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList; : RightPanelPhases.RoomMemberList;
} }
@ -1616,7 +1616,7 @@ const UserInfo: React.FC<Props> = ({
} }
let scopeHeader; let scopeHeader;
if (room?.isSpaceRoom()) { if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} /> <RoomName room={room} />

View file

@ -30,6 +30,7 @@ import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5; const INITIAL_LOAD_NUM_INVITED = 5;
@ -460,7 +461,7 @@ export default class MemberList extends React.Component {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) { if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community"); inviteButtonText = _t("Invite to this community");
} else if (room.isSpaceRoom()) { } else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space"); inviteButtonText = _t("Invite to this space");
} }
@ -492,7 +493,7 @@ export default class MemberList extends React.Component {
let previousPhase = RightPanelPhases.RoomSummary; let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space // We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader; let scopeHeader;
if (room?.isSpaceRoom()) { if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
previousPhase = undefined; previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />

View file

@ -26,6 +26,7 @@ import {isValid3pidInvite} from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps { interface IProps {
event: MatrixEvent; event: MatrixEvent;
@ -135,7 +136,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
} }
let scopeHeader; let scopeHeader;
if (this.room.isSpaceRoom()) { if (SettingsStore.getValue("feature_spaces") && this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} /> <RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} /> <RoomName room={this.room} />

View file

@ -18,6 +18,7 @@ import React from 'react';
import {_t, getCurrentLanguage} from "../../../../../languageHandler"; import {_t, getCurrentLanguage} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton';
import SdkConfig from "../../../../../SdkConfig"; import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom"; import createRoom from "../../../../../createRoom";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
@ -26,6 +27,9 @@ import PlatformPeg from "../../../../../PlatformPeg";
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import UpdateCheckButton from "../../UpdateCheckButton"; import UpdateCheckButton from "../../UpdateCheckButton";
import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { copyPlaintext } from "../../../../../utils/strings";
import * as ContextMenu from "../../../../structures/ContextMenu";
import { toRightOf } from "../../../../structures/ContextMenu";
interface IProps { interface IProps {
closeSettingsFn: () => {}; closeSettingsFn: () => {};
@ -38,6 +42,8 @@ interface IState {
@replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab") @replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab")
export default class HelpUserSettingsTab extends React.Component<IProps, IState> { export default class HelpUserSettingsTab extends React.Component<IProps, IState> {
protected closeCopiedTooltip: () => void;
constructor(props) { constructor(props) {
super(props); super(props);
@ -56,6 +62,12 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
}); });
} }
componentWillUnmount() {
// if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close
// the tooltip otherwise, such as pressing Escape
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
}
private onClearCacheAndReload = (e) => { private onClearCacheAndReload = (e) => {
if (!PlatformPeg.get()) return; if (!PlatformPeg.get()) return;
@ -153,6 +165,20 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
); );
} }
onAccessTokenCopyClick = async (e) => {
e.preventDefault();
const target = e.target; // copy target before we go async and React throws it away
const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken());
const buttonRect = target.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
this.closeCopiedTooltip = target.onmouseleave = close;
}
render() { render() {
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
@ -269,12 +295,20 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br /> {_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br />
{_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br /> {_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br />
{_t("Access Token:") + ' '} <br />
<AccessibleButton element="span" onClick={this.showSpoiler} <details>
data-spoiler={MatrixClientPeg.get().getAccessToken()} <summary>{_t("Access Token")}</summary><br />
> <b>{_t("Your access token gives full access to your account."
&lt;{ _t("click to reveal") }&gt; + " Do not share it with anyone." )}</b>
</AccessibleButton> <div className="mx_HelpUserSettingsTab_accessToken">
<code>{MatrixClientPeg.get().getAccessToken()}</code>
<AccessibleTooltipButton
title={_t("Copy")}
onClick={this.onAccessTokenCopyClick}
className="mx_HelpUserSettingsTab_accessToken_copy"
/>
</div>
</details><br />
<div className='mx_HelpUserSettingsTab_debugButton'> <div className='mx_HelpUserSettingsTab_debugButton'>
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'> <AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{_t("Clear cache and reload")} {_t("Clear cache and reload")}

View file

@ -15,12 +15,11 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; import {IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording} from "../../../voice/VoiceRecording";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {arrayFastResample, arraySeed} from "../../../utils/arrays";
import {percentageOf} from "../../../utils/numbers"; import {percentageOf} from "../../../utils/numbers";
import Waveform from "./Waveform"; import Waveform from "./Waveform";
import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
interface IProps { interface IProps {
recorder: VoiceRecording; recorder: VoiceRecording;
@ -38,14 +37,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
public constructor(props) { public constructor(props) {
super(props); super(props);
this.state = {heights: arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES)}; this.state = {heights: arraySeed(0, RECORDING_PLAYBACK_SAMPLES)};
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
} }
private onRecordingUpdate = (update: IRecordingUpdate) => { private onRecordingUpdate = (update: IRecordingUpdate) => {
// The waveform and the downsample target are pretty close, so we should be fine to // The waveform and the downsample target are pretty close, so we should be fine to
// do this, despite the docs on arrayFastResample. // do this, despite the docs on arrayFastResample.
const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES); const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
this.setState({ this.setState({
// The incoming data is between zero and one, but typically even screaming into a // The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the // microphone won't send you over 0.6, so we artificially adjust the gain for the

View file

@ -0,0 +1,97 @@
/*
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, {createRef} from 'react';
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import CallMediaHandler from "../../../CallMediaHandler";
interface IProps {
feed: CallFeed,
}
export default class AudioFeed extends React.Component<IProps> {
private element = createRef<HTMLAudioElement>();
componentDidMount() {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.playMedia();
}
componentWillUnmount() {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.stopMedia();
}
private playMedia() {
const element = this.element.current;
const audioOutput = CallMediaHandler.getAudioOutput();
if (audioOutput) {
try {
// This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where
// it fails.
// It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID
// back to the default after the call is over - Dave
element.setSinkId(audioOutput);
} catch (e) {
console.error("Couldn't set requested audio output device: using default", e);
logger.warn("Couldn't set requested audio output device: using default", e);
}
}
element.muted = false;
element.srcObject = this.props.feed.stream;
element.autoplay = true;
try {
// A note on calling methods on media elements:
// We used to have queues per media element to serialise all calls on those elements.
// The reason given for this was that load() and play() were racing. However, we now
// never call load() explicitly so this seems unnecessary. However, serialising every
// operation was causing bugs where video would not resume because some play command
// had got stuck and all media operations were queued up behind it. If necessary, we
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play()
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
}
}
private stopMedia() {
const element = this.element.current;
element.pause();
element.src = null;
// As per comment in componentDidMount, setting the sink ID back to the
// default once the call is over makes setSinkId work reliably. - Dave
// Since we are not using the same element anymore, the above doesn't
// seem to be necessary - Šimon
}
private onNewStream = () => {
this.playMedia();
};
render() {
return (
<audio ref={this.element} />
);
}
}

View file

@ -0,0 +1,60 @@
/*
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 AudioFeed from "./AudioFeed"
import { CallEvent, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
interface IProps {
call: MatrixCall;
}
interface IState {
feeds: Array<CallFeed>;
}
export default class AudioFeedArrayForCall extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
feeds: [],
};
}
componentDidMount() {
this.props.call.addListener(CallEvent.FeedsChanged, this.onFeedsChanged);
}
componentWillUnmount() {
this.props.call.removeListener(CallEvent.FeedsChanged, this.onFeedsChanged);
}
onFeedsChanged = () => {
this.setState({
feeds: this.props.call.getRemoteFeeds(),
});
}
render() {
return this.state.feeds.map((feed, i) => {
return (
<AudioFeed feed={feed} key={i} />
);
});
}
}

View file

@ -19,7 +19,7 @@ import React from 'react';
import CallView from "./CallView"; import CallView from "./CallView";
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import CallHandler from '../../../CallHandler'; import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from '../../../dispatcher/payloads'; import { ActionPayload } from '../../../dispatcher/payloads';
import PersistentApp from "../elements/PersistentApp"; import PersistentApp from "../elements/PersistentApp";
@ -27,7 +27,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
const SHOW_CALL_IN_STATES = [ const SHOW_CALL_IN_STATES = [
CallState.Connected, CallState.Connected,
@ -110,12 +109,14 @@ export default class CallPreview extends React.Component<IProps, IState> {
} }
public componentDidMount() { public componentDidMount() {
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
} }
public componentWillUnmount() { public componentWillUnmount() {
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
if (this.roomStoreToken) { if (this.roomStoreToken) {
this.roomStoreToken.remove(); this.roomStoreToken.remove();
@ -143,8 +144,14 @@ export default class CallPreview extends React.Component<IProps, IState> {
switch (payload.action) { switch (payload.action) {
// listen for call state changes to prod the render method, which // listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead // may hide the global CallView if the call it is tracking is dead
case Action.CallChangeRoom:
case 'call_state': { case 'call_state': {
this.updateCalls();
break;
}
}
};
private updateCalls = () => {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls( const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId), CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
); );
@ -153,9 +160,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
primaryCall: primaryCall, primaryCall: primaryCall,
secondaryCall: secondaryCalls[0], secondaryCall: secondaryCalls[0],
}); });
break;
}
}
}; };
private onCallRemoteHold = () => { private onCallRemoteHold = () => {

View file

@ -20,10 +20,9 @@ import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler'; import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import VideoFeed, { VideoFeedType } from "./VideoFeed"; import VideoFeed from './VideoFeed';
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallState, CallType, MatrixCall, CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard'; import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
@ -31,6 +30,7 @@ import {alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton} f
import CallContextMenu from '../context_menus/CallContextMenu'; import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar'; import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu'; import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
@ -40,11 +40,11 @@ interface IProps {
// Another ongoing call to display information about // Another ongoing call to display information about
secondaryCall?: MatrixCall, secondaryCall?: MatrixCall,
// a callback which is called when the content in the callview changes // a callback which is called when the content in the CallView changes
// in a way that is likely to cause a resize. // in a way that is likely to cause a resize.
onResize?: any; onResize?: any;
// Whether this call view is for picture-in-pictue mode // 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. // 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 // 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. // need to control those things separately, so this is simpler.
@ -60,6 +60,7 @@ interface IState {
controlsVisible: boolean, controlsVisible: boolean,
showMoreMenu: boolean, showMoreMenu: boolean,
showDialpad: boolean, showDialpad: boolean,
feeds: CallFeed[],
} }
function getFullScreenElement() { function getFullScreenElement() {
@ -115,6 +116,7 @@ export default class CallView extends React.Component<IProps, IState> {
controlsVisible: true, controlsVisible: true,
showMoreMenu: false, showMoreMenu: false,
showDialpad: false, showDialpad: false,
feeds: this.props.call.getFeeds(),
} }
this.updateCallListeners(null, this.props.call); this.updateCallListeners(null, this.props.call);
@ -172,11 +174,13 @@ export default class CallView extends React.Component<IProps, IState> {
oldCall.removeListener(CallEvent.State, this.onCallState); oldCall.removeListener(CallEvent.State, this.onCallState);
oldCall.removeListener(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold); oldCall.removeListener(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
oldCall.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold); oldCall.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
oldCall.removeListener(CallEvent.FeedsChanged, this.onFeedsChanged);
} }
if (newCall) { if (newCall) {
newCall.on(CallEvent.State, this.onCallState); newCall.on(CallEvent.State, this.onCallState);
newCall.on(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold); newCall.on(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
newCall.on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold); newCall.on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
newCall.on(CallEvent.FeedsChanged, this.onFeedsChanged);
} }
} }
@ -186,6 +190,10 @@ export default class CallView extends React.Component<IProps, IState> {
}); });
}; };
private onFeedsChanged = (newFeeds: Array<CallFeed>) => {
this.setState({feeds: newFeeds});
};
private onCallLocalHoldUnhold = () => { private onCallLocalHoldUnhold = () => {
this.setState({ this.setState({
isLocalOnHold: this.props.call.isLocalOnHold(), isLocalOnHold: this.props.call.isLocalOnHold(),
@ -304,7 +312,7 @@ export default class CallView extends React.Component<IProps, IState> {
} }
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a callview on screen at any given time // Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this // CallHandler would probably be a better place for this
private onNativeKeyDown = ev => { private onNativeKeyDown = ev => {
let handled = false; let handled = false;
@ -474,6 +482,8 @@ export default class CallView extends React.Component<IProps, IState> {
{contextMenuButton} {contextMenuButton}
</div>; </div>;
const avatarSize = this.props.pipMode ? 76 : 160;
// The 'content' for the call, ie. the videos for a video call and profile picture // The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg) // for voice calls (fills the bg)
let contentView: React.ReactNode; let contentView: React.ReactNode;
@ -524,41 +534,85 @@ export default class CallView extends React.Component<IProps, IState> {
</div>; </div>;
} }
// This is a bit messy. I can't see a reason to have two onHold/transfer screens
if (isOnHold || transfereeCall) {
if (this.props.call.type === CallType.Video) { if (this.props.call.type === CallType.Video) {
let localVideoFeed = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const containerClasses = classNames({ const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true, mx_CallView_video: true,
mx_CallView_video_hold: isOnHold, mx_CallView_video_hold: isOnHold,
}); });
if (isOnHold) { let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const backgroundAvatarUrl = avatarUrlForMember( 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', this.props.call.getOpponentMember(), 1024, 1024, 'crop',
); );
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')'; backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />; onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />;
}
if (!this.state.vidMuted) {
localVideoFeed = <VideoFeed type={VideoFeedType.Local} call={this.props.call} />;
}
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}> contentView = (
<div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{onHoldBackground} {onHoldBackground}
<VideoFeed type={VideoFeedType.Remote} call={this.props.call} onResize={this.props.onResize} />
{localVideoFeed}
{holdTransferContent} {holdTransferContent}
{callControls} {callControls}
</div>; </div>
);
} else { } else {
const avatarSize = this.props.pipMode ? 76 : 160;
const classes = classNames({ const classes = classNames({
mx_CallView_content: true,
mx_CallView_voice: true, mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold, mx_CallView_voice_hold: isOnHold,
}); });
contentView =(
<div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer">
<div
className="mx_CallView_voice_avatarContainer"
style={{width: avatarSize, height: avatarSize}}
>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
</div>
</div>
{holdTransferContent}
{callControls}
</div>
);
}
} else if (this.props.call.noIncomingFeeds()) {
// Here we're reusing the css classes from voice on hold, because
// I am lazy. If this gets merged, the CallView might be subject
// to change anyway - I might take an axe to this file in order to
// try to get other things working
const classes = classNames({
mx_CallView_content: true,
mx_CallView_voice: true,
});
const feeds = this.props.call.getLocalFeeds().map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted()) return;
return (
<VideoFeed
key={i}
feed={feed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
/>
);
});
// Saying "Connecting" here isn't really true, but the best thing
// I can come up with, but this might be subject to change as well
contentView = <div className={classes} onMouseMove={this.onMouseMove}> contentView = <div className={classes} onMouseMove={this.onMouseMove}>
{feeds}
<div className="mx_CallView_voice_avatarsContainer"> <div className="mx_CallView_voice_avatarsContainer">
<div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}> <div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}>
<RoomAvatar <RoomAvatar
@ -568,7 +622,35 @@ export default class CallView extends React.Component<IProps, IState> {
/> />
</div> </div>
</div> </div>
{holdTransferContent} <div className="mx_CallView_holdTransferContent">{_t("Connecting")}</div>
{callControls}
</div>;
} else {
const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true,
});
// TODO: Later the CallView should probably be reworked to support
// any number of feeds but now we can always expect there to be two
// feeds. This is because the js-sdk ignores any new incoming streams
const feeds = this.state.feeds.map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted() && feed.isLocal()) return;
return (
<VideoFeed
key={i}
feed={feed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
/>
);
});
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{feeds}
{callControls} {callControls}
</div>; </div>;
} }

View file

@ -16,13 +16,12 @@ limitations under the License.
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React from 'react'; import React from 'react';
import CallHandler from '../../../CallHandler'; import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import CallView from './CallView'; import CallView from './CallView';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import {Resizable} from "re-resizable"; import {Resizable} from "re-resizable";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
interface IProps { interface IProps {
// What room we should display the call for // What room we should display the call for
@ -55,23 +54,28 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
public componentDidMount() { public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
} }
public componentWillUnmount() { public componentWillUnmount() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
} }
private onAction = (payload) => { private onAction = (payload) => {
switch (payload.action) { switch (payload.action) {
case Action.CallChangeRoom:
case 'call_state': { case 'call_state': {
this.updateCall();
break;
}
}
};
private updateCall = () => {
const newCall = this.getCall(); const newCall = this.getCall();
if (newCall !== this.state.call) { if (newCall !== this.state.call) {
this.setState({call: newCall}); this.setState({call: newCall});
} }
break;
}
}
}; };
private getCall(): MatrixCall { private getCall(): MatrixCall {

View file

@ -18,52 +18,102 @@ import classnames from 'classnames';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import MemberAvatar from "../avatars/MemberAvatar"
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
export enum VideoFeedType {
Local,
Remote,
}
interface IProps { interface IProps {
call: MatrixCall, call: MatrixCall,
type: VideoFeedType, feed: CallFeed,
// 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;
// a callback which is called when the video element is resized // a callback which is called when the video element is resized
// due to a change in video metadata // due to a change in video metadata
onResize?: (e: Event) => void, onResize?: (e: Event) => void,
} }
interface IState {
audioMuted: boolean;
videoMuted: boolean;
}
@replaceableComponent("views.voip.VideoFeed") @replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps> { export default class VideoFeed extends React.Component<IProps, IState> {
private vid = createRef<HTMLVideoElement>(); private element = createRef<HTMLVideoElement>();
constructor(props: IProps) {
super(props);
this.state = {
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
};
}
componentDidMount() { componentDidMount() {
this.vid.current.addEventListener('resize', this.onResize); this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.setVideoElement(); this.playMedia();
}
componentDidUpdate(prevProps) {
if (this.props.call !== prevProps.call) {
this.setVideoElement();
}
} }
componentWillUnmount() { componentWillUnmount() {
this.vid.current.removeEventListener('resize', this.onResize); this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.element.current?.removeEventListener('resize', this.onResize);
this.stopMedia();
} }
private setVideoElement() { private playMedia() {
if (this.props.type === VideoFeedType.Local) { const element = this.element.current;
this.props.call.setLocalVideoElement(this.vid.current); if (!element) return;
} else { // We play audio in AudioFeed, not here
this.props.call.setRemoteVideoElement(this.vid.current); element.muted = true;
element.srcObject = this.props.feed.stream;
element.autoplay = true;
try {
// A note on calling methods on media elements:
// We used to have queues per media element to serialise all calls on those elements.
// The reason given for this was that load() and play() were racing. However, we now
// never call load() explicitly so this seems unnecessary. However, serialising every
// operation was causing bugs where video would not resume because some play command
// had got stuck and all media operations were queued up behind it. If necessary, we
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play()
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
} }
} }
onResize = (e) => { private stopMedia() {
if (this.props.onResize) { const element = this.element.current;
if (!element) return;
element.pause();
element.src = null;
// As per comment in componentDidMount, setting the sink ID back to the
// default once the call is over makes setSinkId work reliably. - Dave
// Since we are not using the same element anymore, the above doesn't
// seem to be necessary - Šimon
}
private onNewStream = () => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
this.playMedia();
};
private onResize = (e) => {
if (this.props.onResize && !this.props.feed.isLocal()) {
this.props.onResize(e); this.props.onResize(e);
} }
}; };
@ -71,14 +121,33 @@ export default class VideoFeed extends React.Component<IProps> {
render() { render() {
const videoClasses = { const videoClasses = {
mx_VideoFeed: true, mx_VideoFeed: true,
mx_VideoFeed_local: this.props.type === VideoFeedType.Local, mx_VideoFeed_local: this.props.feed.isLocal(),
mx_VideoFeed_remote: this.props.type === VideoFeedType.Remote, mx_VideoFeed_remote: !this.props.feed.isLocal(),
mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted,
mx_VideoFeed_mirror: ( mx_VideoFeed_mirror: (
this.props.type === VideoFeedType.Local && this.props.feed.isLocal() &&
SettingsStore.getValue('VideoView.flipVideoHorizontally') SettingsStore.getValue('VideoView.flipVideoHorizontally')
), ),
}; };
return <video className={classnames(videoClasses)} ref={this.vid} />; if (this.state.videoMuted) {
const member = this.props.feed.getMember();
const avatarSize = this.props.pipMode ? 76 : 160;
return (
<div className={classnames(videoClasses)} >
<MemberAvatar
member={member}
height={avatarSize}
width={avatarSize}
/>
</div>
);
} else {
return (
<video className={classnames(videoClasses)} ref={this.element} />
);
}
} }
} }

View file

@ -114,9 +114,6 @@ export enum Action {
*/ */
VirtualRoomSupportUpdated = "virtual_room_support_updated", VirtualRoomSupportUpdated = "virtual_room_support_updated",
// Probably would be better to have a VoIP states in a store and have the store emit changes
CallChangeRoom = "call_change_room",
/** /**
* Fired when an upload has started. Should be used with UploadStartedPayload. * Fired when an upload has started. Should be used with UploadStartedPayload.
*/ */

View file

@ -833,7 +833,7 @@
"Match system theme": "Match system theme", "Match system theme": "Match system theme",
"Use a system font": "Use a system font", "Use a system font": "Use a system font",
"System font name": "System font name", "System font name": "System font name",
"Allow Peer-to-Peer for 1:1 calls": "Allow Peer-to-Peer for 1:1 calls", "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)",
"Send analytics data": "Send analytics data", "Send analytics data": "Send analytics data",
"Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session", "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session",
"Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session", "Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session",
@ -885,6 +885,7 @@
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>", "You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>", "You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
"%(peerName)s held the call": "%(peerName)s held the call", "%(peerName)s held the call": "%(peerName)s held the call",
"Connecting": "Connecting",
"Video Call": "Video Call", "Video Call": "Video Call",
"Voice Call": "Voice Call", "Voice Call": "Voice Call",
"Fill Screen": "Fill Screen", "Fill Screen": "Fill Screen",
@ -1252,8 +1253,9 @@
"olm version:": "olm version:", "olm version:": "olm version:",
"Homeserver is": "Homeserver is", "Homeserver is": "Homeserver is",
"Identity Server is": "Identity Server is", "Identity Server is": "Identity Server is",
"Access Token:": "Access Token:", "Access Token": "Access Token",
"click to reveal": "click to reveal", "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", "Clear cache and reload": "Clear cache and reload",
"Labs": "Labs", "Labs": "Labs",
"Customise your experience with experimental labs features. <a>Learn more</a>.": "Customise your experience with experimental labs features. <a>Learn more</a>.", "Customise your experience with experimental labs features. <a>Learn more</a>.": "Customise your experience with experimental labs features. <a>Learn more</a>.",
@ -2031,10 +2033,11 @@
"Direct Messages": "Direct Messages", "Direct Messages": "Direct Messages",
"Space selection": "Space selection", "Space selection": "Space selection",
"Add existing rooms": "Add existing rooms", "Add existing rooms": "Add existing rooms",
"Don't want to add an existing room?": "Don't want to add an existing room?", "Not all selected were added": "Not all selected were added",
"Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)",
"Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...",
"Want to add a new room instead?": "Want to add a new room instead?",
"Create a new room": "Create a new room", "Create a new room": "Create a new room",
"Failed to add rooms to space": "Failed to add rooms to space",
"Adding...": "Adding...",
"Matrix ID": "Matrix ID", "Matrix ID": "Matrix ID",
"Matrix Room ID": "Matrix Room ID", "Matrix Room ID": "Matrix Room ID",
"email address": "email address", "email address": "email address",
@ -2345,7 +2348,6 @@
"Share Community": "Share Community", "Share Community": "Share Community",
"Share Room Message": "Share Room Message", "Share Room Message": "Share Room Message",
"Link to selected message": "Link to selected message", "Link to selected message": "Link to selected message",
"Copy": "Copy",
"Command Help": "Command Help", "Command Help": "Command Help",
"Failed to save space settings.": "Failed to save space settings.", "Failed to save space settings.": "Failed to save space settings.",
"Space settings": "Space settings", "Space settings": "Space settings",
@ -2672,6 +2674,8 @@
"Failed to create initial space rooms": "Failed to create initial space rooms", "Failed to create initial space rooms": "Failed to create initial space rooms",
"Skip for now": "Skip for now", "Skip for now": "Skip for now",
"Creating rooms...": "Creating rooms...", "Creating rooms...": "Creating rooms...",
"Failed to add rooms to space": "Failed to add rooms to space",
"Adding...": "Adding...",
"What do you want to organise?": "What do you want to organise?", "What do you want to organise?": "What do you want to organise?",
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.", "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.",
"Share %(name)s": "Share %(name)s", "Share %(name)s": "Share %(name)s",

View file

@ -438,7 +438,10 @@ export const SETTINGS: {[setting: string]: ISetting} = {
}, },
"webRtcAllowPeerToPeer": { "webRtcAllowPeerToPeer": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
displayName: _td('Allow Peer-to-Peer for 1:1 calls'), displayName: _td(
"Allow Peer-to-Peer for 1:1 calls " +
"(if you enable this, the other party might be able to see your IP address)",
),
default: true, default: true,
invertedSettingName: 'webRtcForceTURN', invertedSettingName: 'webRtcForceTURN',
}, },

View file

@ -122,7 +122,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
private async appendRoom(room: Room) { private async appendRoom(room: Room) {
if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) return; // hide space rooms if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return; // hide space rooms
let updated = false; let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone const rooms = (this.state.rooms || []).slice(); // cheap clone

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {sortBy, throttle} from "lodash"; import {ListIteratee, Many, sortBy, throttle} from "lodash";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {MatrixEvent} from "matrix-js-sdk/src/models/event";
@ -56,15 +56,18 @@ const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces,
}, [[], []]); }, [[], []]);
}; };
const getOrder = (ev: MatrixEvent): string | null => { // For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id`
const content = ev.getContent(); export const getOrder = (order: string, creationTs: number, roomId: string): Array<Many<ListIteratee<any>>> => {
if (typeof content.order === "string" && Array.from(content.order).every((c: string) => { let validatedOrder: string = null;
if (typeof order === "string" && Array.from(order).every((c: string) => {
const charCode = c.charCodeAt(0); const charCode = c.charCodeAt(0);
return charCode >= 0x20 && charCode <= 0x7F; return charCode >= 0x20 && charCode <= 0x7F;
})) { })) {
return content.order; validatedOrder = order;
} }
return null;
return [validatedOrder, creationTs, roomId];
} }
const getRoomFn: FetchRoomFn = (room: Room) => { const getRoomFn: FetchRoomFn = (room: Room) => {
@ -105,6 +108,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._suggestedRooms; return this._suggestedRooms;
} }
/**
* Sets the active space, updates room list filters,
* optionally switches the user's room back to where they were when they last viewed that space.
* @param space which space to switch to.
* @param contextSwitch whether to switch the user's context,
* should not be done when the space switch is done implicitly due to another event like switching room.
*/
public async setActiveSpace(space: Room | null, contextSwitch = true) { public async setActiveSpace(space: Room | null, contextSwitch = true) {
if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return; if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return;
@ -186,9 +196,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
private getChildren(spaceId: string): Room[] { private getChildren(spaceId: string): Room[] {
const room = this.matrixClient?.getRoom(spaceId); const room = this.matrixClient?.getRoom(spaceId);
const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via);
return sortBy(childEvents, getOrder) return sortBy(childEvents, ev => {
.map(ev => this.matrixClient.getRoom(ev.getStateKey())) const roomId = ev.getStateKey();
.filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || []; const childRoom = this.matrixClient?.getRoom(roomId);
const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs();
return getOrder(ev.getContent().order, createTs, roomId);
}).map(ev => {
return this.matrixClient.getRoom(ev.getStateKey());
}).filter(room => {
return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite";
}) || [];
} }
public getChildRooms(spaceId: string): Room[] { public getChildRooms(spaceId: string): Room[] {
@ -300,7 +317,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// if the currently selected space no longer exists, remove its selection // if the currently selected space no longer exists, remove its selection
if (this._activeSpace && detachedNodes.has(this._activeSpace)) { if (this._activeSpace && detachedNodes.has(this._activeSpace)) {
this.setActiveSpace(null); this.setActiveSpace(null, false);
} }
this.onRoomsUpdate(); // TODO only do this if a change has happened this.onRoomsUpdate(); // TODO only do this if a change has happened
@ -383,6 +400,22 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}); });
}, 100, {trailing: true, leading: true}); }, 100, {trailing: true, leading: true});
private switchToRelatedSpace = (roomId: string) => {
if (this.suggestedRooms.find(r => r.room_id === roomId)) return;
let parent = this.getCanonicalParent(roomId);
if (!parent) {
parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(roomId));
}
if (!parent) {
const parents = Array.from(this.parentMap.get(roomId) || []);
parent = parents.find(p => this.matrixClient.getRoom(p));
}
// don't trigger a context switch when we are switching a space to match the chosen room
this.setActiveSpace(parent || null, false);
};
private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => {
const membership = newMembership || room.getMyMembership(); const membership = newMembership || room.getMyMembership();
@ -397,6 +430,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (numSuggestedRooms !== this._suggestedRooms.length) { if (numSuggestedRooms !== this._suggestedRooms.length) {
this.emit(SUGGESTED_ROOMS, this._suggestedRooms); this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
} }
// if the room currently being viewed was just joined then switch to its related space
if (newMembership === "join" && room.roomId === RoomViewStore.getRoomId()) {
this.switchToRelatedSpace(room.roomId);
}
} }
return; return;
} }
@ -415,7 +453,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) { if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) {
// if the user was looking at the space and then joined: select that space // if the user was looking at the space and then joined: select that space
this.setActiveSpace(room); this.setActiveSpace(room, false);
} }
}; };
@ -479,10 +517,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// restore selected state from last session if any and still valid // restore selected state from last session if any and still valid
const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY); const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY);
if (lastSpaceId) { if (lastSpaceId) {
const space = this.rootSpaces.find(s => s.roomId === lastSpaceId); this.setActiveSpace(this.matrixClient.getRoom(lastSpaceId));
if (space) {
this.setActiveSpace(space);
}
} }
} }
@ -490,27 +525,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (!SettingsStore.getValue("feature_spaces")) return; if (!SettingsStore.getValue("feature_spaces")) return;
switch (payload.action) { switch (payload.action) {
case "view_room": { case "view_room": {
const room = this.matrixClient?.getRoom(payload.room_id);
// Don't auto-switch rooms when reacting to a context-switch // Don't auto-switch rooms when reacting to a context-switch
// as this is not helpful and can create loops of rooms/space switching // as this is not helpful and can create loops of rooms/space switching
if (!room || payload.context_switch) break; if (payload.context_switch) break;
if (room.isSpaceRoom()) { const roomId = payload.room_id;
const room = this.matrixClient?.getRoom(roomId);
if (room?.isSpaceRoom()) {
// Don't context switch when navigating to the space room // Don't context switch when navigating to the space room
// as it will cause you to end up in the wrong room // as it will cause you to end up in the wrong room
this.setActiveSpace(room, false); this.setActiveSpace(room, false);
} else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) { } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) {
let parent = this.getCanonicalParent(room.roomId); this.switchToRelatedSpace(roomId);
if (!parent) {
parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
}
if (!parent) {
const parents = Array.from(this.parentMap.get(room.roomId) || []);
parent = parents.find(p => this.matrixClient.getRoom(p));
}
// don't trigger a context switch when we are switching a space to match the chosen room
this.setActiveSpace(parent || null, false);
} }
// Persist last viewed room from a space // Persist last viewed room from a space
@ -521,7 +547,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
case "after_leave_room": case "after_leave_room":
if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { if (this._activeSpace && payload.room_id === this._activeSpace.roomId) {
this.setActiveSpace(null); this.setActiveSpace(null, false);
} }
break; break;
} }

View file

@ -426,6 +426,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
return; // don't do anything on rooms that aren't visible return; // don't do anything on rooms that aren't visible
} }
if (cause === RoomUpdateCause.NewRoom && !this.prefilterConditions.every(c => c.isVisible(room))) {
return; // don't do anything on new rooms which ought not to be shown
}
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) { if (shouldUpdate) {
if (SettingsStore.getValue("advancedRoomListLogging")) { if (SettingsStore.getValue("advancedRoomListLogging")) {

View file

@ -199,8 +199,10 @@ export class Algorithm extends EventEmitter {
} }
private async doUpdateStickyRoom(val: Room) { private async doUpdateStickyRoom(val: Room) {
if (SettingsStore.getValue("feature_spaces") && val?.isSpaceRoom() && val.getMyMembership() !== "invite") {
// no-op sticky rooms for spaces - they're effectively virtual rooms // no-op sticky rooms for spaces - they're effectively virtual rooms
if (val?.isSpaceRoom() && val.getMyMembership() !== "invite") val = null; val = null;
}
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms. // otherwise we risk duplicating rooms.
@ -577,9 +579,8 @@ export class Algorithm extends EventEmitter {
await this.generateFreshTags(newTags); await this.generateFreshTags(newTags);
this.cachedRooms = newTags; this.cachedRooms = newTags; // this recalculates the filtered rooms for us
this.updateTagsFromCache(); this.updateTagsFromCache();
this.recalculateFilteredRooms();
// Now that we've finished generation, we need to update the sticky room to what // Now that we've finished generation, we need to update the sticky room to what
// it was. It's entirely possible that it changed lists though, so if it did then // it was. It's entirely possible that it changed lists though, so if it did then

View file

@ -50,7 +50,7 @@ export class VisibilityProvider {
} }
// hide space rooms as they'll be shown in the SpacePanel // hide space rooms as they'll be shown in the SpacePanel
if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) { if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
return false; return false;
} }

View file

@ -29,7 +29,7 @@ export enum PlaybackState {
Playing = "playing", // active progress through timeline Playing = "playing", // active progress through timeline
} }
export const PLAYBACK_WAVEFORM_SAMPLES = 35; export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
export class Playback extends EventEmitter implements IDestroyable { export class Playback extends EventEmitter implements IDestroyable {

View file

@ -33,6 +33,8 @@ const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus
const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files. const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files.
const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary. const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary.
export const RECORDING_PLAYBACK_SAMPLES = 44;
export interface IRecordingUpdate { export interface IRecordingUpdate {
waveform: number[]; // floating points between 0 (low) and 1 (high). waveform: number[]; // floating points between 0 (low) and 1 (high).
timeSeconds: number; // float timeSeconds: number; // float

View file

@ -16,7 +16,7 @@ limitations under the License.
import './skinned-sdk'; import './skinned-sdk';
import CallHandler, { PlaceCallType } from '../src/CallHandler'; import CallHandler, { PlaceCallType, CallHandlerEvent } from '../src/CallHandler';
import { stubClient, mkStubRoom } from './test-utils'; import { stubClient, mkStubRoom } from './test-utils';
import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { MatrixClientPeg } from '../src/MatrixClientPeg';
import dis from '../src/dispatcher/dispatcher'; import dis from '../src/dispatcher/dispatcher';
@ -172,11 +172,9 @@ describe('CallHandler', () => {
let callRoomChangeEventCount = 0; let callRoomChangeEventCount = 0;
const roomChangePromise = new Promise<void>(resolve => { const roomChangePromise = new Promise<void>(resolve => {
dispatchHandle = dis.register(payload => { callHandler.addListener(CallHandlerEvent.CallChangeRoom, () => {
if (payload.action === Action.CallChangeRoom) {
++callRoomChangeEventCount; ++callRoomChangeEventCount;
resolve(); resolve();
}
}); });
}); });
@ -201,7 +199,7 @@ describe('CallHandler', () => {
fakeCall.emit(CallEvent.AssertedIdentityChanged); fakeCall.emit(CallEvent.AssertedIdentityChanged);
await roomChangePromise; await roomChangePromise;
dis.unregister(dispatchHandle); callHandler.removeAllListeners();
// If everything's gone well, we should have seen only one room change // If everything's gone well, we should have seen only one room change
// event and the call should now be in user 3's room. // event and the call should now be in user 3's room.

View file

@ -435,9 +435,9 @@ jsprim@^1.2.2:
verror "1.10.0" verror "1.10.0"
lodash@^4.15.0, lodash@^4.17.11: lodash@^4.15.0, lodash@^4.17.11:
version "4.17.19" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
mime-db@~1.38.0: mime-db@~1.38.0:
version "1.38.0" version "1.38.0"

View file

@ -5580,9 +5580,9 @@ lodash.sortby@^4.7.0:
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.2.1: lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.2.1:
version "4.17.20" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log-symbols@^4.0.0: log-symbols@^4.0.0:
version "4.0.0" version "4.0.0"
@ -8070,9 +8070,9 @@ typescript@^4.1.3:
integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==
ua-parser-js@^0.7.18: ua-parser-js@^0.7.18:
version "0.7.23" version "0.7.28"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.23.tgz#704d67f951e13195fbcd3d78818577f5bc1d547b" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
integrity sha512-m4hvMLxgGHXG3O3fQVAyyAQpZzDOvwnhOTjYz5Xmr7r/+LpkNy3vJXdVRWgd1TkAb7NGROZuSy96CrlNVjA7KA== integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
unhomoglyph@^1.0.6: unhomoglyph@^1.0.6:
version "1.0.6" version "1.0.6"