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

 Conflicts:
	res/css/structures/_SpaceRoomView.scss
	src/components/structures/SpaceRoomView.tsx
This commit is contained in:
Michael Telatynski 2021-04-29 09:52:21 +01:00
commit a4f02937cb
61 changed files with 1604 additions and 705 deletions

View file

@ -39,9 +39,9 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper";
import {SpaceStoreClass} from "../stores/SpaceStore";
import {VoiceRecording} from "../voice/VoiceRecording";
import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg";
import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
declare global {
interface Window {
@ -73,7 +73,7 @@ declare global {
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass;
mxVoiceRecorder: typeof VoiceRecording;
mxVoiceRecordingStore: VoiceRecordingStore;
mxTypingStore: TypingStore;
mxEventIndexPeg: EventIndexPeg;
}

View file

@ -258,7 +258,7 @@ export default abstract class BasePlatform {
return null;
}
setLanguage(preferredLangs: string[]) {}
async setLanguage(preferredLangs: string[]) {}
setSpellCheckLanguages(preferredLangs: string[]) {}

View file

@ -59,7 +59,6 @@ import {MatrixClientPeg} from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
@ -86,6 +85,8 @@ import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom';
export const PROTOCOL_PSTN = 'm.protocol.pstn';
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
@ -167,6 +168,11 @@ export default class CallHandler {
private invitedRoomsAreVirtual = new Map<string, boolean>();
private invitedRoomCheckInProgress = false;
// Map of the asserted identity users after we've looked them up using the API.
// We need to be be able to determine the mapped room synchronously, so we
// do the async lookup when we get new information and then store these mappings here
private assertedIdentityNativeUsers = new Map<string, string>();
static sharedInstance() {
if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler()
@ -179,8 +185,19 @@ export default class CallHandler {
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
* if a voip_mxid_translate_pattern is set in the config)
*/
public static roomIdForCall(call: MatrixCall): string {
public roomIdForCall(call: MatrixCall): string {
if (!call) return null;
const voipConfig = SdkConfig.get()['voip'];
if (voipConfig && voipConfig.obeyAssertedIdentity) {
const nativeUser = this.assertedIdentityNativeUsers[call.callId];
if (nativeUser) {
const room = findDMForUser(MatrixClientPeg.get(), nativeUser);
if (room) return room.roomId
}
}
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
}
@ -379,14 +396,14 @@ export default class CallHandler {
// We don't allow placing more than one call per room, but that doesn't mean there
// can't be more than one, eg. in a glare situation. This checks that the given call
// is the call we consider 'the' call for its room.
const mappedRoomId = CallHandler.roomIdForCall(call);
const mappedRoomId = this.roomIdForCall(call);
const callForThisRoom = this.getCallForRoom(mappedRoomId);
return callForThisRoom && call.callId === callForThisRoom.callId;
}
private setCallListeners(call: MatrixCall) {
const mappedRoomId = CallHandler.roomIdForCall(call);
let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
call.on(CallEvent.Error, (err: CallError) => {
if (!this.matchesCallForThisRoom(call)) return;
@ -500,6 +517,42 @@ export default class CallHandler {
this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state);
});
call.on(CallEvent.AssertedIdentityChanged, async () => {
if (!this.matchesCallForThisRoom(call)) return;
console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity());
const newAssertedIdentity = call.getRemoteAssertedIdentity().id;
let newNativeAssertedIdentity = newAssertedIdentity;
if (newAssertedIdentity) {
const response = await this.sipNativeLookup(newAssertedIdentity);
if (response.length) newNativeAssertedIdentity = response[0].userid;
}
console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
if (newNativeAssertedIdentity) {
this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity;
// If we don't already have a room with this user, make one. This will be slightly odd
// if they called us because we'll be inviting them, but there's not much we can do about
// this if we want the actual, native room to exist (which we do). This is why it's
// important to only obey asserted identity in trusted environments, since anyone you're
// on a call with can cause you to send a room invite to someone.
await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity);
const newMappedRoomId = this.roomIdForCall(call);
console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
if (newMappedRoomId !== mappedRoomId) {
this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId;
this.calls.set(mappedRoomId, call);
dis.dispatch({
action: Action.CallChangeRoom,
call,
});
}
}
});
}
private async logCallStats(call: MatrixCall, mappedRoomId: string) {
@ -551,7 +604,7 @@ export default class CallHandler {
}
private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.roomIdForCall(call);
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
console.log(
`Call state in ${mappedRoomId} changed to ${status}`,
@ -639,7 +692,7 @@ export default class CallHandler {
const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now();
console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
const call = MatrixClientPeg.get().createCall(mappedRoomId);
this.calls.set(roomId, call);
if (transferee) {
@ -772,7 +825,7 @@ export default class CallHandler {
const call = payload.call as MatrixCall;
const mappedRoomId = CallHandler.roomIdForCall(call);
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
// ignore multiple incoming calls to the same room
return;

View file

@ -57,7 +57,11 @@ export default class VoipUserMapper {
if (!virtualRoom) return null;
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
return virtualRoomEvent.getContent()['native_room'] || null;
const nativeRoomID = virtualRoomEvent.getContent()['native_room'];
const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID);
if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null;
return nativeRoomID;
}
public isVirtualRoom(room: Room): boolean {

View file

@ -128,7 +128,11 @@ export default class RoomStatusBar extends React.Component {
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
if (room.roomId !== this.props.room.roomId) return;
this.setState({unsentMessages: getUnsentMessages(this.props.room)});
const messages = getUnsentMessages(this.props.room);
this.setState({
unsentMessages: messages,
isResending: messages.length > 0 && this.state.isResending,
});
};
// Check whether current size is greater than 0, if yes call props.onVisible

View file

@ -525,7 +525,7 @@ export default class ScrollPanel extends React.Component {
*/
scrollRelative = mult => {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
const delta = mult * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta);
this._saveScrollState();
};

View file

@ -51,6 +51,9 @@ import MemberAvatar from "../views/avatars/MemberAvatar";
import {useStateToggle} from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
import {allSettled} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
import {BetaPill} from "../views/beta/BetaCard";
import {USER_LABS_TAB} from "../views/dialogs/UserSettingsDialog";
import SettingsStore from "../../settings/SettingsStore";
@ -399,7 +402,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue")
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue");
}
return <div>
@ -421,6 +424,65 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
</div>;
};
const SpaceAddExistingRooms = ({ space, onFinished }) => {
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let onClick = onFinished;
let buttonLabel = _t("Skip for now");
if (selectedToAdd.size > 0) {
onClick = async () => {
// TODO rate limiting
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
};
buttonLabel = busy ? _t("Adding...") : _t("Add");
}
return <div>
<h1>{ _t("What do you want to organise?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Pick rooms or conversations to add. This is just a space for you, " +
"no one will be informed. You can add more later.") }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={(checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
>
{ buttonLabel }
</AccessibleButton>
</div>
</div>;
};
const SpaceSetupPublicShare = ({ space, onFinished }) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
@ -718,7 +780,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
space={this.props.space}
justCreatedOpts={this.props.justCreatedOpts}
onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms });
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
}}
/>;
case Phase.PrivateInvite:
@ -734,6 +796,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
"You can add more later too, including already existing ones.")}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import React, {useContext, useState} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -33,6 +33,7 @@ import {allSettled} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -41,31 +42,35 @@ interface IProps extends IDialogProps {
}
const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpaceDialog_entry">
return <label className="mx_AddExistingToSpace_entry">
<RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
</label>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
interface IAddExistingToSpaceProps {
space: Room;
selected: Set<Room>;
onChange(checked: boolean, room: Room): void;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space, selected, onChange }) => {
const cli = useContext(MatrixClientContext);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const [selectedSpace, setSelectedSpace] = useState(space);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId));
const joinRule = selectedSpace.getJoinRule();
const joinRule = space.getJoinRule();
const [spaces, rooms, dms] = cli.getVisibleRooms().reduce((arr, room) => {
if (room.getMyMembership() !== "join") return arr;
if (!room.name.toLowerCase().includes(lcQuery)) return arr;
if (room.isSpaceRoom()) {
if (room !== space && room !== selectedSpace && !existingSubspacesSet.has(room)) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
@ -79,11 +84,80 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
return arr;
}, [[], [], []]);
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selected.has(space)}
onChange={(checked) => {
onChange(checked, space);
}}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let spaceOptionSection;
if (existingSubspacesSet.size > 0) {
if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
@ -123,87 +197,26 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
<MatrixClientContext.Provider value={cli}>
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={(checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>
</MatrixClientContext.Provider>
<div className="mx_AddExistingToSpaceDialog_footer">
<span>
@ -217,6 +230,7 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
kind="primary"
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
// TODO rate limiting
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>

View file

@ -198,6 +198,7 @@ interface IState {
export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
constructor(props) {
super(props);
@ -322,7 +323,15 @@ export default class MessageComposer extends React.Component<IProps, IState> {
});
}
sendMessage = () => {
sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
await this.voiceRecordingButton.send();
return;
}
// XXX: Private function access
this.messageComposerInput._sendMessage();
}
@ -387,6 +396,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_voice_messages")) {
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
ref={c => this.voiceRecordingButton = c}
room={this.props.room} />);
}

View file

@ -763,7 +763,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
'mx_RoomSublist': true,
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist_minimized': this.props.isMinimized,
'mx_RoomSublist_hidden': !this.state.rooms.length && this.props.alwaysVisible !== true,
'mx_RoomSublist_hidden': (
!this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true
),
});
let content = null;

View file

@ -506,9 +506,8 @@ export default class SendMessageComposer extends React.Component {
member.rawDisplayName : userId;
const caret = this._editorRef.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
// index is -1 if there are no parts but we only care for if this would be the part in position 0
const insertIndex = position.index > 0 ? position.index : 0;
const parts = partCreator.createMentionParts(insertIndex, displayName, userId);
// Insert suffix only if the caret is at the start of the composer
const parts = partCreator.createMentionParts(caret.offset === 0, displayName, userId);
model.transform(() => {
const addedLen = model.insert(parts, position);
return model.positionForOffset(caret.offset + addedLen, true);

View file

@ -16,8 +16,8 @@ limitations under the License.
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {_t} from "../../../languageHandler";
import React from "react";
import {VoiceRecording} from "../../../voice/VoiceRecording";
import React, {ReactNode} from "react";
import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import classNames from "classnames";
@ -25,6 +25,8 @@ import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
interface IProps {
room: Room;
@ -32,6 +34,7 @@ interface IProps {
interface IState {
recorder?: VoiceRecording;
recordingPhase?: RecordingState;
}
/**
@ -43,87 +46,141 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
super(props);
this.state = {
recorder: null, // not recording by default
recorder: null, // no recording started by default
};
}
private onStartStopVoiceMessage = async () => {
// TODO: @@ TravisR: We do not want to auto-send on stop.
public async componentWillUnmount() {
await VoiceRecordingStore.instance.disposeRecording();
}
// called by composer
public async send() {
if (!this.state.recorder) {
throw new Error("No recording started - cannot send anything");
}
await this.state.recorder.stop();
const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message",
"msgtype": "org.matrix.msc2516.voice",
//"msgtype": MsgType.Audio,
"url": mxc,
"info": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
// MSC1767 experiment
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: mxc,
name: "Voice message.ogg",
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
"org.matrix.msc1767.audio": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// TODO: @@ TravisR: Waveform? (MSC1767 decision)
},
"org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// Events can't have floats, so we try to maintain resolution by using 1024
// as a maximum value. The waveform contains values between zero and 1, so this
// should come out largely sane.
//
// We're expecting about one data point per second of audio.
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
},
});
await this.disposeRecording();
}
private async disposeRecording() {
await VoiceRecordingStore.instance.disposeRecording();
// Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({recorder: null, recordingPhase: null});
}
private onCancel = async () => {
await this.disposeRecording();
};
private onRecordStartEndClick = async () => {
if (this.state.recorder) {
await this.state.recorder.stop();
const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message",
"msgtype": "org.matrix.msc2516.voice",
//"msgtype": MsgType.Audio,
"url": mxc,
"info": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
// MSC1767 experiment
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: mxc,
name: "Voice message.ogg",
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
"org.matrix.msc1767.audio": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// TODO: @@ TravisR: Waveform? (MSC1767 decision)
},
"org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// Events can't have floats, so we try to maintain resolution by using 1024
// as a maximum value. The waveform contains values between zero and 1, so this
// should come out largely sane.
//
// We're expecting about one data point per second of audio.
waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)),
},
});
await VoiceRecordingStore.instance.disposeRecording();
this.setState({recorder: null});
return;
}
const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start();
this.setState({recorder});
// We don't need to remove the listener: the recorder will clean that up for us.
recorder.on(UPDATE_EVENT, (ev: RecordingState) => {
if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here
this.setState({recordingPhase: ev});
});
this.setState({recorder, recordingPhase: RecordingState.Started});
};
private renderWaveformArea() {
if (!this.state.recorder) return null;
private renderWaveformArea(): ReactNode {
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
return <div className='mx_VoiceRecordComposerTile_waveformContainer'>
if (this.state.recordingPhase !== RecordingState.Started) {
// TODO: @@ TR: Should we disable this during upload? What does a failed upload look like?
return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
}
// only other UI is the recording-in-progress UI
return <div className="mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
<LiveRecordingClock recorder={this.state.recorder} />
<LiveRecordingWaveform recorder={this.state.recorder} />
</div>;
}
public render() {
const classes = classNames({
'mx_MessageComposer_button': !this.state.recorder,
'mx_MessageComposer_voiceMessage': !this.state.recorder,
'mx_VoiceRecordComposerTile_stop': !!this.state.recorder,
});
public render(): ReactNode {
let recordingInfo;
let deleteButton;
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
const classes = classNames({
'mx_MessageComposer_button': !this.state.recorder,
'mx_MessageComposer_voiceMessage': !this.state.recorder,
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
});
let tooltip = _t("Record a voice message");
if (!!this.state.recorder) {
// TODO: @@ TravisR: Change to match behaviour
tooltip = _t("Stop & send recording");
let tooltip = _t("Record a voice message");
if (!!this.state.recorder) {
tooltip = _t("Stop the recording");
}
let stopOrRecordBtn = <AccessibleTooltipButton
className={classes}
onClick={this.onRecordStartEndClick}
title={tooltip}
/>;
if (this.state.recorder && !this.state.recorder?.isRecording) {
stopOrRecordBtn = null;
}
recordingInfo = stopOrRecordBtn;
}
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
deleteButton = <AccessibleTooltipButton
className='mx_VoiceRecordComposerTile_delete'
title={_t("Delete recording")}
onClick={this.onCancel}
/>;
}
return (<>
{deleteButton}
{this.renderWaveformArea()}
<AccessibleTooltipButton
className={classes}
onClick={this.onStartStopVoiceMessage}
title={tooltip}
/>
{recordingInfo}
</>);
}
}

View file

@ -192,7 +192,11 @@ export default class GeneralUserSettingsTab extends React.Component {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage);
this.setState({language: newLanguage});
PlatformPeg.get().reload();
const platform = PlatformPeg.get();
if (platform) {
platform.setLanguage(newLanguage);
platform.reload();
}
};
_onSpellCheckLanguagesChange = (languages) => {

View file

@ -255,10 +255,9 @@ export default class SecurityUserSettingsTab extends React.Component {
_renderIgnoredUsers() {
const {waitingUnignored, ignoredUserIds} = this.state;
if (!ignoredUserIds || ignoredUserIds.length === 0) return null;
const userIds = ignoredUserIds
.map((u) => <IgnoredUser
const userIds = !ignoredUserIds?.length
? _t('You have no ignored users.')
: ignoredUserIds.map((u) => <IgnoredUser
userId={u}
onUnignored={this._onUserUnignored}
key={u}

View file

@ -29,14 +29,20 @@ interface IState {
* displayed, making it possible to see "82:29".
*/
@replaceableComponent("views.voice_messages.Clock")
export default class Clock extends React.PureComponent<IProps, IState> {
export default class Clock extends React.Component<IProps, IState> {
public constructor(props) {
super(props);
}
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor;
}
public render() {
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
const seconds = Math.round(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
return <span className='mx_Clock'>{minutes}:{seconds}</span>;
}
}

View file

@ -31,7 +31,7 @@ interface IState {
* A clock for a live recording.
*/
@replaceableComponent("views.voice_messages.LiveRecordingClock")
export default class LiveRecordingClock extends React.Component<IProps, IState> {
export default class LiveRecordingClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
super(props);
@ -39,12 +39,6 @@ export default class LiveRecordingClock extends React.Component<IProps, IState>
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
}
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
const currentFloor = Math.floor(this.state.seconds);
const nextFloor = Math.floor(nextState.seconds);
return currentFloor !== nextFloor;
}
private onRecordingUpdate = (update: IRecordingUpdate) => {
this.setState({seconds: update.timeSeconds});
};

View file

@ -20,6 +20,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
import {arrayFastResample, arraySeed} from "../../../utils/arrays";
import {percentageOf} from "../../../utils/numbers";
import Waveform from "./Waveform";
import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
interface IProps {
recorder: VoiceRecording;
@ -29,8 +30,6 @@ interface IState {
heights: number[];
}
const DOWNSAMPLE_TARGET = 35; // number of bars we want
/**
* A waveform which shows the waveform of a live recording
*/
@ -39,14 +38,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
public constructor(props) {
super(props);
this.state = {heights: arraySeed(0, DOWNSAMPLE_TARGET)};
this.state = {heights: arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES)};
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
}
private onRecordingUpdate = (update: IRecordingUpdate) => {
// The waveform and the downsample target are pretty close, so we should be fine to
// do this, despite the docs on arrayFastResample.
const bars = arrayFastResample(Array.from(update.waveform), DOWNSAMPLE_TARGET);
const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES);
this.setState({
// The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the

View file

@ -0,0 +1,61 @@
/*
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, {ReactNode} from "react";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {_t} from "../../../languageHandler";
import {Playback, PlaybackState} from "../../../voice/Playback";
import classNames from "classnames";
interface IProps {
// Playback instance to manipulate. Cannot change during the component lifecycle.
playback: Playback;
// The playback phase to render. Able to change during the component lifecycle.
playbackPhase: PlaybackState;
}
/**
* Displays a play/pause button (activating the play/pause function of the recorder)
* to be displayed in reference to a recording.
*/
@replaceableComponent("views.voice_messages.PlayPauseButton")
export default class PlayPauseButton extends React.PureComponent<IProps> {
public constructor(props) {
super(props);
}
private onClick = async () => {
await this.props.playback.toggle();
};
public render(): ReactNode {
const isPlaying = this.props.playback.isPlaying;
const isDisabled = this.props.playbackPhase === PlaybackState.Decoding;
const classes = classNames('mx_PlayPauseButton', {
'mx_PlayPauseButton_play': !isPlaying,
'mx_PlayPauseButton_pause': isPlaying,
'mx_PlayPauseButton_disabled': isDisabled,
});
return <AccessibleTooltipButton
className={classes}
title={isPlaying ? _t("Pause") : _t("Play")}
onClick={this.onClick}
disabled={isDisabled}
/>;
}
}

View file

@ -0,0 +1,71 @@
/*
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 from "react";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import {Playback, PlaybackState} from "../../../voice/Playback";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
interface IProps {
playback: Playback;
}
interface IState {
seconds: number;
durationSeconds: number;
playbackPhase: PlaybackState;
}
/**
* A clock for a playback of a recording.
*/
@replaceableComponent("views.voice_messages.PlaybackClock")
export default class PlaybackClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
super(props);
this.state = {
seconds: this.props.playback.clockInfo.timeSeconds,
// we track the duration on state because we won't really know what the clip duration
// is until the first time update, and as a PureComponent we are trying to dedupe state
// updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
// member property to track "did we get a duration".
durationSeconds: this.props.playback.clockInfo.durationSeconds,
playbackPhase: PlaybackState.Stopped, // assume not started, so full clock
};
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onPlaybackUpdate = (ev: PlaybackState) => {
// Convert Decoding -> Stopped because we don't care about the distinction here
if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped;
this.setState({playbackPhase: ev});
};
private onTimeUpdate = (time: number[]) => {
this.setState({seconds: time[0], durationSeconds: time[1]});
};
public render() {
let seconds = this.state.seconds;
if (this.state.playbackPhase === PlaybackState.Stopped) {
seconds = this.state.durationSeconds;
}
return <Clock seconds={seconds} />;
}
}

View file

@ -0,0 +1,68 @@
/*
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 from "react";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {arraySeed, arrayTrimFill} from "../../../utils/arrays";
import Waveform from "./Waveform";
import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
import {percentageOf} from "../../../utils/numbers";
interface IProps {
playback: Playback;
}
interface IState {
heights: number[];
progress: number;
}
/**
* A waveform which shows the waveform of a previously recorded recording
*/
@replaceableComponent("views.voice_messages.PlaybackWaveform")
export default class PlaybackWaveform extends React.PureComponent<IProps, IState> {
public constructor(props) {
super(props);
this.state = {
heights: this.toHeights(this.props.playback.waveform),
progress: 0, // default no progress
};
this.props.playback.waveformData.onUpdate(this.onWaveformUpdate);
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private toHeights(waveform: number[]) {
const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed);
}
private onWaveformUpdate = (waveform: number[]) => {
this.setState({heights: this.toHeights(waveform)});
};
private onTimeUpdate = (time: number[]) => {
// Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar.
const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1));
this.setState({progress});
};
public render() {
return <Waveform relHeights={this.state.heights} progress={this.state.progress} />;
}
}

View file

@ -0,0 +1,62 @@
/*
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 {Playback, PlaybackState} from "../../../voice/Playback";
import React, {ReactNode} from "react";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import PlaybackWaveform from "./PlaybackWaveform";
import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
}
interface IState {
playbackPhase: PlaybackState;
}
export default class RecordingPlayback extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall
this.props.playback.prepare();
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({playbackPhase: ev});
};
public render(): ReactNode {
return <div className='mx_VoiceMessagePrimaryContainer'>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
<PlaybackClock playback={this.props.playback} />
<PlaybackWaveform playback={this.props.playback} />
</div>
}
}

View file

@ -16,9 +16,11 @@ limitations under the License.
import React from "react";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import classNames from "classnames";
interface IProps {
relHeights: number[]; // relative heights (0-1)
progress: number; // percent complete, 0-1, default 100%
}
interface IState {
@ -28,9 +30,16 @@ interface IState {
* A simple waveform component. This renders bars (centered vertically) for each
* height provided in the component properties. Updating the properties will update
* the rendered waveform.
*
* For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be
* "filled", as a demonstration of the progress property.
*/
@replaceableComponent("views.voice_messages.Waveform")
export default class Waveform extends React.PureComponent<IProps, IState> {
public static defaultProps = {
progress: 1,
};
public constructor(props) {
super(props);
}
@ -38,7 +47,13 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
public render() {
return <div className='mx_Waveform'>
{this.props.relHeights.map((h, i) => {
return <span key={i} style={{height: (h * 100) + '%'}} className='mx_Waveform_bar' />;
const progress = this.props.progress;
const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0;
const classes = classNames({
'mx_Waveform_bar': true,
'mx_Waveform_bar_100pct': isCompleteBar,
});
return <span key={i} style={{height: (h * 100) + '%'}} className={classes} />;
})}
</div>;
}

View file

@ -27,6 +27,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@ -142,6 +143,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
switch (payload.action) {
// listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead
case Action.CallChangeRoom:
case 'call_state': {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),

View file

@ -208,7 +208,7 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onExpandClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
@ -337,7 +337,7 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onRoomAvatarClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
room_id: userFacingRoomId,
@ -345,7 +345,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
private onSecondaryRoomAvatarClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.secondaryCall);
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
dis.dispatch({
action: 'view_room',
@ -354,7 +354,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
private onCallResumeClick = () => {
const userFacingRoomId = CallHandler.roomIdForCall(this.props.call);
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
}
@ -365,8 +365,8 @@ export default class CallView extends React.Component<IProps, IState> {
public render() {
const client = MatrixClientPeg.get();
const callRoomId = CallHandler.roomIdForCall(this.props.call);
const secondaryCallRoomId = CallHandler.roomIdForCall(this.props.secondaryCall);
const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
const callRoom = client.getRoom(callRoomId);
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
@ -482,11 +482,13 @@ export default class CallView extends React.Component<IProps, IState> {
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
let holdTransferContent;
if (transfereeCall) {
const transferTargetRoom = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.props.call));
const transferTargetRoom = MatrixClientPeg.get().getRoom(
CallHandler.sharedInstance().roomIdForCall(this.props.call),
);
const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person");
const transfereeRoom = MatrixClientPeg.get().getRoom(
CallHandler.roomIdForCall(transfereeCall),
CallHandler.sharedInstance().roomIdForCall(transfereeCall),
);
const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person");

View file

@ -22,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher';
import {Resizable} from "re-resizable";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
interface IProps {
// What room we should display the call for
@ -62,6 +63,7 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
private onAction = (payload) => {
switch (payload.action) {
case Action.CallChangeRoom:
case 'call_state': {
const newCall = this.getCall();
if (newCall !== this.state.call) {

View file

@ -72,7 +72,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
e.stopPropagation();
dis.dispatch({
action: 'answer',
room_id: CallHandler.roomIdForCall(this.state.incomingCall),
room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall),
});
};
@ -80,7 +80,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
e.stopPropagation();
dis.dispatch({
action: 'reject',
room_id: CallHandler.roomIdForCall(this.state.incomingCall),
room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall),
});
};
@ -91,7 +91,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
let room = null;
if (this.state.incomingCall) {
room = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.state.incomingCall));
room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall));
}
const caller = room ? room.name : _t("Unknown caller");

View file

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

View file

@ -125,10 +125,8 @@ export default class AutocompleteWrapperModel {
case "at-room":
return [this.partCreator.atRoomPill(completionId), this.partCreator.plain(completion.suffix)];
case "user":
// not using suffix here, because we also need to calculate
// the suffix when clicking a display name to insert a mention,
// which happens in createMentionParts
return this.partCreator.createMentionParts(this.partIndex, text, completionId);
// Insert suffix only if the pill is the part with index 0 - we are at the start of the composer
return this.partCreator.createMentionParts(this.partIndex === 0, text, completionId);
case "command":
// command needs special handling for auto complete, but also renders as plain texts
return [(this.partCreator as CommandPartCreator).command(text)];

View file

@ -535,9 +535,9 @@ export class PartCreator {
return new UserPillPart(userId, displayName, member);
}
createMentionParts(partIndex: number, displayName: string, userId: string) {
createMentionParts(insertTrailingCharacter: boolean, displayName: string, userId: string) {
const pill = this.userPill(displayName, userId);
const postfix = this.plain(partIndex === 0 ? ": " : " ");
const postfix = this.plain(insertTrailingCharacter ? ": " : " ");
return [pill, postfix];
}
}

View file

@ -904,6 +904,8 @@
"Incoming call": "Incoming call",
"Decline": "Decline",
"Accept": "Accept",
"Pause": "Pause",
"Play": "Play",
"The other party cancelled the verification.": "The other party cancelled the verification.",
"Verified!": "Verified!",
"You've successfully verified this user.": "You've successfully verified this user.",
@ -1310,6 +1312,7 @@
"Cryptography": "Cryptography",
"Session ID:": "Session ID:",
"Session key:": "Session key:",
"You have no ignored users.": "You have no ignored users.",
"Bulk options": "Bulk options",
"Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites",
"Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites",
@ -1650,7 +1653,8 @@
"Jump to first unread message.": "Jump to first unread message.",
"Mark all as read": "Mark all as read",
"Record a voice message": "Record a voice message",
"Stop & send recording": "Stop & send recording",
"Stop the recording": "Stop the recording",
"Delete recording": "Delete recording",
"Error updating main address": "Error updating main address",
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
"There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",
@ -2022,10 +2026,10 @@
"Add a new server...": "Add a new server...",
"%(networkName)s rooms": "%(networkName)s rooms",
"Matrix rooms": "Matrix rooms",
"Space selection": "Space selection",
"Add existing rooms": "Add existing rooms",
"Filter your rooms and spaces": "Filter your rooms and spaces",
"Direct Messages": "Direct Messages",
"Space selection": "Space selection",
"Add existing rooms": "Add existing rooms",
"Don't want to add an existing room?": "Don't want to add an existing room?",
"Create a new room": "Create a new room",
"Failed to add rooms to space": "Failed to add rooms to space",
@ -2673,6 +2677,8 @@
"Failed to create initial space rooms": "Failed to create initial space rooms",
"Skip for now": "Skip for now",
"Creating rooms...": "Creating rooms...",
"What do you want to organise?": "What do you want to organise?",
"Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.",
"Share %(name)s": "Share %(name)s",
"It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.",
"Go to my first room": "Go to my first room",

View file

@ -255,7 +255,7 @@ matrixLinkify.options = {
target: function(href, type) {
if (type === 'url') {
const transformed = tryTransformPermalinkToLocalHref(href);
if (transformed !== href || href.match(matrixLinkify.ELEMENT_URL_PATTERN)) {
if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) {
return null;
} else {
return '_blank';

View file

@ -60,6 +60,8 @@ const INITIAL_STATE = {
replyingToEvent: null,
shouldPeek: false,
viaServers: [],
};
/**
@ -113,6 +115,7 @@ class RoomViewStore extends Store<ActionPayload> {
this.setState({
roomId: null,
roomAlias: null,
viaServers: [],
});
break;
case 'view_room_error':
@ -191,6 +194,7 @@ class RoomViewStore extends Store<ActionPayload> {
replyingToEvent: null,
// pull the user out of Room Settings
isEditingSettings: false,
viaServers: payload.via_servers,
};
// Allow being given an event to be replied to when switching rooms but sanity check its for this room
@ -226,6 +230,7 @@ class RoomViewStore extends Store<ActionPayload> {
roomAlias: payload.room_alias,
roomLoading: true,
roomLoadError: null,
viaServers: payload.via_servers,
});
try {
const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias);
@ -261,6 +266,7 @@ class RoomViewStore extends Store<ActionPayload> {
roomAlias: payload.room_alias,
roomLoading: false,
roomLoadError: payload.err,
viaServers: [],
});
}
@ -272,9 +278,10 @@ class RoomViewStore extends Store<ActionPayload> {
const cli = MatrixClientPeg.get();
const address = this.state.roomAlias || this.state.roomId;
const viaServers = this.state.viaServers || [];
try {
await retry<void, MatrixError>(() => cli.joinRoom(address, {
viaServers: payload.via_servers,
viaServers,
...payload.opts,
}), NUM_JOIN_RETRY, (err) => {
// if we received a Gateway timeout then retry

View file

@ -78,3 +78,5 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
return this.updateState({recording: null});
}
}
window.mxVoiceRecordingStore = VoiceRecordingStore.instance;

View file

@ -55,6 +55,15 @@ export default class DMRoomMap {
return DMRoomMap.sharedInstance;
}
/**
* Set the shared instance to the instance supplied
* Used by tests
* @param inst the new shared instance
*/
public static setShared(inst: DMRoomMap) {
DMRoomMap.sharedInstance = inst;
}
/**
* Returns a shared instance of the class
* that uses the singleton matrix client

View file

@ -73,6 +73,26 @@ export function arraySeed<T>(val: T, length: number): T[] {
return a;
}
/**
* Trims or fills the array to ensure it meets the desired length. The seed array
* given is pulled from to fill any missing slots - it is recommended that this be
* at least `len` long. The resulting array will be exactly `len` long, either
* trimmed from the source or filled with the some/all of the seed array.
* @param {T[]} a The array to trim/fill.
* @param {number} len The length to trim or fill to, as needed.
* @param {T[]} seed Values to pull from if the array needs filling.
* @returns {T[]} The resulting array of `len` length.
*/
export function arrayTrimFill<T>(a: T[], len: number, seed: T[]): T[] {
// Dev note: we do length checks because the spread operator can result in some
// performance penalties in more critical code paths. As a utility, it should be
// as fast as possible to not cause a problem for the call stack, no matter how
// critical that stack is.
if (a.length === len) return a;
if (a.length > len) return a.slice(0, len);
return a.concat(seed.slice(0, len - a.length));
}
/**
* Clones an array as fast as possible, retaining references of the array's values.
* @param a The array to clone. Must be defined.

View file

@ -346,7 +346,7 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string {
return permalink;
}
const m = permalink.match(matrixLinkify.ELEMENT_URL_PATTERN);
const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN);
if (m) {
return m[1];
}
@ -411,8 +411,8 @@ function getPermalinkConstructor(): PermalinkConstructor {
export function parsePermalink(fullUrl: string): PermalinkParts {
const elementPrefix = SdkConfig.get()['permalinkPrefix'];
if (fullUrl.startsWith(matrixtoBaseUrl)) {
return new SpecPermalinkConstructor().parsePermalink(fullUrl);
if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) {
return new SpecPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl));
} else if (elementPrefix && fullUrl.startsWith(elementPrefix)) {
return new ElementPermalinkConstructor(elementPrefix).parsePermalink(fullUrl);
}

141
src/voice/Playback.ts Normal file
View file

@ -0,0 +1,141 @@
/*
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 EventEmitter from "events";
import {UPDATE_EVENT} from "../stores/AsyncStore";
import {arrayFastResample, arraySeed} from "../utils/arrays";
import {SimpleObservable} from "matrix-widget-api";
import {IDestroyable} from "../utils/IDestroyable";
import {PlaybackClock} from "./PlaybackClock";
export enum PlaybackState {
Decoding = "decoding",
Stopped = "stopped", // no progress on timeline
Paused = "paused", // some progress on timeline
Playing = "playing", // active progress through timeline
}
export const PLAYBACK_WAVEFORM_SAMPLES = 35;
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
export class Playback extends EventEmitter implements IDestroyable {
private readonly context: AudioContext;
private source: AudioBufferSourceNode;
private state = PlaybackState.Decoding;
private audioBuf: AudioBuffer;
private resampledWaveform: number[];
private waveformObservable = new SimpleObservable<number[]>();
private readonly clock: PlaybackClock;
/**
* Creates a new playback instance from a buffer.
* @param {ArrayBuffer} buf The buffer containing the sound sample.
* @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform
* can be calculated. Contains values between zero and one, inclusive.
*/
constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) {
super();
this.context = new AudioContext();
this.resampledWaveform = arrayFastResample(seedWaveform, PLAYBACK_WAVEFORM_SAMPLES);
this.waveformObservable.update(this.resampledWaveform);
this.clock = new PlaybackClock(this.context);
// TODO: @@ TR: Calculate real waveform
}
public get waveform(): number[] {
return this.resampledWaveform;
}
public get waveformData(): SimpleObservable<number[]> {
return this.waveformObservable;
}
public get clockInfo(): PlaybackClock {
return this.clock;
}
public get currentState(): PlaybackState {
return this.state;
}
public get isPlaying(): boolean {
return this.currentState === PlaybackState.Playing;
}
public emit(event: PlaybackState, ...args: any[]): boolean {
this.state = event;
super.emit(event, ...args);
super.emit(UPDATE_EVENT, event, ...args);
return true; // we don't ever care if the event had listeners, so just return "yes"
}
public destroy() {
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
this.stop();
this.removeAllListeners();
this.clock.destroy();
this.waveformObservable.close();
}
public async prepare() {
this.audioBuf = await this.context.decodeAudioData(this.buf);
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
this.clock.durationSeconds = this.audioBuf.duration;
}
private onPlaybackEnd = async () => {
await this.context.suspend();
this.emit(PlaybackState.Stopped);
};
public async play() {
// We can't restart a buffer source, so we need to create a new one if we hit the end
if (this.state === PlaybackState.Stopped) {
if (this.source) {
this.source.disconnect();
this.source.removeEventListener("ended", this.onPlaybackEnd);
}
this.source = this.context.createBufferSource();
this.source.connect(this.context.destination);
this.source.buffer = this.audioBuf;
this.source.start(); // start immediately
this.source.addEventListener("ended", this.onPlaybackEnd);
}
// We use the context suspend/resume functions because it allows us to pause a source
// node, but that still doesn't help us when the source node runs out (see above).
await this.context.resume();
this.clock.flagStart();
this.emit(PlaybackState.Playing);
}
public async pause() {
await this.context.suspend();
this.emit(PlaybackState.Paused);
}
public async stop() {
await this.onPlaybackEnd();
this.clock.flagStop();
}
public async toggle() {
if (this.isPlaying) await this.pause();
else await this.play();
}
}

View file

@ -0,0 +1,78 @@
/*
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 {SimpleObservable} from "matrix-widget-api";
import {IDestroyable} from "../utils/IDestroyable";
// Because keeping track of time is sufficiently complicated...
export class PlaybackClock implements IDestroyable {
private clipStart = 0;
private stopped = true;
private lastCheck = 0;
private observable = new SimpleObservable<number[]>();
private timerId: number;
private clipDuration = 0;
public constructor(private context: AudioContext) {
}
public get durationSeconds(): number {
return this.clipDuration;
}
public set durationSeconds(val: number) {
this.clipDuration = val;
this.observable.update([this.timeSeconds, this.clipDuration]);
}
public get timeSeconds(): number {
return (this.context.currentTime - this.clipStart) % this.clipDuration;
}
public get liveData(): SimpleObservable<number[]> {
return this.observable;
}
private checkTime = () => {
const now = this.timeSeconds;
if (this.lastCheck !== now) {
this.observable.update([now, this.durationSeconds]);
this.lastCheck = now;
}
};
public flagStart() {
if (this.stopped) {
this.clipStart = this.context.currentTime;
this.stopped = false;
}
if (!this.timerId) {
// case to number because the types are wrong
// 100ms interval to make sure the time is as accurate as possible
this.timerId = <number><any>setInterval(this.checkTime, 100);
}
}
public flagStop() {
this.stopped = true;
}
public destroy() {
this.observable.close();
if (this.timerId) clearInterval(this.timerId);
}
}

View file

@ -24,7 +24,8 @@ import EventEmitter from "events";
import {IDestroyable} from "../utils/IDestroyable";
import {Singleflight} from "../utils/Singleflight";
import {PayloadEvent, WORKLET_NAME} from "./consts";
import {arrayFastClone} from "../utils/arrays";
import {UPDATE_EVENT} from "../stores/AsyncStore";
import {Playback} from "./Playback";
const CHANNELS = 1; // stereo isn't important
const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
@ -52,20 +53,17 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private recorderStream: MediaStream;
private recorderFFT: AnalyserNode;
private recorderWorklet: AudioWorkletNode;
private buffer = new Uint8Array(0);
private buffer = new Uint8Array(0); // use this.audioBuffer to access
private mxc: string;
private recording = false;
private observable: SimpleObservable<IRecordingUpdate>;
private amplitudes: number[] = []; // at each second mark, generated
private playback: Playback;
public constructor(private client: MatrixClient) {
super();
}
public get finalWaveform(): number[] {
return arrayFastClone(this.amplitudes);
}
public get contentType(): string {
return "audio/ogg";
}
@ -79,6 +77,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
return this.recorderContext.currentTime;
}
public get isRecording(): boolean {
return this.recording;
}
public emit(event: string, ...args: any[]): boolean {
super.emit(event, ...args);
super.emit(UPDATE_EVENT, event, ...args);
return true; // we don't ever care if the event had listeners, so just return "yes"
}
private async makeRecorder() {
this.recorderStream = await navigator.mediaDevices.getUserMedia({
audio: {
@ -154,6 +162,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
};
}
private get audioBuffer(): Uint8Array {
// We need a clone of the buffer to avoid accidentally changing the position
// on the real thing.
return this.buffer.slice(0);
}
public get liveData(): SimpleObservable<IRecordingUpdate> {
if (!this.recording) throw new Error("No observable when not recording");
return this.observable;
@ -203,8 +217,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// Now that we've updated the data/waveform, let's do a time check. We don't want to
// go horribly over the limit. We also emit a warning state if needed.
const secondsLeft = TARGET_MAX_LENGTH - timeSeconds;
if (secondsLeft <= 0) {
//
// We use the recorder's perspective of time to make sure we don't cut off the last
// frame of audio, otherwise we end up with a 1:59 clip (119.68 seconds). This extra
// safety can allow us to overshoot the target a bit, but at least when we say 2min
// maximum we actually mean it.
//
// In testing, recorder time and worker time lag by about 400ms, which is roughly the
// time needed to encode a sample/frame.
//
// Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields
const recorderSeconds = this.recorder.encodedSamplePosition / 48000;
const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds;
if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
this.stop();
} else if (secondsLeft <= TARGET_WARN_TIME_LEFT) {
@ -239,9 +264,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
}
// Disconnect the source early to start shutting down resources
await this.recorder.stop(); // stop first to flush the last frame
this.recorderSource.disconnect();
this.recorderWorklet.disconnect();
await this.recorder.stop();
// close the context after the recorder so the recorder doesn't try to
// connect anything to the context (this would generate a warning)
@ -255,15 +280,33 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
await this.recorder.close();
this.emit(RecordingState.Ended);
return this.buffer;
return this.audioBuffer;
});
}
/**
* Gets a playback instance for this voice recording. Note that the playback will not
* have been prepared fully, meaning the `prepare()` function needs to be called on it.
*
* The same playback instance is returned each time.
*
* @returns {Playback} The playback instance.
*/
public getPlayback(): Playback {
this.playback = Singleflight.for(this, "playback").do(() => {
return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper;
});
return this.playback;
}
public destroy() {
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
this.stop();
this.removeAllListeners();
Singleflight.forgetAllFor(this);
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
this.playback?.destroy();
this.observable.close();
}
public async upload(): Promise<string> {
@ -274,7 +317,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
if (this.mxc) return this.mxc;
this.emit(RecordingState.Uploading);
this.mxc = await this.client.uploadContent(new Blob([this.buffer], {
this.mxc = await this.client.uploadContent(new Blob([this.audioBuffer], {
type: this.contentType,
}), {
onlyContentUri: false, // to stop the warnings in the console
@ -283,5 +326,3 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
return this.mxc;
}
}
window.mxVoiceRecorder = VoiceRecording;