Merge remote-tracking branch 'upstream/develop' into compact-reply-rendering

pull/21833/head
Šimon Brandner 2021-07-13 13:21:27 +02:00
commit 0bbdfefd0d
No known key found for this signature in database
GPG Key ID: 9760693FDD98A790
63 changed files with 1126 additions and 485 deletions

6
__mocks__/FontManager.js Normal file
View File

@ -0,0 +1,6 @@
// Stub out FontManager for tests as it doesn't validate anything we don't already know given
// our fixed test environment and it requires the installation of node-canvas.
module.exports = {
fixupColorFonts: () => Promise.resolve(),
};

View File

@ -126,6 +126,7 @@
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/commonmark": "^0.27.4", "@types/commonmark": "^0.27.4",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/css-font-loading-module": "^0.0.6",
"@types/diff-match-patch": "^1.0.32", "@types/diff-match-patch": "^1.0.32",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",

View File

@ -202,6 +202,7 @@
@import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_GroupLayout.scss";
@import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_IRCLayout.scss";
@import "./views/rooms/_JumpToBottomButton.scss"; @import "./views/rooms/_JumpToBottomButton.scss";
@import "./views/rooms/_LinkPreviewGroup.scss";
@import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_LinkPreviewWidget.scss";
@import "./views/rooms/_MemberInfo.scss"; @import "./views/rooms/_MemberInfo.scss";
@import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MemberList.scss";
@ -263,6 +264,7 @@
@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_CallViewForRoom.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadContextMenu.scss";
@import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_DialPadModal.scss";

View File

@ -72,7 +72,7 @@ limitations under the License.
.mx_AccessibleButton_kind_danger_outline { .mx_AccessibleButton_kind_danger_outline {
color: $button-danger-bg-color; color: $button-danger-bg-color;
background-color: $button-secondary-bg-color; background-color: transparent;
border: 1px solid $button-danger-bg-color; border: 1px solid $button-danger-bg-color;
} }

View File

@ -0,0 +1,38 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_LinkPreviewGroup {
.mx_LinkPreviewGroup_hide {
cursor: pointer;
width: 18px;
height: 18px;
img {
flex: 0 0 40px;
visibility: hidden;
}
}
&:hover .mx_LinkPreviewGroup_hide img,
.mx_LinkPreviewGroup_hide.focus-visible:focus img {
visibility: visible;
}
> .mx_AccessibleButton {
color: $accent-color;
text-align: center;
}
}

View File

@ -33,38 +33,29 @@ limitations under the License.
.mx_LinkPreviewWidget_caption { .mx_LinkPreviewWidget_caption {
margin-left: 15px; margin-left: 15px;
flex: 1 1 auto; flex: 1 1 auto;
overflow-x: hidden; // cause it to wrap rather than clip
} }
.mx_LinkPreviewWidget_title { .mx_LinkPreviewWidget_title {
display: inline;
font-weight: bold; font-weight: bold;
white-space: normal; white-space: normal;
} display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.mx_LinkPreviewWidget_siteName { .mx_LinkPreviewWidget_siteName {
display: inline; font-weight: normal;
}
} }
.mx_LinkPreviewWidget_description { .mx_LinkPreviewWidget_description {
margin-top: 8px; margin-top: 8px;
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
} display: -webkit-box;
-webkit-line-clamp: 3;
.mx_LinkPreviewWidget_cancel { -webkit-box-orient: vertical;
cursor: pointer;
width: 18px;
height: 18px;
img {
flex: 0 0 40px;
visibility: hidden;
}
}
.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img,
.mx_LinkPreviewWidget_cancel.focus-visible:focus img {
visibility: visible;
} }
.mx_MatrixChat_useCompactLayout { .mx_MatrixChat_useCompactLayout {

View File

@ -30,8 +30,8 @@ limitations under the License.
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; cursor: pointer;
.mx_CallView_video { .mx_VideoFeed_remote.mx_VideoFeed_voice {
width: 350px; min-height: 150px;
} }
.mx_VideoFeed_local { .mx_VideoFeed_local {

View File

@ -0,0 +1,21 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallPreview {
position: fixed;
left: 0;
top: 0;
}

View File

@ -39,7 +39,6 @@ limitations under the License.
.mx_CallView_pip { .mx_CallView_pip {
width: 320px; width: 320px;
padding-bottom: 8px; padding-bottom: 8px;
margin-top: 10px;
background-color: $voipcall-plinth-color; background-color: $voipcall-plinth-color;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
border-radius: 8px; border-radius: 8px;

View File

@ -15,8 +15,6 @@ limitations under the License.
*/ */
.mx_VideoFeed_voice { .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; background-color: $inverted-bg-color;
} }

View File

@ -15,7 +15,9 @@ limitations under the License.
*/ */
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
import * as ModernizrStatic from "modernizr"; // Load types for the WG CSS Font Loading APIs https://github.com/Microsoft/TypeScript/issues/13569
import "@types/css-font-loading-module";
import "@types/modernizr";
import ContentMessages from "../ContentMessages"; import ContentMessages from "../ContentMessages";
import { IMatrixClientPeg } from "../MatrixClientPeg"; import { IMatrixClientPeg } from "../MatrixClientPeg";
@ -50,7 +52,6 @@ import { RoomScrollStateStore } from "../stores/RoomScrollStateStore";
declare global { declare global {
interface Window { interface Window {
Modernizr: ModernizrStatic;
matrixChat: ReturnType<Renderer>; matrixChat: ReturnType<Renderer>;
mxMatrixClientPeg: IMatrixClientPeg; mxMatrixClientPeg: IMatrixClientPeg;
Olm: { Olm: {

View File

@ -569,7 +569,7 @@ export default class ContentMessages {
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload }); dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
// Focus the composer view // Focus the composer view
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
function onProgress(ev) { function onProgress(ev) {
upload.total = ev.total; upload.total = ev.total;

View File

@ -160,7 +160,8 @@ export default class DeviceListener {
// which result in account data changes affecting checks below. // which result in account data changes affecting checks below.
if ( if (
ev.getType().startsWith('m.secret_storage.') || ev.getType().startsWith('m.secret_storage.') ||
ev.getType().startsWith('m.cross_signing.') ev.getType().startsWith('m.cross_signing.') ||
ev.getType() === 'm.megolm_backup.v1'
) { ) {
this._recheck(); this._recheck();
} }

View File

@ -60,6 +60,8 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
/* /*
* Return true if the given string contains emoji * Return true if the given string contains emoji
* Uses a much, much simpler regex than emojibase's so will give false * Uses a much, much simpler regex than emojibase's so will give false
@ -176,18 +178,31 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
return { tagName, attribs }; return { tagName, attribs };
}, },
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) { 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
let src = attribs.src;
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // we don't want to allow images with `https?` `src`s.
// We also drop inline images (as if they were not present at all) when the "show // We also drop inline images (as if they were not present at all) when the "show
// images" preference is disabled. Future work might expose some UI to reveal them // images" preference is disabled. Future work might expose some UI to reveal them
// like standalone image events have. // like standalone image events have.
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { if (!src || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {} }; return { tagName, attribs: {} };
} }
if (!src.startsWith("mxc://")) {
const match = MEDIA_API_MXC_REGEX.exec(src);
if (match) {
src = `mxc://${match[1]}/${match[2]}`;
}
}
if (!src.startsWith("mxc://")) {
return { tagName, attribs: {} };
}
const width = Number(attribs.width) || 800; const width = Number(attribs.width) || 800;
const height = Number(attribs.height) || 600; const height = Number(attribs.height) || 600;
attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height); attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height);
return { tagName, attribs }; return { tagName, attribs };
}, },
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {

View File

@ -20,12 +20,15 @@ import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix"; import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events'; import EventEmitter from 'events';
interface IMediaDevices { // XXX: MediaDeviceKind is a union type, so we make our own enum
audioOutput: Array<MediaDeviceInfo>; export enum MediaDeviceKindEnum {
audioInput: Array<MediaDeviceInfo>; AudioOutput = "audiooutput",
videoInput: Array<MediaDeviceInfo>; AudioInput = "audioinput",
VideoInput = "videoinput",
} }
export type IMediaDevices = Record<MediaDeviceKindEnum, Array<MediaDeviceInfo>>;
export enum MediaDeviceHandlerEvent { export enum MediaDeviceHandlerEvent {
AudioOutputChanged = "audio_output_changed", AudioOutputChanged = "audio_output_changed",
} }
@ -51,20 +54,14 @@ export default class MediaDeviceHandler extends EventEmitter {
try { try {
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
const output = {
[MediaDeviceKindEnum.AudioOutput]: [],
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
};
const audioOutput = []; devices.forEach((device) => output[device.kind].push(device));
const audioInput = []; return output;
const videoInput = [];
devices.forEach((device) => {
switch (device.kind) {
case 'audiooutput': audioOutput.push(device); break;
case 'audioinput': audioInput.push(device); break;
case 'videoinput': videoInput.push(device); break;
}
});
return { audioOutput, audioInput, videoInput };
} catch (error) { } catch (error) {
console.warn('Unable to refresh WebRTC Devices: ', error); console.warn('Unable to refresh WebRTC Devices: ', error);
} }
@ -106,6 +103,14 @@ export default class MediaDeviceHandler extends EventEmitter {
setMatrixCallVideoInput(deviceId); setMatrixCallVideoInput(deviceId);
} }
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {
switch (kind) {
case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break;
case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break;
case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break;
}
}
public static getAudioOutput(): string { public static getAudioOutput(): string {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput"); return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
} }

View File

@ -17,6 +17,7 @@ limitations under the License.
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 AliasCustomisations from './customisations/Alias';
/** /**
* Given a room object, return the alias we should use for it, * Given a room object, return the alias we should use for it,
@ -28,7 +29,18 @@ import { MatrixClientPeg } from './MatrixClientPeg';
* @returns {string} A display alias for the given room * @returns {string} A display alias for the given room
*/ */
export function getDisplayAliasForRoom(room: Room): string { export function getDisplayAliasForRoom(room: Room): string {
return room.getCanonicalAlias() || room.getAltAliases()[0]; return getDisplayAliasForAliasSet(
room.getCanonicalAlias(), room.getAltAliases(),
);
}
// The various display alias getters should all feed through this one path so
// there's a single place to change the logic.
export function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
if (AliasCustomisations.getDisplayAliasForAliasSet) {
return AliasCustomisations.getDisplayAliasForAliasSet(canonicalAlias, altAliases);
}
return canonicalAlias || altAliases?.[0];
} }
export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean { export function looksLikeDirectMessageRoom(room: Room, myUserId: string): boolean {

View File

@ -447,7 +447,8 @@ function textForPowerEvent(event): () => string | null {
!event.getContent() || !event.getContent().users) { !event.getContent() || !event.getContent().users) {
return null; return null;
} }
const userDefault = event.getContent().users_default || 0; const previousUserDefault = event.getPrevContent().users_default || 0;
const currentUserDefault = event.getContent().users_default || 0;
// Construct set of userIds // Construct set of userIds
const users = []; const users = [];
Object.keys(event.getContent().users).forEach( Object.keys(event.getContent().users).forEach(
@ -463,9 +464,16 @@ function textForPowerEvent(event): () => string | null {
const diffs = []; const diffs = [];
users.forEach((userId) => { users.forEach((userId) => {
// Previous power level // Previous power level
const from = event.getPrevContent().users[userId]; let from = event.getPrevContent().users[userId];
if (!Number.isInteger(from)) {
from = previousUserDefault;
}
// Current power level // Current power level
const to = event.getContent().users[userId]; let to = event.getContent().users[userId];
if (!Number.isInteger(to)) {
to = currentUserDefault;
}
if (from === previousUserDefault && to === currentUserDefault) { return; }
if (to !== from) { if (to !== from) {
diffs.push({ userId, from, to }); diffs.push({ userId, from, to });
} }
@ -479,8 +487,8 @@ function textForPowerEvent(event): () => string | null {
powerLevelDiffText: diffs.map(diff => powerLevelDiffText: diffs.map(diff =>
_t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', {
userId: diff.userId, userId: diff.userId,
fromPowerLevel: Roles.textualPowerLevel(diff.from, userDefault), fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault),
toPowerLevel: Roles.textualPowerLevel(diff.to, userDefault), toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault),
}), }),
).join(", "), ).join(", "),
}); });

View File

@ -398,7 +398,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// refocusing during a paste event will make the // refocusing during a paste event will make the
// paste end up in the newly focused element, // paste end up in the newly focused element,
// so dispatch synchronously before paste happens // so dispatch synchronously before paste happens
dis.fire(Action.FocusComposer, true); dis.fire(Action.FocusSendMessageComposer, true);
} }
}; };
@ -552,7 +552,7 @@ class LoggedInView extends React.Component<IProps, IState> {
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input // synchronous dispatch so we focus before key generates input
dis.fire(Action.FocusComposer, true); dis.fire(Action.FocusSendMessageComposer, true);
ev.stopPropagation(); ev.stopPropagation();
// we should *not* preventDefault() here as // we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer // that would prevent typing in the now-focussed composer

View File

@ -443,7 +443,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
CountlyAnalytics.instance.trackPageChange(durationMs); CountlyAnalytics.instance.trackPageChange(durationMs);
} }
if (this.focusComposer) { if (this.focusComposer) {
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
this.focusComposer = false; this.focusComposer = false;
} }
} }
@ -1427,7 +1427,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
showNotificationsToast(false); showNotificationsToast(false);
} }
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
this.setState({ this.setState({
ready: true, ready: true,
}); });

View File

@ -44,10 +44,14 @@ import NetworkDropdown from "../views/directory/NetworkDropdown";
import ScrollPanel from "./ScrollPanel"; import ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { getDisplayAliasForAliasSet } from "../../Rooms";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800; const MAX_TOPIC_LENGTH = 800;
const LAST_SERVER_KEY = "mx_last_room_directory_server";
const LAST_INSTANCE_KEY = "mx_last_room_directory_instance";
function track(action: string) { function track(action: string) {
Analytics.trackEvent('RoomDirectory', action); Analytics.trackEvent('RoomDirectory', action);
} }
@ -61,7 +65,7 @@ interface IState {
loading: boolean; loading: boolean;
protocolsLoading: boolean; protocolsLoading: boolean;
error?: string; error?: string;
instanceId: string | symbol; instanceId: string;
roomServer: string; roomServer: string;
filterString: string; filterString: string;
selectedCommunityId?: string; selectedCommunityId?: string;
@ -116,6 +120,36 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
} else if (!selectedCommunityId) { } else if (!selectedCommunityId) {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response; this.protocols = response;
const myHomeserver = MatrixClientPeg.getHomeserverName();
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
let roomServer = myHomeserver;
if (
SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) ||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
) {
roomServer = lsRoomServer;
}
let instanceId: string = null;
if (roomServer === myHomeserver && (
lsInstanceId === ALL_ROOMS ||
Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
)) {
instanceId = lsInstanceId;
}
// Refresh the room list only if validation failed and we had to change these
if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) {
this.setState({
protocolsLoading: false,
instanceId,
roomServer,
});
this.refreshRoomList();
return;
}
this.setState({ protocolsLoading: false }); this.setState({ protocolsLoading: false });
}, (err) => { }, (err) => {
console.warn(`error loading third party protocols: ${err}`); console.warn(`error loading third party protocols: ${err}`);
@ -150,8 +184,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
publicRooms: [], publicRooms: [],
loading: true, loading: true,
error: null, error: null,
instanceId: undefined, instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
roomServer: MatrixClientPeg.getHomeserverName(), roomServer: localStorage.getItem(LAST_SERVER_KEY),
filterString: this.props.initialText || "", filterString: this.props.initialText || "",
selectedCommunityId, selectedCommunityId,
communityName: null, communityName: null,
@ -342,7 +376,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
} }
}; };
private onOptionChange = (server: string, instanceId?: string | symbol) => { private onOptionChange = (server: string, instanceId?: string) => {
// clear next batch so we don't try to load more rooms // clear next batch so we don't try to load more rooms
this.nextBatch = null; this.nextBatch = null;
this.setState({ this.setState({
@ -360,6 +394,14 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// find the five gitter ones, at which point we do not want // find the five gitter ones, at which point we do not want
// to render all those rooms when switching back to 'all networks'. // to render all those rooms when switching back to 'all networks'.
// Easiest to just blow away the state & re-fetch. // Easiest to just blow away the state & re-fetch.
// We have to be careful here so that we don't set instanceId = "undefined"
localStorage.setItem(LAST_SERVER_KEY, server);
if (instanceId) {
localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
} else {
localStorage.removeItem(LAST_INSTANCE_KEY);
}
}; };
private onFillRequest = (backwards: boolean) => { private onFillRequest = (backwards: boolean) => {
@ -813,5 +855,5 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list // but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: IRoom) { function getDisplayAliasForRoom(room: IRoom) {
return room.canonical_alias || room.aliases?.[0] || ""; return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
} }

View File

@ -131,7 +131,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
switch (action) { switch (action) {
case RoomListAction.ClearSearch: case RoomListAction.ClearSearch:
this.clearInput(); this.clearInput();
defaultDispatcher.fire(Action.FocusComposer); defaultDispatcher.fire(Action.FocusSendMessageComposer);
break; break;
case RoomListAction.NextRoom: case RoomListAction.NextRoom:
case RoomListAction.PrevRoom: case RoomListAction.PrevRoom:

View File

@ -118,12 +118,12 @@ export default class RoomStatusBar extends React.PureComponent {
this.setState({ isResending: false }); this.setState({ isResending: false });
}); });
this.setState({ isResending: true }); this.setState({ isResending: true });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
_onCancelAllClick = () => { _onCancelAllClick = () => {
Resend.cancelUnsentEvents(this.props.room); Resend.cancelUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {

View File

@ -818,17 +818,16 @@ export default class RoomView extends React.Component<IProps, IState> {
case Action.ComposerInsert: { case Action.ComposerInsert: {
// re-dispatch to the correct composer // re-dispatch to the correct composer
if (this.state.editState) {
dis.dispatch({ dis.dispatch({
...payload, ...payload,
action: "edit_composer_insert", action: this.state.editState ? "edit_composer_insert" : "send_composer_insert",
});
} else {
dis.dispatch({
...payload,
action: "send_composer_insert",
}); });
break;
} }
case Action.FocusAComposer: {
// re-dispatch to the correct composer
dis.fire(this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer);
break; break;
} }
@ -1246,7 +1245,7 @@ export default class RoomView extends React.Component<IProps, IState> {
ContentMessages.sharedInstance().sendContentListToRoom( ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, this.context, ev.dataTransfer.files, this.state.room.roomId, this.context,
); );
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
this.setState({ this.setState({
draggingFile: false, draggingFile: false,
@ -1548,7 +1547,7 @@ export default class RoomView extends React.Component<IProps, IState> {
} else { } else {
// Otherwise we have to jump manually // Otherwise we have to jump manually
this.messagePanel.jumpToLiveTimeline(); this.messagePanel.jumpToLiveTimeline();
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
} }
}; };

View File

@ -42,6 +42,7 @@ import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore"; import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils"; import { linkifyElement } from "../../HtmlUtils";
import { getDisplayAliasForAliasSet } from "../../Rooms";
interface IHierarchyProps { interface IHierarchyProps {
space: Room; space: Room;
@ -666,5 +667,5 @@ export default SpaceRoomDirectory;
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list // but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) { function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
} }

View File

@ -1,6 +1,6 @@
/* /*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016, 2018, 2019, 2021 The Matrix.org Foundation C.I.C. Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
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.
@ -16,12 +16,11 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventStatus } from 'matrix-js-sdk/src/models/event'; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import Resend from '../../../Resend'; import Resend from '../../../Resend';
@ -29,53 +28,65 @@ import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils'; import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils'; import { isContentActionable } from '../../../utils/EventUtils';
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
import { EventType } from "matrix-js-sdk/src/@types/event";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog"; import ForwardDialog from "../dialogs/ForwardDialog";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import ReportEventDialog from '../dialogs/ReportEventDialog';
import ViewSource from '../../structures/ViewSource';
import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog';
import ErrorDialog from '../dialogs/ErrorDialog';
import ShareDialog from '../dialogs/ShareDialog';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
export function canCancel(eventStatus) { export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
} }
@replaceableComponent("views.context_menus.MessageContextMenu") interface IEventTileOps {
export default class MessageContextMenu extends React.Component { isWidgetHidden(): boolean;
static propTypes = { unhideWidget(): void;
}
interface IProps {
/* the MatrixEvent associated with the context menu */ /* the MatrixEvent associated with the context menu */
mxEvent: PropTypes.object.isRequired, mxEvent: MatrixEvent;
/* an optional EventTileOps implementation that can be used to unhide preview widgets */ /* an optional EventTileOps implementation that can be used to unhide preview widgets */
eventTileOps: PropTypes.object, eventTileOps?: IEventTileOps;
permalinkCreator?: RoomPermalinkCreator;
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */ /* an optional function to be called when the user clicks collapse thread, if not provided hide button */
collapseReplyThread: PropTypes.func, collapseReplyThread?(): void;
/* callback called when the menu is dismissed */ /* callback called when the menu is dismissed */
onFinished: PropTypes.func, onFinished(): void;
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
onCloseDialog: PropTypes.func, onCloseDialog?(): void;
}; }
interface IState {
canRedact: boolean;
canPin: boolean;
}
@replaceableComponent("views.context_menus.MessageContextMenu")
export default class MessageContextMenu extends React.Component<IProps, IState> {
state = { state = {
canRedact: false, canRedact: false,
canPin: false, canPin: false,
}; };
componentDidMount() { componentDidMount() {
MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions);
this._checkPermissions(); this.checkPermissions();
} }
componentWillUnmount() { componentWillUnmount() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.removeListener('RoomMember.powerLevel', this._checkPermissions); cli.removeListener('RoomMember.powerLevel', this.checkPermissions);
} }
} }
_checkPermissions = () => { private checkPermissions = (): void => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId()); const room = cli.getRoom(this.props.mxEvent.getRoomId());
@ -93,7 +104,7 @@ export default class MessageContextMenu extends React.Component {
this.setState({ canRedact, canPin }); this.setState({ canRedact, canPin });
}; };
_isPinned() { private isPinned(): boolean {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ''); const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
if (!pinnedEvent) return false; if (!pinnedEvent) return false;
@ -101,38 +112,35 @@ export default class MessageContextMenu extends React.Component {
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
} }
onResendReactionsClick = () => { private onResendReactionsClick = (): void => {
for (const reaction of this._getUnsentReactions()) { for (const reaction of this.getUnsentReactions()) {
Resend.resend(reaction); Resend.resend(reaction);
} }
this.closeMenu(); this.closeMenu();
}; };
onReportEventClick = () => { private onReportEventClick = (): void => {
const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog");
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
mxEvent: this.props.mxEvent, mxEvent: this.props.mxEvent,
}, 'mx_Dialog_reportEvent'); }, 'mx_Dialog_reportEvent');
this.closeMenu(); this.closeMenu();
}; };
onViewSourceClick = () => { private onViewSourceClick = (): void => {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, { Modal.createTrackedDialog('View Event Source', '', ViewSource, {
mxEvent: this.props.mxEvent, mxEvent: this.props.mxEvent,
}, 'mx_Dialog_viewsource'); }, 'mx_Dialog_viewsource');
this.closeMenu(); this.closeMenu();
}; };
onRedactClick = () => { private onRedactClick = (): void => {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: async (proceed, reason) => { onFinished: async (proceed: boolean, reason?: string) => {
if (!proceed) return; if (!proceed) return;
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
try { try {
if (this.props.onCloseDialog) this.props.onCloseDialog(); this.props.onCloseDialog?.();
await cli.redactEvent( await cli.redactEvent(
this.props.mxEvent.getRoomId(), this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(), this.props.mxEvent.getId(),
@ -145,7 +153,6 @@ export default class MessageContextMenu extends React.Component {
// (e.g. no errcode or statusCode) as in that case the redactions end up in the // (e.g. no errcode or statusCode) as in that case the redactions end up in the
// detached queue and we show the room status bar to allow retry // detached queue and we show the room status bar to allow retry
if (typeof code !== "undefined") { if (typeof code !== "undefined") {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// display error message stating you couldn't delete this. // display error message stating you couldn't delete this.
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
title: _t('Error'), title: _t('Error'),
@ -158,7 +165,7 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
onForwardClick = () => { private onForwardClick = (): void => {
Modal.createTrackedDialog('Forward Message', '', ForwardDialog, { Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
event: this.props.mxEvent, event: this.props.mxEvent,
@ -167,12 +174,12 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
onPinClick = () => { private onPinClick = (): void => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId()); const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId(); const eventId = this.props.mxEvent.getId();
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || []; const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];
if (pinnedIds.includes(eventId)) { if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1); pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else { } else {
@ -188,18 +195,16 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
closeMenu = () => { private closeMenu = (): void => {
if (this.props.onFinished) this.props.onFinished(); this.props.onFinished();
}; };
onUnhidePreviewClick = () => { private onUnhidePreviewClick = (): void => {
if (this.props.eventTileOps) { this.props.eventTileOps?.unhideWidget();
this.props.eventTileOps.unhideWidget();
}
this.closeMenu(); this.closeMenu();
}; };
onQuoteClick = () => { private onQuoteClick = (): void => {
dis.dispatch({ dis.dispatch({
action: Action.ComposerInsert, action: Action.ComposerInsert,
event: this.props.mxEvent, event: this.props.mxEvent,
@ -207,9 +212,8 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
onPermalinkClick = (e) => { private onPermalinkClick = (e: React.MouseEvent): void => {
e.preventDefault(); e.preventDefault();
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent, target: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
@ -217,30 +221,27 @@ export default class MessageContextMenu extends React.Component {
this.closeMenu(); this.closeMenu();
}; };
onCollapseReplyThreadClick = () => { private onCollapseReplyThreadClick = (): void => {
this.props.collapseReplyThread(); this.props.collapseReplyThread();
this.closeMenu(); this.closeMenu();
}; };
_getReactions(filter) { private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId()); const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId(); const eventId = this.props.mxEvent.getId();
return room.getPendingEvents().filter(e => { return room.getPendingEvents().filter(e => {
const relation = e.getRelation(); const relation = e.getRelation();
return relation && return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e);
relation.rel_type === "m.annotation" &&
relation.event_id === eventId &&
filter(e);
}); });
} }
_getPendingReactions() { private getPendingReactions(): MatrixEvent[] {
return this._getReactions(e => canCancel(e.status)); return this.getReactions(e => canCancel(e.status));
} }
_getUnsentReactions() { private getUnsentReactions(): MatrixEvent[] {
return this._getReactions(e => e.status === EventStatus.NOT_SENT); return this.getReactions(e => e.status === EventStatus.NOT_SENT);
} }
render() { render() {
@ -248,16 +249,17 @@ export default class MessageContextMenu extends React.Component {
const me = cli.getUserId(); const me = cli.getUserId();
const mxEvent = this.props.mxEvent; const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status; const eventStatus = mxEvent.status;
const unsentReactionsCount = this._getUnsentReactions().length; const unsentReactionsCount = this.getUnsentReactions().length;
let resendReactionsButton;
let redactButton; let resendReactionsButton: JSX.Element;
let forwardButton; let redactButton: JSX.Element;
let pinButton; let forwardButton: JSX.Element;
let unhidePreviewButton; let pinButton: JSX.Element;
let externalURLButton; let unhidePreviewButton: JSX.Element;
let quoteButton; let externalURLButton: JSX.Element;
let collapseReplyThread; let quoteButton: JSX.Element;
let redactItemList; let collapseReplyThread: JSX.Element;
let redactItemList: JSX.Element;
// status is SENT before remote-echo, null after // status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT; const isSent = !eventStatus || eventStatus === EventStatus.SENT;
@ -296,7 +298,7 @@ export default class MessageContextMenu extends React.Component {
pinButton = ( pinButton = (
<IconizedContextMenuOption <IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPin" iconClassName="mx_MessageContextMenu_iconPin"
label={ this._isPinned() ? _t('Unpin') : _t('Pin') } label={ this.isPinned() ? _t('Unpin') : _t('Pin') }
onClick={this.onPinClick} onClick={this.onPinClick}
/> />
); );
@ -327,16 +329,20 @@ export default class MessageContextMenu extends React.Component {
if (this.props.permalinkCreator) { if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
} }
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
const permalinkButton = ( const permalinkButton = (
<IconizedContextMenuOption <IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPermalink" iconClassName="mx_MessageContextMenu_iconPermalink"
onClick={this.onPermalinkClick} onClick={this.onPermalinkClick}
label= {_t('Share')} label= {_t('Share')}
element="a" element="a"
href={permalink} {
target="_blank" // XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
rel="noreferrer noopener" ...{
href: permalink,
target: "_blank",
rel: "noreferrer noopener",
}
}
/> />
); );
@ -351,8 +357,8 @@ export default class MessageContextMenu extends React.Component {
} }
// Bridges can provide a 'external_url' to link back to the source. // Bridges can provide a 'external_url' to link back to the source.
if (typeof (mxEvent.event.content.external_url) === "string" && if (typeof (mxEvent.getContent().external_url) === "string" &&
isUrlPermitted(mxEvent.event.content.external_url) isUrlPermitted(mxEvent.getContent().external_url)
) { ) {
externalURLButton = ( externalURLButton = (
<IconizedContextMenuOption <IconizedContextMenuOption
@ -360,9 +366,14 @@ export default class MessageContextMenu extends React.Component {
onClick={this.closeMenu} onClick={this.closeMenu}
label={ _t('Source URL') } label={ _t('Source URL') }
element="a" element="a"
target="_blank" {
rel="noreferrer noopener" // XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
href={mxEvent.event.content.external_url} ...{
target: "_blank",
rel: "noreferrer noopener",
href: mxEvent.getContent().external_url,
}
}
/> />
); );
} }
@ -377,7 +388,7 @@ export default class MessageContextMenu extends React.Component {
); );
} }
let reportEventButton; let reportEventButton: JSX.Element;
if (mxEvent.getSender() !== me) { if (mxEvent.getSender() !== me) {
reportEventButton = ( reportEventButton = (
<IconizedContextMenuOption <IconizedContextMenuOption

View File

@ -41,7 +41,8 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { compare } from "../../../utils/strings"; import { compare } from "../../../utils/strings";
export const ALL_ROOMS = Symbol("ALL_ROOMS"); // XXX: We would ideally use a symbol here but we can't since we save this value to localStorage
export const ALL_ROOMS = "ALL_ROOMS";
const SETTING_NAME = "room_directory_servers"; const SETTING_NAME = "room_directory_servers";
@ -94,8 +95,7 @@ export interface IInstance {
fields: object; fields: object;
network_id: string; network_id: string;
// XXX: this is undocumented but we rely on it. // XXX: this is undocumented but we rely on it.
// we inject a fake entry with a symbolic instance_id. instance_id: string;
instance_id: string | symbol;
} }
export interface IProtocol { export interface IProtocol {
@ -112,8 +112,8 @@ export type Protocols = Record<string, IProtocol>;
interface IProps { interface IProps {
protocols: Protocols; protocols: Protocols;
selectedServerName: string; selectedServerName: string;
selectedInstanceId: string | symbol; selectedInstanceId: string;
onOptionChange(server: string, instanceId?: string | symbol): void; onOptionChange(server: string, instanceId?: string): void;
} }
// This dropdown sources homeservers from three places: // This dropdown sources homeservers from three places:
@ -171,7 +171,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
const protocolsList = server === hsName ? Object.values(protocols) : []; const protocolsList = server === hsName ? Object.values(protocols) : [];
if (protocolsList.length > 0) { if (protocolsList.length > 0) {
// add a fake protocol with the ALL_ROOMS symbol // add a fake protocol with ALL_ROOMS
protocolsList.push({ protocolsList.push({
instances: [{ instances: [{
fields: [], fields: [],

View File

@ -14,7 +14,7 @@
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ReactHTML } from 'react';
import { Key } from '../../../Keyboard'; import { Key } from '../../../Keyboard';
import classnames from 'classnames'; import classnames from 'classnames';
@ -29,7 +29,7 @@ export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Elemen
*/ */
interface IProps extends React.InputHTMLAttributes<Element> { interface IProps extends React.InputHTMLAttributes<Element> {
inputRef?: React.Ref<Element>; inputRef?: React.Ref<Element>;
element?: string; element?: keyof ReactHTML;
// The kind of button, similar to how Bootstrap works. // The kind of button, similar to how Bootstrap works.
// See available classes for AccessibleButton for options. // See available classes for AccessibleButton for options.
kind?: string; kind?: string;
@ -122,7 +122,7 @@ export default function AccessibleButton({
} }
AccessibleButton.defaultProps = { AccessibleButton.defaultProps = {
element: 'div', element: 'div' as keyof ReactHTML,
role: 'button', role: 'button',
tabIndex: 0, tabIndex: 0,
}; };

View File

@ -238,6 +238,7 @@ export default class AppTile extends React.Component {
case 'm.sticker': case 'm.sticker':
if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({ action: 'post_sticker_message', data: payload.data }); dis.dispatch({ action: 'post_sticker_message', data: payload.data });
dis.dispatch({ action: 'stickerpicker_close' });
} else { } else {
console.warn('Ignoring sticker message. Invalid capability'); console.warn('Ignoring sticker message. Invalid capability');
} }

View File

@ -304,7 +304,7 @@ export default class ReplyThread extends React.Component {
events, events,
}); });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
} }
getReplyThreadColorClass(ev) { getReplyThreadColorClass(ev) {

View File

@ -27,6 +27,7 @@ interface IProps {
value: string; value: string;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean;
onChange?(value: string): void; onChange?(value: string): void;
} }
@ -68,6 +69,7 @@ export default class RoomAliasField extends React.PureComponent<IProps, IState>
onChange={this.onChange} onChange={this.onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)} value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength} maxLength={maxlength}
disabled={this.props.disabled}
/> />
); );
} }

View File

@ -22,6 +22,7 @@ import EmojiPicker from "./EmojiPicker";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -93,6 +94,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
this.props.mxEvent.getRoomId(), this.props.mxEvent.getRoomId(),
myReactions[reaction], myReactions[reaction],
); );
dis.dispatch({ action: Action.FocusAComposer });
// Tell the emoji picker not to bump this in the more frequently used list. // Tell the emoji picker not to bump this in the more frequently used list.
return false; return false;
} else { } else {
@ -104,6 +106,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
}, },
}); });
dis.dispatch({ action: "message_sent" }); dis.dispatch({ action: "message_sent" });
dis.dispatch({ action: Action.FocusAComposer });
return true; return true;
} }
}; };

View File

@ -45,7 +45,7 @@ import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer'; import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewWidget from '../rooms/LinkPreviewWidget'; import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
interface IProps { interface IProps {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
@ -244,7 +244,11 @@ export default class TextualBody extends React.Component<IProps, IState> {
} }
private highlightCode(code: HTMLElement): void { private highlightCode(code: HTMLElement): void {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) { // Auto-detect language only if enabled and only for codeblocks
if (
SettingsStore.getValue("enableSyntaxHighlightLanguageDetection") &&
code.parentElement instanceof HTMLPreElement
) {
highlight.highlightBlock(code); highlight.highlightBlock(code);
} else { } else {
// Only syntax highlight if there's a class starting with language- // Only syntax highlight if there's a class starting with language-
@ -294,14 +298,8 @@ export default class TextualBody extends React.Component<IProps, IState> {
// pass only the first child which is the event tile otherwise this recurses on edited events // pass only the first child which is the event tile otherwise this recurses on edited events
let links = this.findLinks([this.contentRef.current]); let links = this.findLinks([this.contentRef.current]);
if (links.length) { if (links.length) {
// de-duplicate the links after stripping hashes as they don't affect the preview // de-duplicate the links using a set here maintains the order
// using a set here maintains the order links = Array.from(new Set(links));
links = Array.from(new Set(links.map(link => {
const url = new URL(link);
url.hash = "";
return url.toString();
})));
this.setState({ links }); this.setState({ links });
// lazy-load the hidden state of the preview widget from localstorage // lazy-load the hidden state of the preview widget from localstorage
@ -530,15 +528,12 @@ export default class TextualBody extends React.Component<IProps, IState> {
let widgets; let widgets;
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
widgets = this.state.links.map((link)=>{ widgets = <LinkPreviewGroup
return <LinkPreviewWidget links={this.state.links}
key={link}
link={link}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick} onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
/>; />;
});
} }
switch (content.msgtype) { switch (content.msgtype) {

View File

@ -20,6 +20,7 @@ import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import DirectoryCustomisations from '../../../customisations/Directory';
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -66,10 +67,15 @@ export default class RoomPublishSetting extends React.PureComponent<IProps, ISta
render() { render() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const enabled = (
DirectoryCustomisations.requireCanonicalAliasAccessToPublish?.() === false ||
this.props.canSetCanonicalAlias
);
return ( return (
<LabelledToggleSwitch value={this.state.isRoomPublished} <LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange} onChange={this.onRoomPublishChange}
disabled={!this.props.canSetCanonicalAlias} disabled={!enabled}
label={_t("Publish this room to the public in %(domain)s's room directory?", { label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(), domain: client.getDomain(),
})} })}

View File

@ -181,7 +181,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
} else { } else {
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: 'edit_event', event: null }); dis.dispatch({ action: 'edit_event', event: null });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
} }
event.preventDefault(); event.preventDefault();
break; break;
@ -200,7 +200,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private cancelEdit = (): void => { private cancelEdit = (): void => {
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: "edit_event", event: null }); dis.dispatch({ action: "edit_event", event: null });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
private get shouldSaveStoredEditorState(): boolean { private get shouldSaveStoredEditorState(): boolean {
@ -375,7 +375,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
// close the event editing and focus composer // close the event editing and focus composer
dis.dispatch({ action: "edit_event", event: null }); dis.dispatch({ action: "edit_event", event: null });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
private cancelPreviousPendingEdit(): void { private cancelPreviousPendingEdit(): void {
@ -452,6 +452,8 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
} else if (payload.text) { } else if (payload.text) {
this.editorRef.current?.insertPlaintext(payload.text); this.editorRef.current?.insertPlaintext(payload.text);
} }
} else if (payload.action === Action.FocusEditMessageComposer && this.editorRef.current) {
this.editorRef.current.focus();
} }
}; };

View File

@ -0,0 +1,90 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useEffect } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { IPreviewUrlResponse } from "matrix-js-sdk/src/client";
import { useStateToggle } from "../../../hooks/useStateToggle";
import LinkPreviewWidget from "./LinkPreviewWidget";
import AccessibleButton from "../elements/AccessibleButton";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
const INITIAL_NUM_PREVIEWS = 2;
interface IProps {
links: string[]; // the URLs to be previewed
mxEvent: MatrixEvent; // the Event associated with the preview
onCancelClick(): void; // called when the preview's cancel ('hide') button is clicked
onHeightChanged(): void; // called when the preview's contents has loaded
}
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onHeightChanged }) => {
const cli = useContext(MatrixClientContext);
const [expanded, toggleExpanded] = useStateToggle();
const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(link => {
return cli.getUrlPreview(link, ts).then(preview => [link, preview], error => {
console.error("Failed to get URL preview: " + error);
});
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
}, [links, ts], []);
useEffect(() => {
onHeightChanged();
}, [onHeightChanged, expanded, previews]);
const showPreviews = expanded ? previews : previews.slice(0, INITIAL_NUM_PREVIEWS);
let toggleButton: JSX.Element;
if (previews.length > INITIAL_NUM_PREVIEWS) {
toggleButton = <AccessibleButton onClick={toggleExpanded}>
{ expanded
? _t("Collapse")
: _t("Show %(count)s other previews", { count: previews.length - showPreviews.length }) }
</AccessibleButton>;
}
return <div className="mx_LinkPreviewGroup">
{ showPreviews.map(([link, preview], i) => (
<LinkPreviewWidget key={link} link={link} preview={preview} mxEvent={mxEvent}>
{ i === 0 ? (
<AccessibleButton
className="mx_LinkPreviewGroup_hide"
onClick={onCancelClick}
aria-label={_t("Close preview")}
>
<img
className="mx_filterFlipColor"
alt=""
role="presentation"
src={require("../../../../res/img/cancel.svg")}
width="18"
height="18"
/>
</AccessibleButton>
): undefined }
</LinkPreviewWidget>
)) }
{ toggleButton }
</div>;
};
export default LinkPreviewGroup;

View File

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
@ -16,71 +15,44 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client';
import { linkifyElement } from '../../../HtmlUtils'; import { linkifyElement } from '../../../HtmlUtils';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils"; import * as ImageUtils from "../../../ImageUtils";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import ImageView from '../elements/ImageView';
interface IProps {
link: string;
preview: IPreviewUrlResponse;
mxEvent: MatrixEvent; // the Event associated with the preview
}
@replaceableComponent("views.rooms.LinkPreviewWidget") @replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component { export default class LinkPreviewWidget extends React.Component<IProps> {
static propTypes = { private readonly description = createRef<HTMLDivElement>();
link: PropTypes.string.isRequired, // the URL being previewed
mxEvent: PropTypes.object.isRequired, // the Event associated with the preview
onCancelClick: PropTypes.func, // called when the preview's cancel ('hide') button is clicked
onHeightChanged: PropTypes.func, // called when the preview's contents has loaded
};
constructor(props) {
super(props);
this.state = {
preview: null,
};
this.unmounted = false;
MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((res)=>{
if (this.unmounted) {
return;
}
this.setState(
{ preview: res },
this.props.onHeightChanged,
);
}, (error)=>{
console.error("Failed to get URL preview: " + error);
});
this._description = createRef();
}
componentDidMount() { componentDidMount() {
if (this._description.current) { if (this.description.current) {
linkifyElement(this._description.current); linkifyElement(this.description.current);
} }
} }
componentDidUpdate() { componentDidUpdate() {
if (this._description.current) { if (this.description.current) {
linkifyElement(this._description.current); linkifyElement(this.description.current);
} }
} }
componentWillUnmount() { private onImageClick = ev => {
this.unmounted = true; const p = this.props.preview;
}
onImageClick = ev => {
const p = this.state.preview;
if (ev.button != 0 || ev.metaKey) return; if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault(); ev.preventDefault();
const ImageView = sdk.getComponent("elements.ImageView");
let src = p["og:image"]; let src = p["og:image"];
if (src && src.startsWith("mxc://")) { if (src && src.startsWith("mxc://")) {
@ -100,7 +72,7 @@ export default class LinkPreviewWidget extends React.Component {
}; };
render() { render() {
const p = this.state.preview; const p = this.props.preview;
if (!p || Object.keys(p).length === 0) { if (!p || Object.keys(p).length === 0) {
return <div />; return <div />;
} }
@ -136,21 +108,21 @@ export default class LinkPreviewWidget extends React.Component {
// opaque string. This does not allow any HTML to be injected into the DOM. // opaque string. This does not allow any HTML to be injected into the DOM.
const description = AllHtmlEntities.decode(p["og:description"] || ""); const description = AllHtmlEntities.decode(p["og:description"] || "");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return ( return (
<div className="mx_LinkPreviewWidget"> <div className="mx_LinkPreviewWidget">
{ img } { img }
<div className="mx_LinkPreviewWidget_caption"> <div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title"><a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a></div> <div className="mx_LinkPreviewWidget_title">
<div className="mx_LinkPreviewWidget_siteName">{ p["og:site_name"] ? (" - " + p["og:site_name"]) : null }</div> <a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a>
<div className="mx_LinkPreviewWidget_description" ref={this._description}> { p["og:site_name"] && <span className="mx_LinkPreviewWidget_siteName">
{ (" - " + p["og:site_name"]) }
</span> }
</div>
<div className="mx_LinkPreviewWidget_description" ref={this.description}>
{ description } { description }
</div> </div>
</div> </div>
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}> { this.props.children }
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2017 New Vector Ltd. Copyright 2017-2021 The Matrix.org Foundation C.I.C.
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.
@ -21,9 +21,10 @@ import { linkifyElement } from '../../../HtmlUtils';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import { getDisplayAliasForAliasSet } from '../../../Rooms';
export function getDisplayAliasForRoom(room) { export function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : ""); return getDisplayAliasForAliasSet(room.canonicalAlias, room.aliases);
} }
export const roomShape = PropTypes.shape({ export const roomShape = PropTypes.shape({

View File

@ -497,7 +497,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
switch (payload.action) { switch (payload.action) {
case 'reply_to_event': case 'reply_to_event':
case Action.FocusComposer: case Action.FocusSendMessageComposer:
this.editorRef.current?.focus(); this.editorRef.current?.focus();
break; break;
case "send_composer_insert": case "send_composer_insert":

View File

@ -33,7 +33,7 @@ import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { MsgType } from "matrix-js-sdk/src/@types/event"; 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 from "../../../MediaDeviceHandler"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
interface IProps { interface IProps {
room: Room; room: Room;
@ -95,7 +95,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
duration: Math.round(this.state.recorder.durationSeconds * 1000), duration: Math.round(this.state.recorder.durationSeconds * 1000),
// https://github.com/matrix-org/matrix-doc/pull/3246 // https://github.com/matrix-org/matrix-doc/pull/3246
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)), waveform: this.state.recorder.getPlayback().thumbnailWaveform.map(v => Math.round(v * 1024)),
}, },
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint "org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
}); });
@ -135,7 +135,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
// change between this and recording, but at least we will have tried. // change between this and recording, but at least we will have tried.
try { try {
const devices = await MediaDeviceHandler.getDevices(); const devices = await MediaDeviceHandler.getDevices();
if (!devices?.['audioInput']?.length) { if (!devices?.[MediaDeviceKindEnum.AudioInput]?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, { Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"), title: _t("No microphone found"),
description: <> description: <>

View File

@ -280,6 +280,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
const mutedUsers = []; const mutedUsers = [];
Object.keys(userLevels).forEach((user) => { Object.keys(userLevels).forEach((user) => {
if (!Number.isInteger(userLevels[user])) { return; }
const canChange = userLevels[user] < currentUserLevel && canChangeLevels; const canChange = userLevels[user] < currentUserLevel && canChangeLevels;
if (userLevels[user] > defaultUserLevel) { // privileged if (userLevels[user] > defaultUserLevel) { // privileged
privilegedUsers.push( privilegedUsers.push(

View File

@ -18,41 +18,58 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig"; import SdkConfig from "../../../../../SdkConfig";
import MediaDeviceHandler from "../../../../../MediaDeviceHandler"; import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../MediaDeviceHandler";
import Field from "../../../elements/Field"; import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../../index";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import { SettingLevel } from "../../../../../settings/SettingLevel"; import { SettingLevel } from "../../../../../settings/SettingLevel";
import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import SettingsFlag from '../../../elements/SettingsFlag';
import ErrorDialog from '../../../dialogs/ErrorDialog';
const getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>) => {
// Note we're looking for a device with deviceId 'default' but adding a device
// with deviceId == the empty string: this is because Chrome gives us a device
// with deviceId 'default', so we're looking for this, not the one we are adding.
if (!devices.some((i) => i.deviceId === 'default')) {
devices.unshift({ deviceId: '', label: _t('Default Device') });
return '';
} else {
return 'default';
}
};
interface IState extends Record<MediaDeviceKindEnum, string> {
mediaDevices: IMediaDevices;
}
@replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab") @replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab")
export default class VoiceUserSettingsTab extends React.Component { export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
constructor() { constructor(props: {}) {
super(); super(props);
this.state = { this.state = {
mediaDevices: false, mediaDevices: null,
activeAudioOutput: null, [MediaDeviceKindEnum.AudioOutput]: null,
activeAudioInput: null, [MediaDeviceKindEnum.AudioInput]: null,
activeVideoInput: null, [MediaDeviceKindEnum.VideoInput]: null,
}; };
} }
async componentDidMount() { async componentDidMount() {
const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices(); const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
if (canSeeDeviceLabels) { if (canSeeDeviceLabels) {
this._refreshMediaDevices(); this.refreshMediaDevices();
} }
} }
_refreshMediaDevices = async (stream) => { private refreshMediaDevices = async (stream?: MediaStream): Promise<void> => {
this.setState({ this.setState({
mediaDevices: await MediaDeviceHandler.getDevices(), mediaDevices: await MediaDeviceHandler.getDevices(),
activeAudioOutput: MediaDeviceHandler.getAudioOutput(), [MediaDeviceKindEnum.AudioOutput]: MediaDeviceHandler.getAudioOutput(),
activeAudioInput: MediaDeviceHandler.getAudioInput(), [MediaDeviceKindEnum.AudioInput]: MediaDeviceHandler.getAudioInput(),
activeVideoInput: MediaDeviceHandler.getVideoInput(), [MediaDeviceKindEnum.VideoInput]: MediaDeviceHandler.getVideoInput(),
}); });
if (stream) { if (stream) {
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again) // kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
@ -62,7 +79,7 @@ export default class VoiceUserSettingsTab extends React.Component {
} }
}; };
_requestMediaPermissions = async () => { private requestMediaPermissions = async (): Promise<void> => {
let constraints; let constraints;
let stream; let stream;
let error; let error;
@ -86,7 +103,6 @@ export default class VoiceUserSettingsTab extends React.Component {
if (error) { if (error) {
console.log("Failed to list userMedia devices", error); console.log("Failed to list userMedia devices", error);
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('No media permissions', '', ErrorDialog, { Modal.createTrackedDialog('No media permissions', '', ErrorDialog, {
title: _t('No media permissions'), title: _t('No media permissions'),
description: _t( description: _t(
@ -95,118 +111,74 @@ export default class VoiceUserSettingsTab extends React.Component {
), ),
}); });
} else { } else {
this._refreshMediaDevices(stream); this.refreshMediaDevices(stream);
} }
}; };
_setAudioOutput = (e) => { private setDevice = (deviceId: string, kind: MediaDeviceKindEnum): void => {
MediaDeviceHandler.instance.setAudioOutput(e.target.value); MediaDeviceHandler.instance.setDevice(deviceId, kind);
this.setState({ this.setState<null>({ [kind]: deviceId });
activeAudioOutput: e.target.value,
});
}; };
_setAudioInput = (e) => { private changeWebRtcMethod = (p2p: boolean): void => {
MediaDeviceHandler.instance.setAudioInput(e.target.value);
this.setState({
activeAudioInput: e.target.value,
});
};
_setVideoInput = (e) => {
MediaDeviceHandler.instance.setVideoInput(e.target.value);
this.setState({
activeVideoInput: e.target.value,
});
};
_changeWebRtcMethod = (p2p) => {
MatrixClientPeg.get().setForceTURN(!p2p); MatrixClientPeg.get().setForceTURN(!p2p);
}; };
_changeFallbackICEServerAllowed = (allow) => { private changeFallbackICEServerAllowed = (allow: boolean): void => {
MatrixClientPeg.get().setFallbackICEServerAllowed(allow); MatrixClientPeg.get().setFallbackICEServerAllowed(allow);
}; };
_renderDeviceOptions(devices, category) { private renderDeviceOptions(devices: Array<MediaDeviceInfo>, category: MediaDeviceKindEnum): Array<JSX.Element> {
return devices.map((d) => { return devices.map((d) => {
return (<option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>); return (<option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>);
}); });
} }
render() { private renderDropdown(kind: MediaDeviceKindEnum, label: string): JSX.Element {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); const devices = this.state.mediaDevices[kind].slice(0);
if (devices.length === 0) return null;
const defaultDevice = getDefaultDevice(devices);
return (
<Field
element="select"
label={label}
value={this.state[kind] || defaultDevice}
onChange={(e) => this.setDevice(e.target.value, kind)}
>
{ this.renderDeviceOptions(devices, kind) }
</Field>
);
}
render() {
let requestButton = null; let requestButton = null;
let speakerDropdown = null; let speakerDropdown = null;
let microphoneDropdown = null; let microphoneDropdown = null;
let webcamDropdown = null; let webcamDropdown = null;
if (this.state.mediaDevices === false) { if (!this.state.mediaDevices) {
requestButton = ( requestButton = (
<div className='mx_VoiceUserSettingsTab_missingMediaPermissions'> <div className='mx_VoiceUserSettingsTab_missingMediaPermissions'>
<p>{_t("Missing media permissions, click the button below to request.")}</p> <p>{_t("Missing media permissions, click the button below to request.")}</p>
<AccessibleButton onClick={this._requestMediaPermissions} kind="primary"> <AccessibleButton onClick={this.requestMediaPermissions} kind="primary">
{_t("Request media permissions")} {_t("Request media permissions")}
</AccessibleButton> </AccessibleButton>
</div> </div>
); );
} else if (this.state.mediaDevices) { } else if (this.state.mediaDevices) {
speakerDropdown = <p>{ _t('No Audio Outputs detected') }</p>;
microphoneDropdown = <p>{ _t('No Microphones detected') }</p>;
webcamDropdown = <p>{ _t('No Webcams detected') }</p>;
const defaultOption = {
deviceId: '',
label: _t('Default Device'),
};
const getDefaultDevice = (devices) => {
// Note we're looking for a device with deviceId 'default' but adding a device
// with deviceId == the empty string: this is because Chrome gives us a device
// with deviceId 'default', so we're looking for this, not the one we are adding.
if (!devices.some((i) => i.deviceId === 'default')) {
devices.unshift(defaultOption);
return '';
} else {
return 'default';
}
};
const audioOutputs = this.state.mediaDevices.audioOutput.slice(0);
if (audioOutputs.length > 0) {
const defaultDevice = getDefaultDevice(audioOutputs);
speakerDropdown = ( speakerDropdown = (
<Field element="select" label={_t("Audio Output")} this.renderDropdown(MediaDeviceKindEnum.AudioOutput, _t("Audio Output")) ||
value={this.state.activeAudioOutput || defaultDevice} <p>{ _t('No Audio Outputs detected') }</p>
onChange={this._setAudioOutput}>
{this._renderDeviceOptions(audioOutputs, 'audioOutput')}
</Field>
); );
}
const audioInputs = this.state.mediaDevices.audioInput.slice(0);
if (audioInputs.length > 0) {
const defaultDevice = getDefaultDevice(audioInputs);
microphoneDropdown = ( microphoneDropdown = (
<Field element="select" label={_t("Microphone")} this.renderDropdown(MediaDeviceKindEnum.AudioInput, _t("Microphone")) ||
value={this.state.activeAudioInput || defaultDevice} <p>{ _t('No Microphones detected') }</p>
onChange={this._setAudioInput}>
{this._renderDeviceOptions(audioInputs, 'audioInput')}
</Field>
); );
}
const videoInputs = this.state.mediaDevices.videoInput.slice(0);
if (videoInputs.length > 0) {
const defaultDevice = getDefaultDevice(videoInputs);
webcamDropdown = ( webcamDropdown = (
<Field element="select" label={_t("Camera")} this.renderDropdown(MediaDeviceKindEnum.VideoInput, _t("Camera")) ||
value={this.state.activeVideoInput || defaultDevice} <p>{ _t('No Webcams detected') }</p>
onChange={this._setVideoInput}>
{this._renderDeviceOptions(videoInputs, 'videoInput')}
</Field>
); );
} }
}
return ( return (
<div className="mx_SettingsTab mx_VoiceUserSettingsTab"> <div className="mx_SettingsTab mx_VoiceUserSettingsTab">
@ -220,12 +192,12 @@ export default class VoiceUserSettingsTab extends React.Component {
<SettingsFlag <SettingsFlag
name='webRtcAllowPeerToPeer' name='webRtcAllowPeerToPeer'
level={SettingLevel.DEVICE} level={SettingLevel.DEVICE}
onChange={this._changeWebRtcMethod} onChange={this.changeWebRtcMethod}
/> />
<SettingsFlag <SettingsFlag
name='fallbackICEServerAllowed' name='fallbackICEServerAllowed'
level={SettingLevel.DEVICE} level={SettingLevel.DEVICE}
onChange={this._changeFallbackICEServerAllowed} onChange={this.changeFallbackICEServerAllowed}
/> />
</div> </div>
</div> </div>

View File

@ -220,6 +220,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
value={alias} value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")} placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
label={_t("Address")} label={_t("Address")}
disabled={busy}
/> />
: null : null
} }

View File

@ -96,7 +96,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> } { error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt onClick={() => onFinished(false)} /> <SpaceFeedbackPrompt />
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<SpaceBasicSettings <SpaceBasicSettings

View File

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { createRef } from 'react';
import CallView from "./CallView"; import CallView from "./CallView";
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
@ -27,6 +27,22 @@ 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 UIStore from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution';
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.05;
const PADDING = {
top: 58,
bottom: 58,
left: 76,
right: 8,
};
const SHOW_CALL_IN_STATES = [ const SHOW_CALL_IN_STATES = [
CallState.Connected, CallState.Connected,
@ -49,6 +65,10 @@ interface IState {
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
// they belong to // they belong to
secondaryCall: MatrixCall; secondaryCall: MatrixCall;
// Position of the CallPreview
translationX: number;
translationY: number;
} }
// Splits a list of calls into one 'primary' one and a list // Splits a list of calls into one 'primary' one and a list
@ -91,6 +111,16 @@ export default class CallPreview extends React.Component<IProps, IState> {
private roomStoreToken: any; private roomStoreToken: any;
private dispatcherRef: string; private dispatcherRef: string;
private settingsWatcherRef: string; private settingsWatcherRef: string;
private callViewWrapper = createRef<HTMLDivElement>();
private initX = 0;
private initY = 0;
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH;
private moving = false;
private scheduledUpdate = new MarkedExecution(
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -105,12 +135,17 @@ export default class CallPreview extends React.Component<IProps, IState> {
roomId, roomId,
primaryCall: primaryCall, primaryCall: primaryCall,
secondaryCall: secondaryCalls[0], secondaryCall: secondaryCalls[0],
translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH,
}; };
} }
public componentDidMount() { public componentDidMount() {
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
window.addEventListener("resize", this.snap);
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);
} }
@ -118,6 +153,9 @@ export default class CallPreview extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
window.removeEventListener("resize", this.snap);
if (this.roomStoreToken) { if (this.roomStoreToken) {
this.roomStoreToken.remove(); this.roomStoreToken.remove();
} }
@ -125,6 +163,83 @@ export default class CallPreview extends React.Component<IProps, IState> {
SettingsStore.unwatchSetting(this.settingsWatcherRef); SettingsStore.unwatchSetting(this.settingsWatcherRef);
} }
private animationCallback = () => {
// If the PiP isn't being dragged and there is only a tiny difference in
// the desiredTranslation and translation, quit the animationCallback
// loop. If that is the case, it means the PiP has snapped into its
// position and there is nothing to do. Not doing this would cause an
// infinite loop
if (
!this.moving &&
Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
) return;
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
this.setState({
translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
});
this.scheduledUpdate.mark();
};
private setTranslation(inTranslationX: number, inTranslationY: number) {
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
// Avoid overflow on the x axis
if (inTranslationX + width >= UIStore.instance.windowWidth) {
this.desiredTranslationX = UIStore.instance.windowWidth - width;
} else if (inTranslationX <= 0) {
this.desiredTranslationX = 0;
} else {
this.desiredTranslationX = inTranslationX;
}
// Avoid overflow on the y axis
if (inTranslationY + height >= UIStore.instance.windowHeight) {
this.desiredTranslationY = UIStore.instance.windowHeight - height;
} else if (inTranslationY <= 0) {
this.desiredTranslationY = 0;
} else {
this.desiredTranslationY = inTranslationY;
}
}
private snap = () => {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
// the position to snap to from the PiP center and not its top-left
// corner
const windowWidth = (
UIStore.instance.windowWidth -
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
);
const windowHeight = (
UIStore.instance.windowHeight -
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
);
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = PADDING.top;
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = PADDING.top;
}
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
};
private onRoomViewStoreUpdate = (payload) => { private onRoomViewStoreUpdate = (payload) => {
if (RoomViewStore.getRoomId() === this.state.roomId) return; if (RoomViewStore.getRoomId() === this.state.roomId) return;
@ -173,10 +288,52 @@ export default class CallPreview extends React.Component<IProps, IState> {
}); });
}; };
private onStartMoving = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
};
private onMoving = (event: React.MouseEvent | MouseEvent) => {
if (!this.moving) return;
event.preventDefault();
event.stopPropagation();
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
};
private onEndMoving = () => {
this.moving = false;
this.snap();
};
public render() { public render() {
if (this.state.primaryCall) { if (this.state.primaryCall) {
const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px";
const style = {
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})`,
};
return ( return (
<CallView call={this.state.primaryCall} secondaryCall={this.state.secondaryCall} pipMode={true} /> <div
className="mx_CallPreview"
style={style}
ref={this.callViewWrapper}
>
<CallView
call={this.state.primaryCall}
secondaryCall={this.state.secondaryCall}
pipMode={true}
onMouseDownOnHeader={this.onStartMoving}
/>
</div>
); );
} }

View File

@ -49,6 +49,9 @@ interface IProps {
// 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.
pipMode?: boolean; pipMode?: boolean;
// Used for dragging the PiP CallView
onMouseDownOnHeader?: (event: React.MouseEvent) => void;
} }
interface IState { interface IState {
@ -698,7 +701,11 @@ export default class CallView extends React.Component<IProps, IState> {
</span>; </span>;
} }
header = <div className="mx_CallView_header"> header = (
<div
className="mx_CallView_header"
onMouseDown={this.props.onMouseDownOnHeader}
>
<AccessibleButton onClick={this.onRoomAvatarClick}> <AccessibleButton onClick={this.onRoomAvatarClick}>
<RoomAvatar room={callRoom} height={32} width={32} /> <RoomAvatar room={callRoom} height={32} width={32} />
</AccessibleButton> </AccessibleButton>
@ -710,7 +717,8 @@ export default class CallView extends React.Component<IProps, IState> {
</div> </div>
</div> </div>
{headerControls} {headerControls}
</div>; </div>
);
myClassName = 'mx_CallView_pip'; myClassName = 'mx_CallView_pip';
} }

View File

@ -0,0 +1,31 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function getDisplayAliasForAliasSet(canonicalAlias: string, altAliases: string[]): string {
// E.g. prefer one of the aliases over another
return null;
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IAliasCustomisations {
getDisplayAliasForAliasSet?: typeof getDisplayAliasForAliasSet;
}
// A real customisation module will define and export one or more of the
// customisation points that make up `IAliasCustomisations`.
export default {} as IAliasCustomisations;

View File

@ -0,0 +1,31 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function requireCanonicalAliasAccessToPublish(): boolean {
// Some environments may not care about this requirement and could return false
return true;
}
// This interface summarises all available customisation points and also marks
// them all as optional. This allows customisers to only define and export the
// customisations they need while still maintaining type safety.
export interface IDirectoryCustomisations {
requireCanonicalAliasAccessToPublish?: typeof requireCanonicalAliasAccessToPublish;
}
// A real customisation module will define and export one or more of the
// customisation points that make up `IDirectoryCustomisations`.
export default {} as IDirectoryCustomisations;

View File

@ -56,9 +56,21 @@ export enum Action {
CheckUpdates = "check_updates", CheckUpdates = "check_updates",
/** /**
* Focuses the user's cursor to the composer. No additional payload information required. * Focuses the user's cursor to the send message composer. No additional payload information required.
*/ */
FocusComposer = "focus_composer", FocusSendMessageComposer = "focus_send_message_composer",
/**
* Focuses the user's cursor to the edit message composer. No additional payload information required.
*/
FocusEditMessageComposer = "focus_edit_message_composer",
/**
* Focuses the user's cursor to the edit message composer or send message
* composer based on the current edit state. No additional payload
* information required.
*/
FocusAComposer = "focus_a_composer",
/** /**
* Opens the user menu (previously known as the top left menu). No additional payload information required. * Opens the user menu (previously known as the top left menu). No additional payload information required.

View File

@ -695,6 +695,7 @@
"Error leaving room": "Error leaving room", "Error leaving room": "Error leaving room",
"Unrecognised address": "Unrecognised address", "Unrecognised address": "Unrecognised address",
"You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.", "You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.",
"User %(userId)s is already invited to the room": "User %(userId)s is already invited to the room",
"User %(userId)s is already in the room": "User %(userId)s is already in the room", "User %(userId)s is already in the room": "User %(userId)s is already in the room",
"User %(user_id)s does not exist": "User %(user_id)s does not exist", "User %(user_id)s does not exist": "User %(user_id)s does not exist",
"User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist", "User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist",
@ -1364,17 +1365,17 @@
"Where youre logged in": "Where youre logged in", "Where youre logged in": "Where youre logged in",
"Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.": "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.", "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.": "Manage the names of and sign out of your sessions below or <a>verify them in your User Profile</a>.",
"A session's public name is visible to people you communicate with": "A session's public name is visible to people you communicate with", "A session's public name is visible to people you communicate with": "A session's public name is visible to people you communicate with",
"Default Device": "Default Device",
"No media permissions": "No media permissions", "No media permissions": "No media permissions",
"You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam", "You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam",
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.", "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
"Request media permissions": "Request media permissions", "Request media permissions": "Request media permissions",
"No Audio Outputs detected": "No Audio Outputs detected",
"No Microphones detected": "No Microphones detected",
"No Webcams detected": "No Webcams detected",
"Default Device": "Default Device",
"Audio Output": "Audio Output", "Audio Output": "Audio Output",
"No Audio Outputs detected": "No Audio Outputs detected",
"Microphone": "Microphone", "Microphone": "Microphone",
"No Microphones detected": "No Microphones detected",
"Camera": "Camera", "Camera": "Camera",
"No Webcams detected": "No Webcams detected",
"Voice & Video": "Voice & Video", "Voice & Video": "Voice & Video",
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.", "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.",
@ -1508,6 +1509,8 @@
"Your message was sent": "Your message was sent", "Your message was sent": "Your message was sent",
"Failed to send": "Failed to send", "Failed to send": "Failed to send",
"Scroll to most recent messages": "Scroll to most recent messages", "Scroll to most recent messages": "Scroll to most recent messages",
"Show %(count)s other previews|other": "Show %(count)s other previews",
"Show %(count)s other previews|one": "Show %(count)s other preview",
"Close preview": "Close preview", "Close preview": "Close preview",
"and %(count)s others...|other": "and %(count)s others...", "and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...", "and %(count)s others...|one": "and one other...",

View File

@ -22,7 +22,6 @@ import { RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS } from "./RightPanelStoreP
import { ActionPayload } from "../dispatcher/payloads"; import { ActionPayload } from "../dispatcher/payloads";
import { Action } from '../dispatcher/actions'; import { Action } from '../dispatcher/actions';
import { SettingLevel } from "../settings/SettingLevel"; import { SettingLevel } from "../settings/SettingLevel";
import RoomViewStore from './RoomViewStore';
interface RightPanelStoreState { interface RightPanelStoreState {
// Whether or not to show the right panel at all. We split out rooms and groups // Whether or not to show the right panel at all. We split out rooms and groups
@ -68,6 +67,7 @@ const MEMBER_INFO_PHASES = [
export default class RightPanelStore extends Store<ActionPayload> { export default class RightPanelStore extends Store<ActionPayload> {
private static instance: RightPanelStore; private static instance: RightPanelStore;
private state: RightPanelStoreState; private state: RightPanelStoreState;
private lastRoomId: string;
constructor() { constructor() {
super(dis); super(dis);
@ -147,8 +147,10 @@ export default class RightPanelStore extends Store<ActionPayload> {
__onDispatch(payload: ActionPayload) { __onDispatch(payload: ActionPayload) {
switch (payload.action) { switch (payload.action) {
case 'view_room': case 'view_room':
if (payload.room_id === this.lastRoomId) break; // skip this transition, probably a permalink
// fallthrough
case 'view_group': case 'view_group':
if (payload.room_id === RoomViewStore.getRoomId()) break; // skip this transition, probably a permalink this.lastRoomId = payload.room_id;
// Reset to the member list if we're viewing member info // Reset to the member list if we're viewing member info
if (MEMBER_INFO_PHASES.includes(this.state.lastRoomPhase)) { if (MEMBER_INFO_PHASES.includes(this.state.lastRoomPhase)) {

View File

@ -0,0 +1,32 @@
/*
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 { clamp } from "lodash";
/**
* This method linearly interpolates between two points (start, end). This is
* most commonly used to find a point some fraction of the way along a line
* between two endpoints (e.g. to move an object gradually between those
* points).
* @param {number} start the starting point
* @param {number} end the ending point
* @param {number} amt the interpolant
* @returns
*/
export function lerp(start: number, end: number, amt: number) {
amt = clamp(amt, 0, 1);
return (1 - amt) * start + amt * end;
}

View File

@ -0,0 +1,54 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { arrayFastClone, arraySeed } from "./arrays";
/**
* An array which is of fixed length and accepts rolling values. Values will
* be inserted on the left, falling off the right.
*/
export class FixedRollingArray<T> {
private samples: T[] = [];
/**
* Creates a new fixed rolling array.
* @param width The width of the array.
* @param padValue The value to seed the array with.
*/
constructor(private width: number, padValue: T) {
this.samples = arraySeed(padValue, this.width);
}
/**
* The array, as a fixed length.
*/
public get value(): T[] {
return this.samples;
}
/**
* Pushes a value to the array.
* @param value The value to push.
*/
public pushValue(value: T) {
let swap = arrayFastClone(this.samples);
swap.splice(0, 0, value);
if (swap.length > this.width) {
swap = swap.slice(0, this.width);
}
this.samples = swap;
}
}

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); 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.
@ -21,7 +21,7 @@ limitations under the License.
* MIT license * MIT license
*/ */
function safariVersionCheck(ua) { function safariVersionCheck(ua: string): boolean {
console.log("Browser is Safari - checking version for COLR support"); console.log("Browser is Safari - checking version for COLR support");
try { try {
const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/); const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/);
@ -44,7 +44,7 @@ function safariVersionCheck(ua) {
return false; return false;
} }
async function isColrFontSupported() { async function isColrFontSupported(): Promise<boolean> {
console.log("Checking for COLR support"); console.log("Checking for COLR support");
const { userAgent } = navigator; const { userAgent } = navigator;
@ -101,7 +101,7 @@ async function isColrFontSupported() {
} }
let colrFontCheckStarted = false; let colrFontCheckStarted = false;
export async function fixupColorFonts() { export async function fixupColorFonts(): Promise<void> {
if (colrFontCheckStarted) { if (colrFontCheckStarted) {
return; return;
} }
@ -112,14 +112,14 @@ export async function fixupColorFonts() {
document.fonts.add(new FontFace("Twemoji", path, {})); document.fonts.add(new FontFace("Twemoji", path, {}));
// For at least Chrome on Windows 10, we have to explictly add extra // For at least Chrome on Windows 10, we have to explictly add extra
// weights for the emoji to appear in bold messages, etc. // weights for the emoji to appear in bold messages, etc.
document.fonts.add(new FontFace("Twemoji", path, { weight: 600 })); document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
document.fonts.add(new FontFace("Twemoji", path, { weight: 700 })); document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
} else { } else {
// fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix // fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix
const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`; const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`;
document.fonts.add(new FontFace("Twemoji", path, {})); document.fonts.add(new FontFace("Twemoji", path, {}));
document.fonts.add(new FontFace("Twemoji", path, { weight: 600 })); document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
document.fonts.add(new FontFace("Twemoji", path, { weight: 700 })); document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
} }
// ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified. // ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified.
} }

View File

@ -39,6 +39,9 @@ const UNKNOWN_PROFILE_ERRORS = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UN
export type CompletionStates = Record<string, InviteState>; export type CompletionStates = Record<string, InviteState>;
const USER_ALREADY_JOINED = "IO.ELEMENT.ALREADY_JOINED";
const USER_ALREADY_INVITED = "IO.ELEMENT.ALREADY_INVITED";
/** /**
* Invites multiple addresses to a room or group, handling rate limiting from the server * Invites multiple addresses to a room or group, handling rate limiting from the server
*/ */
@ -130,9 +133,14 @@ export default class MultiInviter {
if (!room) throw new Error("Room not found"); if (!room) throw new Error("Room not found");
const member = room.getMember(addr); const member = room.getMember(addr);
if (member && ['join', 'invite'].includes(member.membership)) { if (member.membership === "join") {
throw new new MatrixError({ throw new MatrixError({
errcode: "RIOT.ALREADY_IN_ROOM", errcode: USER_ALREADY_JOINED,
error: "Member already joined",
});
} else if (member.membership === "invite") {
throw new MatrixError({
errcode: USER_ALREADY_INVITED,
error: "Member already invited", error: "Member already invited",
}); });
} }
@ -180,30 +188,47 @@ export default class MultiInviter {
let errorText; let errorText;
let fatal = false; let fatal = false;
if (err.errcode === 'M_FORBIDDEN') { switch (err.errcode) {
fatal = true; case "M_FORBIDDEN":
errorText = _t('You do not have permission to invite people to this room.'); errorText = _t('You do not have permission to invite people to this room.');
} else if (err.errcode === "RIOT.ALREADY_IN_ROOM") { fatal = true;
break;
case USER_ALREADY_INVITED:
errorText = _t("User %(userId)s is already invited to the room", { userId: address });
break;
case USER_ALREADY_JOINED:
errorText = _t("User %(userId)s is already in the room", { userId: address }); errorText = _t("User %(userId)s is already in the room", { userId: address });
} else if (err.errcode === 'M_LIMIT_EXCEEDED') { break;
case "M_LIMIT_EXCEEDED":
// we're being throttled so wait a bit & try again // we're being throttled so wait a bit & try again
setTimeout(() => { setTimeout(() => {
this.doInvite(address, ignoreProfile).then(resolve, reject); this.doInvite(address, ignoreProfile).then(resolve, reject);
}, 5000); }, 5000);
return; return;
} else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND'].includes(err.errcode)) { case "M_NOT_FOUND":
case "M_USER_NOT_FOUND":
errorText = _t("User %(user_id)s does not exist", { user_id: address }); errorText = _t("User %(user_id)s does not exist", { user_id: address });
} else if (err.errcode === 'M_PROFILE_UNDISCLOSED') { break;
case "M_PROFILE_UNDISCLOSED":
errorText = _t("User %(user_id)s may or may not exist", { user_id: address }); errorText = _t("User %(user_id)s may or may not exist", { user_id: address });
} else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) { break;
case "M_PROFILE_NOT_FOUND":
if (!ignoreProfile) {
// Invite without the profile check // Invite without the profile check
console.warn(`User ${address} does not have a profile - inviting anyways automatically`); console.warn(`User ${address} does not have a profile - inviting anyways automatically`);
this.doInvite(address, true).then(resolve, reject); this.doInvite(address, true).then(resolve, reject);
} else if (err.errcode === "M_BAD_STATE") { return;
}
break;
case "M_BAD_STATE":
errorText = _t("The user must be unbanned before they can be invited."); errorText = _t("The user must be unbanned before they can be invited.");
} else if (err.errcode === "M_UNSUPPORTED_ROOM_VERSION") { break;
case "M_UNSUPPORTED_ROOM_VERSION":
errorText = _t("The user's homeserver does not support the version of the room."); errorText = _t("The user's homeserver does not support the version of the room.");
} else { break;
}
if (!errorText) {
errorText = _t('Unknown server error'); errorText = _t('Unknown server error');
} }

View File

@ -112,11 +112,9 @@ export function arrayRescale(input: number[], newMin: number, newMax: number): n
* @returns {T[]} The array. * @returns {T[]} The array.
*/ */
export function arraySeed<T>(val: T, length: number): T[] { export function arraySeed<T>(val: T, length: number): T[] {
const a: T[] = []; // Size the array up front for performance, and use `fill` to let the browser
for (let i = 0; i < length; i++) { // optimize the operation better than we can with a `for` loop, if it wants.
a.push(val); return new Array<T>(length).fill(val);
}
return a;
} }
/** /**

View File

@ -31,6 +31,7 @@ export enum PlaybackState {
} }
export const PLAYBACK_WAVEFORM_SAMPLES = 39; export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const THUMBNAIL_WAVEFORM_SAMPLES = 100; // arbitrary: [30,120]
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
function makePlaybackWaveform(input: number[]): number[] { function makePlaybackWaveform(input: number[]): number[] {
@ -51,6 +52,12 @@ function makePlaybackWaveform(input: number[]): number[] {
} }
export class Playback extends EventEmitter implements IDestroyable { export class Playback extends EventEmitter implements IDestroyable {
/**
* Stable waveform for representing a thumbnail of the media. Values are
* guaranteed to be between zero and one, inclusive.
*/
public readonly thumbnailWaveform: number[];
private readonly context: AudioContext; private readonly context: AudioContext;
private source: AudioBufferSourceNode; private source: AudioBufferSourceNode;
private state = PlaybackState.Decoding; private state = PlaybackState.Decoding;
@ -72,6 +79,7 @@ export class Playback extends EventEmitter implements IDestroyable {
this.fileSize = this.buf.byteLength; this.fileSize = this.buf.byteLength;
this.context = createAudioContext(); this.context = createAudioContext();
this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES); this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES);
this.thumbnailWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, THUMBNAIL_WAVEFORM_SAMPLES);
this.waveformObservable.update(this.resampledWaveform); this.waveformObservable.update(this.resampledWaveform);
this.clock = new PlaybackClock(this.context); this.clock = new PlaybackClock(this.context);
} }

View File

@ -22,14 +22,29 @@ declare const currentTime: number;
// declare const currentFrame: number; // declare const currentFrame: number;
// declare const sampleRate: number; // declare const sampleRate: number;
// We rate limit here to avoid overloading downstream consumers with amplitude information.
// The two major consumers are the voice message waveform thumbnail (resampled down to an
// appropriate length) and the live waveform shown to the user. Effectively, this controls
// the refresh rate of that live waveform and the number of samples the thumbnail has to
// work with.
const TARGET_AMPLITUDE_FREQUENCY = 16; // Hz
function roundTimeToTargetFreq(seconds: number): number {
// Epsilon helps avoid floating point rounding issues (1 + 1 = 1.999999, etc)
return Math.round((seconds + Number.EPSILON) * TARGET_AMPLITUDE_FREQUENCY) / TARGET_AMPLITUDE_FREQUENCY;
}
function nextTimeForTargetFreq(roundedSeconds: number): number {
// The extra round is just to make sure we cut off any floating point issues
return roundTimeToTargetFreq(roundedSeconds + (1 / TARGET_AMPLITUDE_FREQUENCY));
}
class MxVoiceWorklet extends AudioWorkletProcessor { class MxVoiceWorklet extends AudioWorkletProcessor {
private nextAmplitudeSecond = 0; private nextAmplitudeSecond = 0;
private amplitudeIndex = 0;
process(inputs, outputs, parameters) { process(inputs, outputs, parameters) {
// We only fire amplitude updates once a second to avoid flooding the recording instance const currentSecond = roundTimeToTargetFreq(currentTime);
// with useless data. Much of the data would end up discarded, so we ratelimit ourselves
// here.
const currentSecond = Math.round(currentTime);
if (currentSecond === this.nextAmplitudeSecond) { if (currentSecond === this.nextAmplitudeSecond) {
// We're expecting exactly one mono input source, so just grab the very first frame of // We're expecting exactly one mono input source, so just grab the very first frame of
// samples for the analysis. // samples for the analysis.
@ -47,9 +62,9 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
this.port.postMessage(<IAmplitudePayload>{ this.port.postMessage(<IAmplitudePayload>{
ev: PayloadEvent.AmplitudeMark, ev: PayloadEvent.AmplitudeMark,
amplitude: amplitude, amplitude: amplitude,
forSecond: currentSecond, forIndex: this.amplitudeIndex++,
}); });
this.nextAmplitudeSecond++; this.nextAmplitudeSecond = nextTimeForTargetFreq(currentSecond);
} }
// We mostly use this worklet to fire regular clock updates through to components // We mostly use this worklet to fire regular clock updates through to components

View File

@ -19,7 +19,6 @@ import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import MediaDeviceHandler from "../MediaDeviceHandler"; import MediaDeviceHandler from "../MediaDeviceHandler";
import { SimpleObservable } from "matrix-widget-api"; import { SimpleObservable } from "matrix-widget-api";
import { clamp, percentageOf, percentageWithin } from "../utils/numbers";
import EventEmitter from "events"; import EventEmitter from "events";
import { IDestroyable } from "../utils/IDestroyable"; import { IDestroyable } from "../utils/IDestroyable";
import { Singleflight } from "../utils/Singleflight"; import { Singleflight } from "../utils/Singleflight";
@ -29,6 +28,8 @@ import { Playback } from "./Playback";
import { createAudioContext } from "./compat"; import { createAudioContext } from "./compat";
import { IEncryptedFile } from "matrix-js-sdk/src/@types/event"; import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
import { uploadFile } from "../ContentMessages"; import { uploadFile } from "../ContentMessages";
import { FixedRollingArray } from "../utils/FixedRollingArray";
import { clamp } from "../utils/numbers";
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.
@ -61,7 +62,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private recorderContext: AudioContext; private recorderContext: AudioContext;
private recorderSource: MediaStreamAudioSourceNode; private recorderSource: MediaStreamAudioSourceNode;
private recorderStream: MediaStream; private recorderStream: MediaStream;
private recorderFFT: AnalyserNode;
private recorderWorklet: AudioWorkletNode; private recorderWorklet: AudioWorkletNode;
private recorderProcessor: ScriptProcessorNode; private recorderProcessor: ScriptProcessorNode;
private buffer = new Uint8Array(0); // use this.audioBuffer to access private buffer = new Uint8Array(0); // use this.audioBuffer to access
@ -70,6 +70,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private observable: SimpleObservable<IRecordingUpdate>; private observable: SimpleObservable<IRecordingUpdate>;
private amplitudes: number[] = []; // at each second mark, generated private amplitudes: number[] = []; // at each second mark, generated
private playback: Playback; private playback: Playback;
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
public constructor(private client: MatrixClient) { public constructor(private client: MatrixClient) {
super(); super();
@ -111,14 +112,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing) // latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing)
}); });
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
this.recorderFFT = this.recorderContext.createAnalyser();
// Bring the FFT time domain down a bit. The default is 2048, and this must be a power
// of two. We use 64 points because we happen to know down the line we need less than
// that, but 32 would be too few. Large numbers are not helpful here and do not add
// precision: they introduce higher precision outputs of the FFT (frequency data), but
// it makes the time domain less than helpful.
this.recorderFFT.fftSize = 64;
// Set up our worklet. We use this for timing information and waveform analysis: the // 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. // web audio API prefers this be done async to avoid holding the main thread with math.
@ -129,8 +122,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
} }
// Connect our inputs and outputs // Connect our inputs and outputs
this.recorderSource.connect(this.recorderFFT);
if (this.recorderContext.audioWorklet) { if (this.recorderContext.audioWorklet) {
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);
@ -145,8 +136,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
break; break;
case PayloadEvent.AmplitudeMark: case PayloadEvent.AmplitudeMark:
// Sanity check to make sure we're adding about one sample per second // Sanity check to make sure we're adding about one sample per second
if (ev.data['forSecond'] === this.amplitudes.length) { if (ev.data['forIndex'] === this.amplitudes.length) {
this.amplitudes.push(ev.data['amplitude']); this.amplitudes.push(ev.data['amplitude']);
this.liveWaveform.pushValue(ev.data['amplitude']);
} }
break; break;
} }
@ -231,36 +223,8 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private processAudioUpdate = (timeSeconds: number) => { private processAudioUpdate = (timeSeconds: number) => {
if (!this.recording) return; if (!this.recording) return;
// The time domain is the input to the FFT, which means we use an array of the same
// size. The time domain is also known as the audio waveform. We're ignoring the
// output of the FFT here (frequency data) because we're not interested in it.
const data = new Float32Array(this.recorderFFT.fftSize);
if (!this.recorderFFT.getFloatTimeDomainData) {
// Safari compat
const data2 = new Uint8Array(this.recorderFFT.fftSize);
this.recorderFFT.getByteTimeDomainData(data2);
for (let i = 0; i < data2.length; i++) {
data[i] = percentageWithin(percentageOf(data2[i], 0, 256), -1, 1);
}
} else {
this.recorderFFT.getFloatTimeDomainData(data);
}
// We can't just `Array.from()` the array because we're dealing with 32bit floats
// and the built-in function won't consider that when converting between numbers.
// However, the runtime will convert the float32 to a float64 during the math operations
// which is why the loop works below. Note that a `.map()` call also doesn't work
// and will instead return a Float32Array still.
const translatedData: number[] = [];
for (let i = 0; i < data.length; i++) {
// We're clamping the values so we can do that math operation mentioned above,
// and to ensure that we produce consistent data (it's possible for the array
// to exceed the specified range with some audio input devices).
translatedData.push(clamp(data[i], 0, 1));
}
this.observable.update({ this.observable.update({
waveform: translatedData, waveform: this.liveWaveform.value.map(v => clamp(v, 0, 1)),
timeSeconds: timeSeconds, timeSeconds: timeSeconds,
}); });

View File

@ -32,6 +32,6 @@ export interface ITimingPayload extends IPayload {
export interface IAmplitudePayload extends IPayload { export interface IAmplitudePayload extends IPayload {
ev: PayloadEvent.AmplitudeMark; ev: PayloadEvent.AmplitudeMark;
forSecond: number; forIndex: number;
amplitude: number; amplitude: number;
} }

View File

@ -22,8 +22,10 @@ import sdk from "../../../skinned-sdk";
import { mkEvent, mkStubRoom } from "../../../test-utils"; import { mkEvent, mkStubRoom } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import * as languageHandler from "../../../../src/languageHandler"; import * as languageHandler from "../../../../src/languageHandler";
import * as TestUtils from "../../../test-utils";
const TextualBody = sdk.getComponent("views.messages.TextualBody"); const _TextualBody = sdk.getComponent("views.messages.TextualBody");
const TextualBody = TestUtils.wrapInMatrixClientContext(_TextualBody);
configure({ adapter: new Adapter() }); configure({ adapter: new Adapter() });
@ -302,13 +304,12 @@ describe("<TextualBody />", () => {
event: true, event: true,
}); });
const wrapper = mount(<TextualBody mxEvent={ev} showUrlPreview={true} />); const wrapper = mount(<TextualBody mxEvent={ev} showUrlPreview={true} onHeightChanged={() => {}} />);
expect(wrapper.text()).toBe(ev.getContent().body); expect(wrapper.text()).toBe(ev.getContent().body);
let widgets = wrapper.find("LinkPreviewWidget"); let widgets = wrapper.find("LinkPreviewGroup");
// at this point we should have exactly one widget // at this point we should have exactly one link
expect(widgets.length).toBe(1); expect(widgets.at(0).prop("links")).toEqual(["https://matrix.org/"]);
expect(widgets.at(0).prop("link")).toBe("https://matrix.org/");
// simulate an event edit and check the transition from the old URL preview to the new one // simulate an event edit and check the transition from the old URL preview to the new one
const ev2 = mkEvent({ const ev2 = mkEvent({
@ -333,11 +334,9 @@ describe("<TextualBody />", () => {
// XXX: this is to give TextualBody enough time for state to settle // XXX: this is to give TextualBody enough time for state to settle
wrapper.setState({}, () => { wrapper.setState({}, () => {
widgets = wrapper.find("LinkPreviewWidget"); widgets = wrapper.find("LinkPreviewGroup");
// at this point we should have exactly two widgets (not the matrix.org one anymore) // at this point we should have exactly two links (not the matrix.org one anymore)
expect(widgets.length).toBe(2); expect(widgets.at(0).prop("links")).toEqual(["https://vector.im/", "https://riot.im/"]);
expect(widgets.at(0).prop("link")).toBe("https://vector.im/");
expect(widgets.at(1).prop("link")).toBe("https://riot.im/");
}); });
}); });
}); });

View File

@ -0,0 +1,35 @@
/*
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 { lerp } from "../../src/utils/AnimationUtils";
describe("lerp", () => {
it("correctly interpolates", () => {
expect(lerp(0, 100, 0.5)).toBe(50);
expect(lerp(50, 100, 0.5)).toBe(75);
expect(lerp(0, 1, 0.1)).toBe(0.1);
});
it("clamps the interpolant", () => {
expect(lerp(0, 100, 50)).toBe(100);
expect(lerp(0, 100, -50)).toBe(0);
});
it("handles negative numbers", () => {
expect(lerp(-100, 0, 0.5)).toBe(-50);
expect(lerp(100, -100, 0.5)).toBe(0);
});
});

View File

@ -0,0 +1,65 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FixedRollingArray } from "../../src/utils/FixedRollingArray";
describe('FixedRollingArray', () => {
it('should seed the array with the given value', () => {
const seed = "test";
const width = 24;
const array = new FixedRollingArray(width, seed);
expect(array.value.length).toBe(width);
expect(array.value.every(v => v === seed)).toBe(true);
});
it('should insert at the correct end', () => {
const seed = "test";
const value = "changed";
const width = 24;
const array = new FixedRollingArray(width, seed);
array.pushValue(value);
expect(array.value.length).toBe(width);
expect(array.value[0]).toBe(value);
});
it('should roll over', () => {
const seed = -1;
const width = 24;
const array = new FixedRollingArray(width, seed);
const maxValue = width * 2;
const minValue = width; // because we're forcing a rollover
for (let i = 0; i <= maxValue; i++) {
array.pushValue(i);
}
expect(array.value.length).toBe(width);
for (let i = 1; i < width; i++) {
const current = array.value[i];
const previous = array.value[i - 1];
expect(previous - current).toBe(1);
if (i === 1) {
expect(previous).toBe(maxValue);
} else if (i === width) {
expect(current).toBe(minValue);
}
}
});
});

View File

@ -1488,6 +1488,11 @@
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
"@types/css-font-loading-module@^0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.6.tgz#1ac3417ed31eeb953134d29b56bca921644b87c0"
integrity sha512-MBvSMSxXFtIukyXRU3HhzL369rIWaqMVQD5kmDCYIFFD6Fe3lJ4c9UnLD02MLdTp7Z6ti7rO3SQtuDo7C80mmw==
"@types/diff-match-patch@^1.0.32": "@types/diff-match-patch@^1.0.32":
version "1.0.32" version "1.0.32"
resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz#d9c3b8c914aa8229485351db4865328337a3d09f" resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz#d9c3b8c914aa8229485351db4865328337a3d09f"