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

This commit is contained in:
Michael Telatynski 2021-08-06 11:39:24 +01:00
commit 64995dfae7
33 changed files with 290 additions and 147 deletions

1
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1 @@
* @matrix-org/element-web

View file

@ -193,7 +193,8 @@
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js", "decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js", "waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js" "workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js",
"RecorderWorklet": "<rootDir>/__mocks__/empty.js"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!matrix-js-sdk).+$" "/node_modules/(?!matrix-js-sdk).+$"

View file

@ -85,7 +85,7 @@ limitations under the License.
.mx_InteractiveAuthEntryComponents_termsPolicy { .mx_InteractiveAuthEntryComponents_termsPolicy {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: start; justify-content: flex-start;
align-items: center; align-items: center;
} }

View file

@ -35,7 +35,6 @@ limitations under the License.
.mx_desktopCapturerSourcePicker_source_thumbnail { .mx_desktopCapturerSourcePicker_source_thumbnail {
margin: 4px; margin: 4px;
padding: 4px; padding: 4px;
width: 312px;
border-width: 2px; border-width: 2px;
border-radius: 8px; border-radius: 8px;
border-style: solid; border-style: solid;
@ -53,6 +52,5 @@ limitations under the License.
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
width: 312px;
} }
} }

View file

@ -271,7 +271,7 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: start; justify-content: flex-start;
padding: 5px 0; padding: 5px 0;
.mx_EventTile_avatar { .mx_EventTile_avatar {

View file

@ -310,14 +310,12 @@ $hover-select-border: 4px;
} }
.mx_RoomView_timeline_rr_enabled { .mx_RoomView_timeline_rr_enabled {
.mx_EventTile[data-layout=group] {
.mx_EventTile:not([data-layout=bubble]) {
.mx_EventTile_line { .mx_EventTile_line {
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */ /* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px; margin-right: 110px;
} }
} }
// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter // on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter
} }

View file

@ -46,6 +46,21 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/trashcan.svg'); mask-image: url('$(res)/img/element-icons/trashcan.svg');
} }
.mx_VoiceRecordComposerTile_uploadingState {
margin-right: 10px;
color: $secondary-fg-color;
}
.mx_VoiceRecordComposerTile_failedState {
margin-right: 21px;
.mx_VoiceRecordComposerTile_uploadState_badge {
display: inline-block;
margin-right: 4px;
vertical-align: middle;
}
}
.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer { .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
// Note: remaining class properties are in the PlayerContainer CSS. // Note: remaining class properties are in the PlayerContainer CSS.
@ -68,7 +83,7 @@ limitations under the License.
height: 10px; height: 10px;
position: absolute; position: absolute;
left: 12px; // 12px from the left edge for container padding left: 12px; // 12px from the left edge for container padding
top: 18px; // vertically center (middle align with clock) top: 16px; // vertically center (middle align with clock)
border-radius: 10px; border-radius: 10px;
} }
} }

View file

@ -28,7 +28,6 @@ limitations under the License.
.mx_CallPreview { .mx_CallPreview {
pointer-events: initial; // restore pointer events so the user can leave/interact pointer-events: initial; // restore pointer events so the user can leave/interact
cursor: pointer;
.mx_VideoFeed_remote.mx_VideoFeed_voice { .mx_VideoFeed_remote.mx_VideoFeed_voice {
min-height: 150px; min-height: 150px;

View file

@ -75,8 +75,6 @@ limitations under the License.
height: 100%; height: 100%;
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
// We don't want to collide with the call controls that have 52px of height
margin-bottom: 52px;
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -208,6 +206,7 @@ limitations under the License.
align-items: center; align-items: center;
justify-content: left; justify-content: left;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
} }
.mx_CallView_header_callType { .mx_CallView_header_callType {

View file

@ -40,8 +40,6 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
aspect-ratio: 16 / 9;
} }
.mx_VideoFeed_video { .mx_VideoFeed_video {

View file

@ -20,6 +20,7 @@ limitations under the License.
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
aspect-ratio: 16 / 9;
} }
.mx_VideoFeed_video { .mx_VideoFeed_video {

View file

@ -1,7 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5151 20.0831L15.6941 17.2621L17.2621 15.6941L20.0831 18.5151C21.5741 20.0061 22.1529 21.7793 21.9661 21.9661C21.7793 22.1529 20.0061 21.5741 18.5151 20.0831Z" fill="#737D8C"/> <path d="m11.068 2c-0.32021 4.772e-4 -0.66852 0.17244-0.96484 0.46875-2.5464 2.5435-5.0905 5.0892-7.6348 7.6348-0.79016 0.7902-0.69302 1.9462 1.1641 1.9707 1.855 0.02447 3.4407-0.56671 3.8281-0.69141l2.4355 3.1445c-0.83503 1.9462-0.86902 4.062-0.058594 5.7949 0.47213 1.0095 1.79 1.0049 2.5781 0.2168l3.2773-3.2773 2.8223 2.8223c1.491 1.491 3.2644 2.0696 3.4512 1.8828s-0.39181-1.9602-1.8828-3.4512l-2.8223-2.8223 3.2773-3.2773c0.788-0.788 0.79075-2.106-0.21875-2.5781-1.733-0.81044-3.8468-0.77643-5.793 0.058594l-3.1445-2.4355c0.1247-0.38742 0.71588-1.9731 0.69141-3.8281-0.015311-1.1607-0.47217-1.6336-1.0059-1.6328z" fill="#737d8c"/>
<path d="M7.46196 11.3821C7.07677 11.5059 5.49073 12.0989 3.63366 12.0744C1.77658 12.0499 1.67795 10.8941 2.46811 10.1039L6.28598 6.28602L9.42196 9.42203L7.46196 11.3821Z" fill="#737D8C"/>
<path d="M11.3821 7.46202C11.5059 7.07682 12.0989 5.49077 12.0744 3.63368C12.0499 1.77658 10.8941 1.67795 10.1039 2.46812L6.28598 6.28602L9.42196 9.42203L11.3821 7.46202Z" fill="#737D8C"/>
<path d="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" fill="#737D8C"/>
<path d="M11.774 11.774C9.31114 14.2369 8.61779 17.7115 9.83827 20.3213C10.3104 21.3308 11.6288 21.3273 12.4169 20.5392L20.5391 12.4169C21.3271 11.6289 21.3307 10.3104 20.3212 9.83829C17.7114 8.61779 14.2369 9.31115 11.774 11.774Z" fill="#737D8C"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 744 B

Before After
Before After

View file

@ -8,9 +8,9 @@
/* Noto Color Emoji contains digits, in fixed-width, therefore causing /* Noto Color Emoji contains digits, in fixed-width, therefore causing
digits in flowed text to stand out. digits in flowed text to stand out.
TODO: Consider putting all emoji fonts to the end rather than the front. */ TODO: Consider putting all emoji fonts to the end rather than the front. */
$font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', 'Sans-Serif', 'Noto Color Emoji'; $font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', 'monospace', 'Noto Color Emoji'; $monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0 // Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
$system-light: #F4F6FA; $system-light: #F4F6FA;

View file

@ -8,9 +8,9 @@
/* Noto Color Emoji contains digits, in fixed-width, therefore causing /* Noto Color Emoji contains digits, in fixed-width, therefore causing
digits in flowed text to stand out. digits in flowed text to stand out.
TODO: Consider putting all emoji fonts to the end rather than the front. */ TODO: Consider putting all emoji fonts to the end rather than the front. */
$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', 'Sans-Serif', 'Noto Color Emoji'; $font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', 'monospace', 'Noto Color Emoji'; $monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0 // Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
$system-light: #F4F6FA; $system-light: #F4F6FA;

View file

@ -1,7 +1,8 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -60,7 +61,6 @@ import Modal from './Modal';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils'; import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import { Jitsi } from "./widgets/Jitsi"; import { Jitsi } from "./widgets/Jitsi";
import { WidgetType } from "./widgets/WidgetType"; import { WidgetType } from "./widgets/WidgetType";
@ -86,6 +86,9 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
import EventEmitter from 'events'; import EventEmitter from 'events';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom'; import { ensureDMExists, findDMForUser } from './createRoom';
import { IPushRule, RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
import { getIncomingCallToastKey } from './toasts/IncomingCallToast'; import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
import ToastStore from './stores/ToastStore'; import ToastStore from './stores/ToastStore';
import IncomingCallToast from "./toasts/IncomingCallToast"; import IncomingCallToast from "./toasts/IncomingCallToast";
@ -479,14 +482,28 @@ export default class CallHandler extends EventEmitter {
} }
switch (newState) { switch (newState) {
case CallState.Ringing: case CallState.Ringing: {
const incomingCallPushRule = (
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule
);
const pushRuleEnabled = incomingCallPushRule?.enabled;
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
action.set_tweak === TweakName.Sound &&
action.value === "ring"
));
if (pushRuleEnabled && tweakSetToRing) {
this.play(AudioID.Ring); this.play(AudioID.Ring);
} else {
this.silenceCall(call.callId);
}
break; break;
case CallState.InviteSent: }
case CallState.InviteSent: {
this.play(AudioID.Ringback); this.play(AudioID.Ringback);
break; break;
case CallState.Ended: }
{ case CallState.Ended: {
const hangupReason = call.hangupReason; const hangupReason = call.hangupReason;
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason); Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
this.removeCallForRoom(mappedRoomId); this.removeCallForRoom(mappedRoomId);
@ -1011,14 +1028,10 @@ export default class CallHandler extends EventEmitter {
// prevent double clicking the call button // prevent double clicking the call button
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
const hasJitsi = currentJitsiWidgets.length > 0 if (jitsiWidget) {
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI); // If there already is a Jitsi widget pin it
if (hasJitsi) { WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t('A call is currently being placed!'),
});
return; return;
} }

View file

@ -209,6 +209,14 @@ async function loadImageElement(imageFile: File) {
return { width, height, img }; return { width, height, img };
} }
// Minimum size for image files before we generate a thumbnail for them.
const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB
// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail.
const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB
const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
// We don't apply these thresholds to video thumbnails as a poster image is always useful
// and videos tend to be much larger.
/** /**
* Read the metadata for an image file and create and upload a thumbnail of the image. * Read the metadata for an image file and create and upload a thumbnail of the image.
* *
@ -217,23 +225,33 @@ async function loadImageElement(imageFile: File) {
* @param {File} imageFile The image to read and thumbnail. * @param {File} imageFile The image to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info. * @return {Promise} A promise that resolves with the attachment info.
*/ */
function infoForImageFile(matrixClient, roomId, imageFile) { async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) {
let thumbnailType = "image/png"; let thumbnailType = "image/png";
if (imageFile.type === "image/jpeg") { if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg"; thumbnailType = "image/jpeg";
} }
let imageInfo; const imageElement = await loadImageElement(imageFile);
return loadImageElement(imageFile).then((r) => {
return createThumbnail(r.img, r.width, r.height, thumbnailType); const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
}).then((result) => { const imageInfo = result.info;
imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail); // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
}).then((result) => { const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
imageInfo.thumbnail_url = result.url; if (
imageInfo.thumbnail_file = result.file; imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
) {
delete imageInfo["thumbnail_info"];
return imageInfo;
}
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
imageInfo["thumbnail_url"] = uploadResult.url;
imageInfo["thumbnail_file"] = uploadResult.file;
return imageInfo; return imageInfo;
});
} }
/** /**

View file

@ -38,17 +38,9 @@ function makePlaybackWaveform(input: number[]): number[] {
// First, convert negative amplitudes to positive so we don't detect zero as "noisy". // First, convert negative amplitudes to positive so we don't detect zero as "noisy".
const noiseWaveform = input.map(v => Math.abs(v)); const noiseWaveform = input.map(v => Math.abs(v));
// Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. // Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
// We also rescale the waveform to be 0-1 for the remaining function logic. // We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
// Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled
// waveform. Most speech happens below the 0.5 mark.
const filtered = resampled.map(v => clamp(v, 0.1, 0.5));
// Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something
// sensible. This is what we return to keep our contract of "values between zero and one".
return arrayRescale(filtered, 0, 1);
} }
export class Playback extends EventEmitter implements IDestroyable { export class Playback extends EventEmitter implements IDestroyable {

View file

@ -30,6 +30,7 @@ import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
import { uploadFile } from "../ContentMessages"; import { uploadFile } from "../ContentMessages";
import { FixedRollingArray } from "../utils/FixedRollingArray"; import { FixedRollingArray } from "../utils/FixedRollingArray";
import { clamp } from "../utils/numbers"; import { clamp } from "../utils/numbers";
import mxRecorderWorkletPath from "./RecorderWorklet";
const CHANNELS = 1; // stereo isn't important const CHANNELS = 1; // stereo isn't important
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
@ -113,16 +114,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
}); });
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
// Set up our worklet. We use this for timing information and waveform analysis: the
// web audio API prefers this be done async to avoid holding the main thread with math.
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
if (!mxRecorderWorkletPath) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("Unable to create recorder: no worklet script registered");
}
// Connect our inputs and outputs // Connect our inputs and outputs
if (this.recorderContext.audioWorklet) { if (this.recorderContext.audioWorklet) {
// Set up our worklet. We use this for timing information and waveform analysis: the
// web audio API prefers this be done async to avoid holding the main thread with math.
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
this.recorderSource.connect(this.recorderWorklet); this.recorderSource.connect(this.recorderWorklet);

View file

@ -222,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
if (inviteSender) { if (inviteSender) {
inviterSection = <div className="mx_SpaceRoomView_preview_inviter"> inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
<MemberAvatar member={inviter} width={32} height={32} /> <MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div> <div>
<div className="mx_SpaceRoomView_preview_inviter_name"> <div className="mx_SpaceRoomView_preview_inviter_name">
{ _t("<inviter/> invites you", {}, { { _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter.name || inviteSender }</b>, inviter: () => <b>{ inviter?.name || inviteSender }</b>,
}) } }) }
</div> </div>
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid"> { inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">

View file

@ -17,8 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample } from "../../../utils/arrays"; import { arrayFastResample, arraySeed } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers";
import Waveform from "./Waveform"; import Waveform from "./Waveform";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
waveform: [], waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
}; };
} }
componentDidMount() { componentDidMount() {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => { this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); // The incoming data is between zero and one, so we don't need to clamp/rescale it.
// The incoming data is between zero and one, but typically even screaming into a this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
// microphone won't send you over 0.6, so we artificially adjust the gain for the
// waveform. This results in a slightly more cinematic/animated waveform for the
// user.
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
this.scheduledUpdate.mark(); this.scheduledUpdate.mark();
}); });
} }

View file

@ -25,7 +25,6 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip'; import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -117,14 +116,12 @@ export default class CallEvent extends React.Component<IProps, IState> {
if (state === CallState.Ended) { if (state === CallState.Ended) {
const hangupReason = this.props.callEventGrouper.hangupReason; const hangupReason = this.props.callEventGrouper.hangupReason;
const gotRejected = this.props.callEventGrouper.gotRejected; const gotRejected = this.props.callEventGrouper.gotRejected;
const rejectParty = this.props.callEventGrouper.rejectParty;
if (gotRejected) { if (gotRejected) {
const weDeclinedCall = MatrixClientPeg.get().getUserId() === rejectParty;
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ weDeclinedCall ? _t("You declined this call") : _t("They declined this call") } { _t("Call declined") }
{ this.renderCallBackButton(weDeclinedCall ? _t("Call back") : _t("Call again")) } { this.renderCallBackButton(_t("Call back")) }
</div> </div>
); );
} else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) { } else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) {
@ -136,14 +133,14 @@ export default class CallEvent extends React.Component<IProps, IState> {
// Also, if we don't have a reason // Also, if we don't have a reason
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ _t("This call has ended") } { _t("Call ended") }
</div> </div>
); );
} else if (hangupReason === CallErrorCode.InviteTimeout) { } else if (hangupReason === CallErrorCode.InviteTimeout) {
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ _t("They didn't pick up") } { _t("Missed call") }
{ this.renderCallBackButton(_t("Call again")) } { this.renderCallBackButton(_t("Call back")) }
</div> </div>
); );
} }
@ -176,7 +173,8 @@ export default class CallEvent extends React.Component<IProps, IState> {
className="mx_CallEvent_content_tooltip" className="mx_CallEvent_content_tooltip"
kind={InfoTooltipKind.Warning} kind={InfoTooltipKind.Warning}
/> />
{ _t("This call has failed") } { _t("Connection failed") }
{ this.renderCallBackButton(_t("Retry")) }
</div> </div>
); );
} }
@ -190,7 +188,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
if (state === CustomCallState.Missed) { if (state === CustomCallState.Missed) {
return ( return (
<div className="mx_CallEvent_content"> <div className="mx_CallEvent_content">
{ _t("You missed this call") } { _t("Missed call") }
{ this.renderCallBackButton(_t("Call back")) } { this.renderCallBackButton(_t("Call back")) }
</div> </div>
); );

View file

@ -55,6 +55,14 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
const IS_MAC = navigator.platform.indexOf("Mac") !== -1; const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
["(", ")"],
["[", "]"],
["{", "}"],
["<", ">"],
]);
function ctrlShortcutLabel(key: string): string { function ctrlShortcutLabel(key: string): string {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key; return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
} }
@ -99,6 +107,7 @@ interface IState {
showVisualBell?: boolean; showVisualBell?: boolean;
autoComplete?: AutocompleteWrapperModel; autoComplete?: AutocompleteWrapperModel;
completionIndex?: number; completionIndex?: number;
surroundWith: boolean;
} }
@replaceableComponent("views.rooms.BasicMessageEditor") @replaceableComponent("views.rooms.BasicMessageEditor")
@ -117,12 +126,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private readonly emoticonSettingHandle: string; private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string; private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string;
private readonly historyManager = new HistoryManager(); private readonly historyManager = new HistoryManager();
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"), showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
}; };
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
@ -130,6 +141,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.configureEmoticonAutoReplace(); this.configureEmoticonAutoReplace();
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null, this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this.configureShouldShowPillAvatar); this.configureShouldShowPillAvatar);
this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
this.surroundWithSettingChanged);
} }
public componentDidUpdate(prevProps: IProps) { public componentDidUpdate(prevProps: IProps) {
@ -422,6 +435,28 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private onKeyDown = (event: React.KeyboardEvent): void => { private onKeyDown = (event: React.KeyboardEvent): void => {
const model = this.props.model; const model = this.props.model;
let handled = false; let handled = false;
if (this.state.surroundWith && document.getSelection().type != "Caret") {
// This surrounds the selected text with a character. This is
// intentionally left out of the keybinding manager as the keybinds
// here shouldn't be changeable
const selectionRange = getRangeForSelection(
this.editorRef.current,
this.props.model,
document.getSelection(),
);
// trim the range as we want it to exclude leading/trailing spaces
selectionRange.trim();
if ([...SURROUND_WITH_DOUBLE_CHARACTERS.keys(), ...SURROUND_WITH_CHARACTERS].includes(event.key)) {
this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true;
toggleInlineFormat(selectionRange, event.key, SURROUND_WITH_DOUBLE_CHARACTERS.get(event.key));
handled = true;
}
}
const action = getKeyBindingsManager().getMessageComposerAction(event); const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) { switch (action) {
case MessageComposerAction.FormatBold: case MessageComposerAction.FormatBold:
@ -574,6 +609,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ showPillAvatar }); this.setState({ showPillAvatar });
}; };
private surroundWithSettingChanged = () => {
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
this.setState({ surroundWith });
};
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange); document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("input", this.onInput, true);
@ -581,6 +621,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true); this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
SettingsStore.unwatchSetting(this.emoticonSettingHandle); SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle); SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
SettingsStore.unwatchSetting(this.surroundWithHandle);
} }
componentDidMount() { componentDidMount() {

View file

@ -17,10 +17,7 @@ limitations under the License.
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
RecordingState,
VoiceRecording,
} from "../../../audio/VoiceRecording";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import classNames from "classnames"; import classNames from "classnames";
@ -34,6 +31,11 @@ import { MsgType } from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
import NotificationBadge from "./NotificationBadge";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import InlineSpinner from "../elements/InlineSpinner";
import { PlaybackManager } from "../../../audio/PlaybackManager";
interface IProps { interface IProps {
room: Room; room: Room;
@ -42,6 +44,7 @@ interface IProps {
interface IState { interface IState {
recorder?: VoiceRecording; recorder?: VoiceRecording;
recordingPhase?: RecordingState; recordingPhase?: RecordingState;
didUploadFail?: boolean;
} }
/** /**
@ -69,9 +72,19 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await this.state.recorder.stop(); await this.state.recorder.stop();
let upload: IUpload;
try { try {
const upload = await this.state.recorder.upload(this.props.room.roomId); upload = await this.state.recorder.upload(this.props.room.roomId);
} catch (e) {
console.error("Error uploading voice message:", e);
// Flag error and move on. The recording phase will be reset by the upload function.
this.setState({ didUploadFail: true });
return; // don't dispose the recording: the user has a chance to re-upload
}
try {
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued. // noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
MatrixClientPeg.get().sendMessage(this.props.room.roomId, { MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message", "body": "Voice message",
@ -104,12 +117,11 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
}); });
} catch (e) { } catch (e) {
console.error("Error sending/uploading voice message:", e); console.error("Error sending voice message:", e);
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
title: _t('Upload Failed'), // Voice message should be in the timeline at this point, so let other things take care
description: _t("The voice message failed to upload."), // of error handling. We also shouldn't need the recording anymore, so fall through to
}); // disposal.
return; // don't dispose the recording so the user can retry, maybe
} }
await this.disposeRecording(); await this.disposeRecording();
} }
@ -118,7 +130,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await VoiceRecordingStore.instance.disposeRecording(); await VoiceRecordingStore.instance.disposeRecording();
// Reset back to no recording, which means no phase (ie: restart component entirely) // Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({ recorder: null, recordingPhase: null }); this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
} }
private onCancel = async () => { private onCancel = async () => {
@ -166,6 +178,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
} }
try { try {
// stop any noises which might be happening
await PlaybackManager.instance.playOnly(null);
const recorder = VoiceRecordingStore.instance.startRecording(); const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start(); await recorder.start();
@ -209,9 +224,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording, 'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
}); });
let tooltip = _t("Record a voice message"); let tooltip = _t("Send voice message");
if (!!this.state.recorder) { if (!!this.state.recorder) {
tooltip = _t("Stop the recording"); tooltip = _t("Stop recording");
} }
let stopOrRecordBtn = <AccessibleTooltipButton let stopOrRecordBtn = <AccessibleTooltipButton
@ -229,12 +244,30 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) { if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
deleteButton = <AccessibleTooltipButton deleteButton = <AccessibleTooltipButton
className='mx_VoiceRecordComposerTile_delete' className='mx_VoiceRecordComposerTile_delete'
title={_t("Delete recording")} title={_t("Delete")}
onClick={this.onCancel} onClick={this.onCancel}
/>; />;
} }
let uploadIndicator;
if (this.state.recordingPhase === RecordingState.Uploading) {
uploadIndicator = <span className='mx_VoiceRecordComposerTile_uploadingState'>
<InlineSpinner w={16} h={16} />
</span>;
} else if (this.state.didUploadFail && this.state.recordingPhase === RecordingState.Ended) {
uploadIndicator = <span className='mx_VoiceRecordComposerTile_failedState'>
<span className='mx_VoiceRecordComposerTile_uploadState_badge'>
{ /* Need to stick the badge in a span to ensure it doesn't create a block component */ }
<NotificationBadge
notification={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
/>
</span>
<span className='text-warning'>{ _t("Failed to send") }</span>
</span>;
}
return (<> return (<>
{ uploadIndicator }
{ deleteButton } { deleteButton }
{ this.renderWaveformArea() } { this.renderWaveformArea() }
{ recordingInfo } { recordingInfo }

View file

@ -157,6 +157,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
'MessageComposerInput.suggestEmoji', 'MessageComposerInput.suggestEmoji',
'sendTypingNotifications', 'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend', 'MessageComposerInput.ctrlEnterToSend',
'MessageComposerInput.surroundWith',
'MessageComposerInput.showStickersButton', 'MessageComposerInput.showStickersButton',
]; ];

View file

@ -36,7 +36,7 @@ const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232; const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2; const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.05; const SNAPPING_AMT = 0.1;
const PADDING = { const PADDING = {
top: 58, top: 58,

View file

@ -23,11 +23,16 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import VideoFeed from './VideoFeed'; import VideoFeed from './VideoFeed';
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import { CallState, CallType, MatrixCall, CallEvent } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent, CallState, CallType, MatrixCall } 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';
import { alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton } from '../../structures/ContextMenu'; import {
alwaysAboveLeftOf,
alwaysAboveRightOf,
ChevronFace,
ContextMenuTooltipButton,
} from '../../structures/ContextMenu';
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';
@ -37,6 +42,8 @@ import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes'; import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import CallViewSidebar from './CallViewSidebar'; import CallViewSidebar from './CallViewSidebar';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Alignment } from "../elements/Tooltip";
interface IProps { interface IProps {
// The call for us to display // The call for us to display
@ -75,6 +82,8 @@ interface IState {
sidebarShown: boolean; sidebarShown: boolean;
} }
const tooltipYOffset = -24;
function getFullScreenElement() { function getFullScreenElement() {
return ( return (
document.fullscreenElement || document.fullscreenElement ||
@ -115,7 +124,6 @@ export default class CallView extends React.Component<IProps, IState> {
private controlsHideTimer: number = null; private controlsHideTimer: number = null;
private dialpadButton = createRef<HTMLDivElement>(); private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>(); private contextMenuButton = createRef<HTMLDivElement>();
private contextMenu = createRef<HTMLDivElement>();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -479,9 +487,12 @@ export default class CallView extends React.Component<IProps, IState> {
let vidMuteButton; let vidMuteButton;
if (this.props.call.type === CallType.Video) { if (this.props.call.type === CallType.Video) {
vidMuteButton = ( vidMuteButton = (
<AccessibleButton <AccessibleTooltipButton
className={vidClasses} className={vidClasses}
onClick={this.onVidMuteClick} onClick={this.onVidMuteClick}
title={this.state.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
} }
@ -496,9 +507,15 @@ export default class CallView extends React.Component<IProps, IState> {
this.props.call.state === CallState.Connected this.props.call.state === CallState.Connected
) { ) {
screensharingButton = ( screensharingButton = (
<AccessibleButton <AccessibleTooltipButton
className={screensharingClasses} className={screensharingClasses}
onClick={this.onScreenshareClick} onClick={this.onScreenshareClick}
title={this.state.screensharing
? _t("Stop sharing your screen")
: _t("Start sharing your screen")
}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
} }
@ -518,6 +535,7 @@ export default class CallView extends React.Component<IProps, IState> {
<AccessibleButton <AccessibleButton
className={sidebarButtonClasses} className={sidebarButtonClasses}
onClick={this.onToggleSidebar} onClick={this.onToggleSidebar}
aria-label={this.state.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
/> />
); );
} }
@ -526,22 +544,28 @@ export default class CallView extends React.Component<IProps, IState> {
let contextMenuButton; let contextMenuButton;
if (this.state.callState === CallState.Connected) { if (this.state.callState === CallState.Connected) {
contextMenuButton = ( contextMenuButton = (
<ContextMenuButton <ContextMenuTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more" className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick} onClick={this.onMoreClick}
inputRef={this.contextMenuButton} inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu} isExpanded={this.state.showMoreMenu}
title={_t("More")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
} }
let dialpadButton; let dialpadButton;
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) { if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
dialpadButton = ( dialpadButton = (
<ContextMenuButton <ContextMenuTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad" className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton} inputRef={this.dialpadButton}
onClick={this.onDialpadClick} onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad} isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
); );
} }
@ -554,7 +578,11 @@ export default class CallView extends React.Component<IProps, IState> {
ChevronFace.None, ChevronFace.None,
CONTEXT_MENU_VPADDING, CONTEXT_MENU_VPADDING,
)} )}
mountAsChild={true} // We mount the context menus as a as a child typically in order to include the
// context menus when fullscreening the call content.
// However, this does not work as well when the call is embedded in a
// picture-in-picture frame. Thus, only mount as child when we are *not* in PiP.
mountAsChild={!this.props.pipMode}
onFinished={this.closeDialpad} onFinished={this.closeDialpad}
call={this.props.call} call={this.props.call}
/>; />;
@ -568,7 +596,7 @@ export default class CallView extends React.Component<IProps, IState> {
ChevronFace.None, ChevronFace.None,
CONTEXT_MENU_VPADDING, CONTEXT_MENU_VPADDING,
)} )}
mountAsChild={true} mountAsChild={!this.props.pipMode}
onFinished={this.closeContextMenu} onFinished={this.closeContextMenu}
call={this.props.call} call={this.props.call}
/>; />;
@ -583,9 +611,12 @@ export default class CallView extends React.Component<IProps, IState> {
{ dialPad } { dialPad }
{ contextMenu } { contextMenu }
{ dialpadButton } { dialpadButton }
<AccessibleButton <AccessibleTooltipButton
className={micClasses} className={micClasses}
onClick={this.onMicMuteClick} onClick={this.onMicMuteClick}
title={this.state.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
{ vidMuteButton } { vidMuteButton }
<div className={micCacheClasses} /> <div className={micCacheClasses} />
@ -593,9 +624,12 @@ export default class CallView extends React.Component<IProps, IState> {
{ screensharingButton } { screensharingButton }
{ sidebarButton } { sidebarButton }
{ contextMenuButton } { contextMenuButton }
<AccessibleButton <AccessibleTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup" className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={this.onHangupClick} onClick={this.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/> />
</div> </div>
); );
@ -820,7 +854,7 @@ export default class CallView extends React.Component<IProps, IState> {
let fullScreenButton; let fullScreenButton;
if (!this.props.pipMode) { if (!this.props.pipMode) {
fullScreenButton = ( fullScreenButton = (
<div <AccessibleTooltipButton
className="mx_CallView_header_button mx_CallView_header_button_fullscreen" className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick} onClick={this.onFullscreenClick}
title={_t("Fill Screen")} title={_t("Fill Screen")}
@ -830,7 +864,7 @@ export default class CallView extends React.Component<IProps, IState> {
let expandButton; let expandButton;
if (this.props.pipMode) { if (this.props.pipMode) {
expandButton = <div expandButton = <AccessibleTooltipButton
className="mx_CallView_header_button mx_CallView_header_button_expand" className="mx_CallView_header_button mx_CallView_header_button_expand"
onClick={this.onExpandClick} onClick={this.onExpandClick}
title={_t("Return to call")} title={_t("Return to call")}

View file

@ -35,6 +35,16 @@ export const EMOTICON_TO_EMOJI = new Map<string, IEmoji>();
export const getEmojiFromUnicode = unicode => UNICODE_TO_EMOJI.get(stripVariation(unicode)); export const getEmojiFromUnicode = unicode => UNICODE_TO_EMOJI.get(stripVariation(unicode));
const isRegionalIndicator = (x: string): boolean => {
// First verify that the string is a single character. We use Array.from
// to make sure we count by characters, not UTF-8 code units.
return Array.from(x).length === 1 &&
// Next verify that the character is within the code point range for
// regional indicators.
// http://unicode.org/charts/PDF/Unicode-6.0/U60-1F100.pdf
x >= '\u{1f1e6}' && x <= '\u{1f1ff}';
};
const EMOJIBASE_GROUP_ID_TO_CATEGORY = [ const EMOJIBASE_GROUP_ID_TO_CATEGORY = [
"people", // smileys "people", // smileys
"people", // actually people "people", // actually people
@ -72,7 +82,11 @@ export const EMOJI: IEmoji[] = EMOJIBASE.map((emojiData: Omit<IEmoji, "shortcode
shortcodes: typeof shortcodeData === "string" ? [shortcodeData] : shortcodeData, shortcodes: typeof shortcodeData === "string" ? [shortcodeData] : shortcodeData,
}; };
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group]; // We manually include regional indicators in the symbols group, since
// Emojibase intentionally leaves them uncategorized
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group] ??
(isRegionalIndicator(emoji.unicode) ? "symbols" : null);
if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) { if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) {
DATA_BY_CATEGORY[categoryId].push(emoji); DATA_BY_CATEGORY[categoryId].push(emoji);
} }

View file

@ -64,8 +64,6 @@
"Unable to transfer call": "Unable to transfer call", "Unable to transfer call": "Unable to transfer call",
"Transfer Failed": "Transfer Failed", "Transfer Failed": "Transfer Failed",
"Failed to transfer call": "Failed to transfer call", "Failed to transfer call": "Failed to transfer call",
"Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!",
"Permission Required": "Permission Required", "Permission Required": "Permission Required",
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room", "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
"End conference": "End conference", "End conference": "End conference",
@ -849,6 +847,7 @@
"Use Ctrl + F to search timeline": "Use Ctrl + F to search timeline", "Use Ctrl + F to search timeline": "Use Ctrl + F to search timeline",
"Use Command + Enter to send a message": "Use Command + Enter to send a message", "Use Command + Enter to send a message": "Use Command + Enter to send a message",
"Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message", "Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message",
"Surround selected text when typing special characters": "Surround selected text when typing special characters",
"Automatically replace plain text Emoji": "Automatically replace plain text Emoji", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
"Mirror local video feed": "Mirror local video feed", "Mirror local video feed": "Mirror local video feed",
"Enable Community Filter Panel": "Enable Community Filter Panel", "Enable Community Filter Panel": "Enable Community Filter Panel",
@ -905,6 +904,17 @@
"sends snowfall": "sends snowfall", "sends snowfall": "sends snowfall",
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect", "Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
"sends space invaders": "sends space invaders", "sends space invaders": "sends space invaders",
"Start the camera": "Start the camera",
"Stop the camera": "Stop the camera",
"Stop sharing your screen": "Stop sharing your screen",
"Start sharing your screen": "Start sharing your screen",
"Hide sidebar": "Hide sidebar",
"Show sidebar": "Show sidebar",
"More": "More",
"Dialpad": "Dialpad",
"Unmute the microphone": "Unmute the microphone",
"Mute the microphone": "Mute the microphone",
"Hangup": "Hangup",
"unknown person": "unknown person", "unknown person": "unknown person",
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>", "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</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>Switch</a>",
@ -1706,14 +1716,12 @@
"Invited by %(sender)s": "Invited by %(sender)s", "Invited by %(sender)s": "Invited by %(sender)s",
"Jump to first unread message.": "Jump to first unread message.", "Jump to first unread message.": "Jump to first unread message.",
"Mark all as read": "Mark all as read", "Mark all as read": "Mark all as read",
"The voice message failed to upload.": "The voice message failed to upload.",
"Unable to access your microphone": "Unable to access your microphone", "Unable to access your microphone": "Unable to access your microphone",
"We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
"No microphone found": "No microphone found", "No microphone found": "No microphone found",
"We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.", "We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.",
"Record a voice message": "Record a voice message", "Send voice message": "Send voice message",
"Stop the recording": "Stop the recording", "Stop recording": "Stop recording",
"Delete recording": "Delete recording",
"Error updating main address": "Error updating main address", "Error updating main address": "Error updating main address",
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
"There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",
@ -1874,19 +1882,15 @@
"Verification cancelled": "Verification cancelled", "Verification cancelled": "Verification cancelled",
"Compare emoji": "Compare emoji", "Compare emoji": "Compare emoji",
"Connected": "Connected", "Connected": "Connected",
"You declined this call": "You declined this call", "Call declined": "Call declined",
"They declined this call": "They declined this call",
"Call back": "Call back", "Call back": "Call back",
"Call again": "Call again", "Missed call": "Missed call",
"This call has ended": "This call has ended",
"They didn't pick up": "They didn't pick up",
"Could not connect media": "Could not connect media", "Could not connect media": "Could not connect media",
"Connection failed": "Connection failed", "Connection failed": "Connection failed",
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone", "Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
"An unknown error occurred": "An unknown error occurred", "An unknown error occurred": "An unknown error occurred",
"Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)", "Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)",
"This call has failed": "This call has failed", "Retry": "Retry",
"You missed this call": "You missed this call",
"The call is in an unknown state!": "The call is in an unknown state!", "The call is in an unknown state!": "The call is in an unknown state!",
"Sunday": "Sunday", "Sunday": "Sunday",
"Monday": "Monday", "Monday": "Monday",
@ -1909,7 +1913,6 @@
"Error processing audio message": "Error processing audio message", "Error processing audio message": "Error processing audio message",
"React": "React", "React": "React",
"Edit": "Edit", "Edit": "Edit",
"Retry": "Retry",
"Reply": "Reply", "Reply": "Reply",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Download %(text)s": "Download %(text)s", "Download %(text)s": "Download %(text)s",

View file

@ -449,6 +449,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: isMac ? _td("Use Command + Enter to send a message") : _td("Use Ctrl + Enter to send a message"), displayName: isMac ? _td("Use Command + Enter to send a message") : _td("Use Ctrl + Enter to send a message"),
default: false, default: false,
}, },
"MessageComposerInput.surroundWith": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Surround selected text when typing special characters"),
default: false,
},
"MessageComposerInput.autoReplaceEmoji": { "MessageComposerInput.autoReplaceEmoji": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Automatically replace plain text Emoji'), displayName: _td('Automatically replace plain text Emoji'),

View file

@ -171,15 +171,10 @@ export async function setTheme(theme) {
// look for the stylesheet elements. // look for the stylesheet elements.
// styleElements is a map from style name to HTMLLinkElement. // styleElements is a map from style name to HTMLLinkElement.
const styleElements = Object.create(null); const styleElements = Object.create(null);
let a; const themes = Array.from(document.querySelectorAll('[data-mx-theme]'));
for (let i = 0; (a = document.getElementsByTagName("link")[i]); i++) { themes.forEach(theme => {
const href = a.getAttribute("href"); styleElements[theme.attributes['data-mx-theme'].value.toLowerCase()] = theme;
// shouldn't we be using the 'title' tag rather than the href? });
const match = href && href.match(/^bundles\/.*\/theme-(.*)\.css$/);
if (match) {
styleElements[match[1]] = a;
}
}
if (!(stylesheetName in styleElements)) { if (!(stylesheetName in styleElements)) {
throw new Error("Unknown theme " + stylesheetName); throw new Error("Unknown theme " + stylesheetName);

View file

@ -45,7 +45,7 @@ export default class IncomingCallToast extends React.Component<IProps, IState> {
super(props); super(props);
this.state = { this.state = {
silenced: false, silenced: CallHandler.sharedInstance().isCallSilenced(this.props.call.callId),
}; };
} }

View file

@ -43,9 +43,8 @@ function getManagedIframe(): { iframe: HTMLIFrameElement, onLoadPromise: Promise
// Dev note: the reassignment warnings are entirely incorrect here. // Dev note: the reassignment warnings are entirely incorrect here.
// @ts-ignore managedIframe.style.display = "none";
// noinspection JSConstantReassignment
managedIframe.style = { display: "none" };
// @ts-ignore // @ts-ignore
// noinspection JSConstantReassignment // noinspection JSConstantReassignment
managedIframe.sandbox = "allow-scripts allow-downloads allow-downloads-without-user-activation"; managedIframe.sandbox = "allow-scripts allow-downloads allow-downloads-without-user-activation";

View file

@ -96,6 +96,7 @@ export function createTestClient() {
getItem: jest.fn(), getItem: jest.fn(),
}, },
}, },
pushRules: {},
decryptEventIfNeeded: () => Promise.resolve(), decryptEventIfNeeded: () => Promise.resolve(),
isUserIgnored: jest.fn().mockReturnValue(false), isUserIgnored: jest.fn().mockReturnValue(false),
getCapabilities: jest.fn().mockResolvedValue({}), getCapabilities: jest.fn().mockResolvedValue({}),