Merge branch 'develop' into gsouquet/compact-composer-18533

This commit is contained in:
Germain Souquet 2021-09-03 09:20:38 +01:00
commit 6d80976eae
43 changed files with 683 additions and 391 deletions

View file

@ -354,7 +354,7 @@ $appearance-tab-border-color: $input-darker-bg-color;
// blur amounts for left left panel (only for element theme) // blur amounts for left left panel (only for element theme)
:root { :root {
--lp-background-blur: 30px; --lp-background-blur: 40px;
} }
$composer-shadow-color: rgba(0, 0, 0, 0.04); $composer-shadow-color: rgba(0, 0, 0, 0.04);

View file

@ -26,7 +26,7 @@ export class ManagedPlayback extends Playback {
} }
public async play(): Promise<void> { public async play(): Promise<void> {
this.manager.playOnly(this); this.manager.pauseAllExcept(this);
return super.play(); return super.play();
} }

View file

@ -117,6 +117,8 @@ export class Playback extends EventEmitter implements IDestroyable {
} }
public destroy() { public destroy() {
// Dev note: It's critical that we call stop() during cleanup to ensure that downstream callers
// are aware of the final clock position before the user triggered an unload.
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
this.stop(); this.stop();
this.removeAllListeners(); this.removeAllListeners();
@ -177,9 +179,12 @@ export class Playback extends EventEmitter implements IDestroyable {
this.waveformObservable.update(this.resampledWaveform); this.waveformObservable.update(this.resampledWaveform);
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update
this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration; this.clock.durationSeconds = this.element ? this.element.duration : this.audioBuf.duration;
// Signal that we're not decoding anymore. This is done last to ensure the clock is updated for
// when the downstream callers try to use it.
this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore
} }
private onPlaybackEnd = async () => { private onPlaybackEnd = async () => {

View file

@ -89,9 +89,9 @@ export class PlaybackClock implements IDestroyable {
return this.observable; return this.observable;
} }
private checkTime = () => { private checkTime = (force = false) => {
const now = this.timeSeconds; // calculated dynamically const now = this.timeSeconds; // calculated dynamically
if (this.lastCheck !== now) { if (this.lastCheck !== now || force) {
this.observable.update([now, this.durationSeconds]); this.observable.update([now, this.durationSeconds]);
this.lastCheck = now; this.lastCheck = now;
} }
@ -141,7 +141,7 @@ export class PlaybackClock implements IDestroyable {
public syncTo(contextTime: number, clipTime: number) { public syncTo(contextTime: number, clipTime: number) {
this.clipStart = contextTime - clipTime; this.clipStart = contextTime - clipTime;
this.stopped = false; // count as a mid-stream pause (if we were stopped) this.stopped = false; // count as a mid-stream pause (if we were stopped)
this.checkTime(); this.checkTime(true);
} }
public destroy() { public destroy() {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { DEFAULT_WAVEFORM, Playback } from "./Playback"; import { DEFAULT_WAVEFORM, Playback, PlaybackState } from "./Playback";
import { ManagedPlayback } from "./ManagedPlayback"; import { ManagedPlayback } from "./ManagedPlayback";
/** /**
@ -34,12 +34,14 @@ export class PlaybackManager {
} }
/** /**
* Stops all other playback instances. If no playback is provided, all instances * Pauses all other playback instances. If no playback is provided, all playing
* are stopped. * instances are paused.
* @param playback Optional. The playback to leave untouched. * @param playback Optional. The playback to leave untouched.
*/ */
public playOnly(playback?: Playback) { public pauseAllExcept(playback?: Playback) {
this.instances.filter(p => p !== playback).forEach(p => p.stop()); this.instances
.filter(p => p !== playback && p.currentState === PlaybackState.Playing)
.forEach(p => p.pause());
} }
public destroyPlaybackInstance(playback: ManagedPlayback) { public destroyPlaybackInstance(playback: ManagedPlayback) {

212
src/audio/PlaybackQueue.ts Normal file
View file

@ -0,0 +1,212 @@
/*
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 { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk";
import { Playback, PlaybackState } from "./Playback";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { arrayFastClone } from "../utils/arrays";
import { PlaybackManager } from "./PlaybackManager";
import { isVoiceMessage } from "../utils/EventUtils";
import RoomViewStore from "../stores/RoomViewStore";
/**
* Audio playback queue management for a given room. This keeps track of where the user
* was at for each playback, what order the playbacks were played in, and triggers subsequent
* playbacks.
*
* Currently this is only intended to be used by voice messages.
*
* The primary mechanics are:
* * Persisted clock state for each playback instance (tied to Event ID).
* * Limited memory of playback order (see code; not persisted).
* * Autoplay of next eligible playback instance.
*/
export class PlaybackQueue {
private static queues = new Map<string, PlaybackQueue>(); // keyed by room ID
private playbacks = new Map<string, Playback>(); // keyed by event ID
private clockStates = new Map<string, number>(); // keyed by event ID
private playbackIdOrder: string[] = []; // event IDs, last == current
private currentPlaybackId: string; // event ID, broken out from above for ease of use
private recentFullPlays = new Set<string>(); // event IDs
constructor(private client: MatrixClient, private room: Room) {
this.loadClocks();
RoomViewStore.addListener(() => {
if (RoomViewStore.getRoomId() === this.room.roomId) {
// Reset the state of the playbacks before they start mounting and enqueuing updates.
// We reset the entirety of the queue, including order, to ensure the user isn't left
// confused with what order the messages are playing in.
this.currentPlaybackId = null; // this in particular stops autoplay when the room is switched to
this.recentFullPlays = new Set<string>();
this.playbackIdOrder = [];
}
});
}
public static forRoom(roomId: string): PlaybackQueue {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) throw new Error("Unknown room");
if (PlaybackQueue.queues.has(room.roomId)) {
return PlaybackQueue.queues.get(room.roomId);
}
const queue = new PlaybackQueue(cli, room);
PlaybackQueue.queues.set(room.roomId, queue);
return queue;
}
private persistClocks() {
localStorage.setItem(
`mx_voice_message_clocks_${this.room.roomId}`,
JSON.stringify(Array.from(this.clockStates.entries())),
);
}
private loadClocks() {
const val = localStorage.getItem(`mx_voice_message_clocks_${this.room.roomId}`);
if (!!val) {
this.clockStates = new Map<string, number>(JSON.parse(val));
}
}
public unsortedEnqueue(mxEvent: MatrixEvent, playback: Playback) {
// We don't ever detach our listeners: we expect the Playback to clean up for us
this.playbacks.set(mxEvent.getId(), playback);
playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, mxEvent, state));
playback.clockInfo.liveData.onUpdate((clock) => this.onPlaybackClock(playback, mxEvent, clock));
}
private onPlaybackStateChange(playback: Playback, mxEvent: MatrixEvent, newState: PlaybackState) {
// Remember where the user got to in playback
const wasLastPlaying = this.currentPlaybackId === mxEvent.getId();
if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()) && !wasLastPlaying) {
// noinspection JSIgnoredPromiseFromCall
playback.skipTo(this.clockStates.get(mxEvent.getId()));
} else if (newState === PlaybackState.Stopped) {
// Remove the now-useless clock for some space savings
this.clockStates.delete(mxEvent.getId());
if (wasLastPlaying) {
this.recentFullPlays.add(this.currentPlaybackId);
const orderClone = arrayFastClone(this.playbackIdOrder);
const last = orderClone.pop();
if (last === this.currentPlaybackId) {
const next = orderClone.pop();
if (next) {
const instance = this.playbacks.get(next);
if (!instance) {
console.warn(
"Voice message queue desync: Missing playback for next message: "
+ `Current=${this.currentPlaybackId} Last=${last} Next=${next}`,
);
} else {
this.playbackIdOrder = orderClone;
PlaybackManager.instance.pauseAllExcept(instance);
// This should cause a Play event, which will re-populate our playback order
// and update our current playback ID.
// noinspection JSIgnoredPromiseFromCall
instance.play();
}
} else {
// else no explicit next event, so find an event we haven't played that comes next. The live
// timeline is already most recent last, so we can iterate down that.
const timeline = arrayFastClone(this.room.getLiveTimeline().getEvents());
let scanForVoiceMessage = false;
let nextEv: MatrixEvent;
for (const event of timeline) {
if (event.getId() === mxEvent.getId()) {
scanForVoiceMessage = true;
continue;
}
if (!scanForVoiceMessage) continue;
// Dev note: This is where we'd break to cause text/non-voice messages to
// interrupt automatic playback.
const isRightType = isVoiceMessage(event);
const havePlayback = this.playbacks.has(event.getId());
const isRecentlyCompleted = this.recentFullPlays.has(event.getId());
if (isRightType && havePlayback && !isRecentlyCompleted) {
nextEv = event;
break;
}
}
if (!nextEv) {
// if we don't have anywhere to go, reset the recent playback queue so the user
// can start a new chain of playbacks.
this.recentFullPlays = new Set<string>();
this.playbackIdOrder = [];
} else {
this.playbackIdOrder = orderClone;
const instance = this.playbacks.get(nextEv.getId());
PlaybackManager.instance.pauseAllExcept(instance);
// This should cause a Play event, which will re-populate our playback order
// and update our current playback ID.
// noinspection JSIgnoredPromiseFromCall
instance.play();
}
}
} else {
console.warn(
"Voice message queue desync: Expected playback stop to be last in order. "
+ `Current=${this.currentPlaybackId} Last=${last} EventID=${mxEvent.getId()}`,
);
}
}
}
if (newState === PlaybackState.Playing) {
const order = this.playbackIdOrder;
if (this.currentPlaybackId !== mxEvent.getId() && !!this.currentPlaybackId) {
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
const lastInstance = this.playbacks.get(this.currentPlaybackId);
if (
lastInstance.currentState === PlaybackState.Playing
|| lastInstance.currentState === PlaybackState.Paused
) {
order.push(this.currentPlaybackId);
}
}
}
this.currentPlaybackId = mxEvent.getId();
if (order.length === 0 || order[order.length - 1] !== this.currentPlaybackId) {
order.push(this.currentPlaybackId);
}
}
// Only persist clock information on pause/stop (end) to avoid overwhelming the storage.
// This should get triggered from normal voice message component unmount due to the playback
// stopping itself for cleanup.
if (newState === PlaybackState.Paused || newState === PlaybackState.Stopped) {
this.persistClocks();
}
}
private onPlaybackClock(playback: Playback, mxEvent: MatrixEvent, clocks: number[]) {
if (playback.currentState === PlaybackState.Decoding) return; // ignore pre-ready values
if (playback.currentState !== PlaybackState.Stopped) {
this.clockStates.set(mxEvent.getId(), clocks[0]); // [0] is the current seek position
}
}
}

View file

@ -347,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
}); });
} }
private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => { private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: React.MouseEvent) => {
// If room was shift-clicked, remove it from the room directory // If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) { if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault(); ev.preventDefault();

View file

@ -1867,7 +1867,7 @@ export default class RoomView extends React.Component<IProps, IState> {
isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)} isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)}
/>; />;
} else if (showRoomUpgradeBar) { } else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />; aux = <RoomUpgradeWarningBar room={this.state.room} />;
} else if (myMembership !== "join") { } else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it. // We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it. // We may have a 3rd party invite to it.
@ -2042,7 +2042,6 @@ export default class RoomView extends React.Component<IProps, IState> {
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0} highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>); />);
} }

View file

@ -519,6 +519,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
inlineErrors: true, inlineErrors: true,
parentSpace: space, parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined, joinRule: !isPublic ? JoinRule.Restricted : undefined,
suggested: true,
}); });
})); }));
onFinished(filteredRoomNames.length > 0); onFinished(filteredRoomNames.length > 0);

View file

@ -136,6 +136,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
<MessageComposer <MessageComposer
room={this.props.room} room={this.props.room}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
replyInThread={true}
replyToEvent={this.state?.thread?.replyToEvent} replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false} showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}

View file

@ -36,6 +36,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean; viewUserOnClick?: boolean;
title?: string; title?: string;
style?: any;
} }
interface IState { interface IState {

View file

@ -19,7 +19,7 @@ import React, { ReactHTML } from 'react';
import { Key } from '../../../Keyboard'; import { Key } from '../../../Keyboard';
import classnames from 'classnames'; import classnames from 'classnames';
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element>; export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element> | React.FormEvent<Element>;
/** /**
* children: React's magic prop. Represents all children given to the element. * children: React's magic prop. Represents all children given to the element.
@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
tabIndex?: number; tabIndex?: number;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
onClick(e?: ButtonEvent): void; onClick(e?: ButtonEvent): void | Promise<void>;
} }
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> { interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {

View file

@ -19,6 +19,7 @@ import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { UNSTABLE_ELEMENT_REPLY_IN_THREAD } from "matrix-js-sdk/src/@types/event";
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout"; import { Layout } from "../../../settings/Layout";
@ -206,15 +207,28 @@ export default class ReplyThread extends React.Component<IProps, IState> {
return { body, html }; return { body, html };
} }
public static makeReplyMixIn(ev: MatrixEvent) { public static makeReplyMixIn(ev: MatrixEvent, replyInThread: boolean) {
if (!ev) return {}; if (!ev) return {};
return {
const replyMixin = {
'm.relates_to': { 'm.relates_to': {
'm.in_reply_to': { 'm.in_reply_to': {
'event_id': ev.getId(), 'event_id': ev.getId(),
}, },
}, },
}; };
/**
* @experimental
* Rendering hint for threads, only attached if true to make
* sure that Element does not start sending that property for all events
*/
if (replyInThread) {
const inReplyTo = replyMixin['m.relates_to']['m.in_reply_to'];
inReplyTo[UNSTABLE_ELEMENT_REPLY_IN_THREAD.name] = replyInThread;
}
return replyMixin;
} }
public static makeThread( public static makeThread(

View file

@ -24,6 +24,8 @@ import { IMediaEventContent } from "../../../customisations/models/IMediaEventCo
import MFileBody from "./MFileBody"; import MFileBody from "./MFileBody";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import { PlaybackManager } from "../../../audio/PlaybackManager"; import { PlaybackManager } from "../../../audio/PlaybackManager";
import { isVoiceMessage } from "../../../utils/EventUtils";
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
interface IState { interface IState {
error?: Error; error?: Error;
@ -67,6 +69,10 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback }); this.setState({ playback });
if (isVoiceMessage(this.props.mxEvent)) {
PlaybackQueue.forRoom(this.props.mxEvent.getRoomId()).unsortedEnqueue(this.props.mxEvent, playback);
}
// Note: the components later on will handle preparing the Playback class for us. // Note: the components later on will handle preparing the Playback class for us.
} }

View file

@ -55,6 +55,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = true; private unmounted = true;
private image = createRef<HTMLImageElement>(); private image = createRef<HTMLImageElement>();
private timeout?: number;
constructor(props: IBodyProps) { constructor(props: IBodyProps) {
super(props); super(props);
@ -128,7 +129,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => { private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: true }); this.setState({ hover: true });
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) {
return; return;
} }
const imgElement = e.currentTarget; const imgElement = e.currentTarget;
@ -138,7 +139,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => { private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: false }); this.setState({ hover: false });
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) {
return; return;
} }
const imgElement = e.currentTarget; const imgElement = e.currentTarget;
@ -146,12 +147,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}; };
private onImageError = (): void => { private onImageError = (): void => {
this.clearBlurhashTimeout();
this.setState({ this.setState({
imgError: true, imgError: true,
}); });
}; };
private onImageLoad = (): void => { private onImageLoad = (): void => {
this.clearBlurhashTimeout();
this.props.onHeightChanged(); this.props.onHeightChanged();
let loadedImageDimensions; let loadedImageDimensions;
@ -267,6 +270,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} }
} }
private clearBlurhashTimeout() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
componentDidMount() { componentDidMount() {
this.unmounted = false; this.unmounted = false;
this.context.on('sync', this.onClientSync); this.context.on('sync', this.onClientSync);
@ -281,8 +291,9 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} // else don't download anything because we don't want to display anything. } // else don't download anything because we don't want to display anything.
// Add a 150ms timer for blurhash to first appear. // Add a 150ms timer for blurhash to first appear.
if (this.media.isEncrypted) { if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
setTimeout(() => { this.clearBlurhashTimeout();
this.timeout = setTimeout(() => {
if (!this.state.imgLoaded || !this.state.imgError) { if (!this.state.imgLoaded || !this.state.imgError) {
this.setState({ this.setState({
placeholder: 'blurhash', placeholder: 'blurhash',
@ -295,6 +306,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener('sync', this.onClientSync); this.context.removeListener('sync', this.onClientSync);
this.clearBlurhashTimeout();
} }
protected messageContent( protected messageContent(
@ -387,7 +399,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
} }
if (this.isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { if (this.isGif() && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) {
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>; gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
} }
@ -487,7 +499,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
const contentUrl = this.getContentUrl(); const contentUrl = this.getContentUrl();
let thumbUrl; let thumbUrl;
if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { if (this.isGif() && SettingsStore.getValue("autoplayGifs")) {
thumbUrl = contentUrl; thumbUrl = contentUrl;
} else { } else {
thumbUrl = this.getThumbUrl(); thumbUrl = this.getThumbUrl();

View file

@ -145,7 +145,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
} }
async componentDidMount() { async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean; const autoplay = SettingsStore.getValue("autoplayVideo") as boolean;
this.loadBlurhash(); this.loadBlurhash();
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) { if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
@ -209,7 +209,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos"); const autoplay = SettingsStore.getValue("autoplayVideo");
if (this.state.error !== null) { if (this.state.error !== null) {
return ( return (

View file

@ -428,7 +428,7 @@ const UserOptionsSection: React.FC<{
let directMessageButton; let directMessageButton;
if (!isMe) { if (!isMe) {
directMessageButton = ( directMessageButton = (
<AccessibleButton onClick={() => openDMForUser(cli, member.userId)} className="mx_UserInfo_field"> <AccessibleButton onClick={() => { openDMForUser(cli, member.userId); }} className="mx_UserInfo_field">
{ _t('Direct message') } { _t('Direct message') }
</AccessibleButton> </AccessibleButton>
); );

View file

@ -43,11 +43,6 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
function eventIsReply(mxEvent: MatrixEvent): boolean {
const relatesTo = mxEvent.getContent()["m.relates_to"];
return !!(relatesTo && relatesTo["m.in_reply_to"]);
}
function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body; const html = mxEvent.getContent().formatted_body;
if (!html) { if (!html) {
@ -72,7 +67,7 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
if (isEmote) { if (isEmote) {
model = stripEmoteCommand(model); model = stripEmoteCommand(model);
} }
const isReply = eventIsReply(editedEvent); const isReply = !!editedEvent.replyEventId;
let plainPrefix = ""; let plainPrefix = "";
let htmlPrefix = ""; let htmlPrefix = "";

View file

@ -243,6 +243,7 @@ interface IProps {
// opaque readreceipt info for each userId; used by ReadReceiptMarker // opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations. Should be an empty object when the room // to manage its animations. Should be an empty object when the room
// first loads // first loads
// TODO: Proper typing for RR info
readReceiptMap?: any; readReceiptMap?: any;
// A function which is used to check if the parent panel is being // A function which is used to check if the parent panel is being

View file

@ -14,11 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames'; import classNames from 'classnames';
export default (props) => { interface IProps {
numUnreadMessages: number;
highlight: boolean;
onScrollToBottomClick: (e: React.MouseEvent) => void;
}
const JumpToBottomButton: React.FC<IProps> = (props) => {
const className = classNames({ const className = classNames({
'mx_JumpToBottomButton': true, 'mx_JumpToBottomButton': true,
'mx_JumpToBottomButton_highlight': props.highlight, 'mx_JumpToBottomButton_highlight': props.highlight,
@ -36,3 +43,5 @@ export default (props) => {
{ badge } { badge }
</div>); </div>);
}; };
export default JumpToBottomButton;

View file

@ -192,6 +192,7 @@ interface IProps {
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
replyInThread?: boolean;
showReplyPreview?: boolean; showReplyPreview?: boolean;
e2eStatus?: E2EStatus; e2eStatus?: E2EStatus;
compact?: boolean; compact?: boolean;
@ -217,6 +218,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
static defaultProps = { static defaultProps = {
replyInThread: false,
showReplyPreview: true, showReplyPreview: true,
compact: false, compact: false,
}; };
@ -498,6 +500,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
room={this.props.room} room={this.props.room}
placeholder={this.renderPlaceholderText()} placeholder={this.renderPlaceholderText()}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
replyInThread={this.props.replyInThread}
replyToEvent={this.props.replyToEvent} replyToEvent={this.props.replyToEvent}
onChange={this.onChange} onChange={this.onChange}
disabled={this.state.haveRecording} disabled={this.state.haveRecording}

View file

@ -15,62 +15,75 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef, RefObject } from 'react';
import PropTypes from 'prop-types'; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { formatDate } from '../../../DateUtils'; import { formatDate } from '../../../DateUtils';
import NodeAnimator from "../../../NodeAnimator"; import NodeAnimator from "../../../NodeAnimator";
import * as sdk from "../../../index";
import { toPx } from "../../../utils/units"; import { toPx } from "../../../utils/units";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.ReadReceiptMarker") import MemberAvatar from '../avatars/MemberAvatar';
export default class ReadReceiptMarker extends React.PureComponent {
static propTypes = { interface IProps {
// the RoomMember to show the RR for // the RoomMember to show the RR for
member: PropTypes.object, member?: RoomMember;
// userId to fallback the avatar to // userId to fallback the avatar to
// if the member hasn't been loaded yet // if the member hasn't been loaded yet
fallbackUserId: PropTypes.string.isRequired, fallbackUserId: string;
// number of pixels to offset the avatar from the right of its parent; // number of pixels to offset the avatar from the right of its parent;
// typically a negative value. // typically a negative value.
leftOffset: PropTypes.number, leftOffset?: number;
// true to hide the avatar (it will still be animated) // true to hide the avatar (it will still be animated)
hidden: PropTypes.bool, hidden?: boolean;
// don't animate this RR into position // don't animate this RR into position
suppressAnimation: PropTypes.bool, suppressAnimation?: boolean;
// an opaque object for storing information about this user's RR in // an opaque object for storing information about this user's RR in
// this room // this room
readReceiptInfo: PropTypes.object, // TODO: proper typing for RR info
readReceiptInfo: any;
// A function which is used to check if the parent panel is being // A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we // unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted. // are being unmounted.
checkUnmounting: PropTypes.func, checkUnmounting?: () => boolean;
// callback for clicks on this RR // callback for clicks on this RR
onClick: PropTypes.func, onClick?: (e: React.MouseEvent) => void;
// Timestamp when the receipt was read // Timestamp when the receipt was read
timestamp: PropTypes.number, timestamp?: number;
// True to show twelve hour format, false otherwise // True to show twelve hour format, false otherwise
showTwelveHour: PropTypes.bool, showTwelveHour?: boolean;
}; }
interface IState {
suppressDisplay: boolean;
startStyles?: IReadReceiptMarkerStyle[];
}
interface IReadReceiptMarkerStyle {
top: number;
left: number;
}
@replaceableComponent("views.rooms.ReadReceiptMarker")
export default class ReadReceiptMarker extends React.PureComponent<IProps, IState> {
private avatar: React.RefObject<HTMLDivElement | HTMLImageElement | HTMLSpanElement> = createRef();
static defaultProps = { static defaultProps = {
leftOffset: 0, leftOffset: 0,
}; };
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._avatar = createRef();
this.state = { this.state = {
// if we are going to animate the RR, we don't show it on first render, // if we are going to animate the RR, we don't show it on first render,
// and instead just add a placeholder to the DOM; once we've been // and instead just add a placeholder to the DOM; once we've been
@ -80,7 +93,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
}; };
} }
componentWillUnmount() { public componentWillUnmount(): void {
// before we remove the rr, store its location in the map, so that if // before we remove the rr, store its location in the map, so that if
// it reappears, it can be animated from the right place. // it reappears, it can be animated from the right place.
const rrInfo = this.props.readReceiptInfo; const rrInfo = this.props.readReceiptInfo;
@ -95,29 +108,29 @@ export default class ReadReceiptMarker extends React.PureComponent {
return; return;
} }
const avatarNode = this._avatar.current; const avatarNode = this.avatar.current;
rrInfo.top = avatarNode.offsetTop; rrInfo.top = avatarNode.offsetTop;
rrInfo.left = avatarNode.offsetLeft; rrInfo.left = avatarNode.offsetLeft;
rrInfo.parent = avatarNode.offsetParent; rrInfo.parent = avatarNode.offsetParent;
} }
componentDidMount() { public componentDidMount(): void {
if (!this.state.suppressDisplay) { if (!this.state.suppressDisplay) {
// we've already done our display - nothing more to do. // we've already done our display - nothing more to do.
return; return;
} }
this._animateMarker(); this.animateMarker();
} }
componentDidUpdate(prevProps) { public componentDidUpdate(prevProps: IProps): void {
const differentLeftOffset = prevProps.leftOffset !== this.props.leftOffset; const differentLeftOffset = prevProps.leftOffset !== this.props.leftOffset;
const visibilityChanged = prevProps.hidden !== this.props.hidden; const visibilityChanged = prevProps.hidden !== this.props.hidden;
if (differentLeftOffset || visibilityChanged) { if (differentLeftOffset || visibilityChanged) {
this._animateMarker(); this.animateMarker();
} }
} }
_animateMarker() { private animateMarker(): void {
// treat new RRs as though they were off the top of the screen // treat new RRs as though they were off the top of the screen
let oldTop = -15; let oldTop = -15;
@ -126,7 +139,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
oldTop = oldInfo.top + oldInfo.parent.getBoundingClientRect().top; oldTop = oldInfo.top + oldInfo.parent.getBoundingClientRect().top;
} }
const newElement = this._avatar.current; const newElement = this.avatar.current;
let startTopOffset; let startTopOffset;
if (!newElement.offsetParent) { if (!newElement.offsetParent) {
// this seems to happen sometimes for reasons I don't understand // this seems to happen sometimes for reasons I don't understand
@ -156,10 +169,9 @@ export default class ReadReceiptMarker extends React.PureComponent {
}); });
} }
render() { public render(): JSX.Element {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
if (this.state.suppressDisplay) { if (this.state.suppressDisplay) {
return <div ref={this._avatar} />; return <div ref={this.avatar as RefObject<HTMLDivElement>} />;
} }
const style = { const style = {
@ -198,7 +210,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
style={style} style={style}
title={title} title={title}
onClick={this.props.onClick} onClick={this.props.onClick}
inputRef={this._avatar} inputRef={this.avatar as RefObject<HTMLImageElement>}
/> />
</NodeAnimator> </NodeAnimator>
); );

View file

@ -14,41 +14,38 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import React from 'react'; import React from 'react';
import { _t } from '../../../languageHandler'; import { Room } from 'matrix-js-sdk/src';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { roomShape } from './RoomDetailRow';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomDetailRow from "./RoomDetailRow";
interface IProps {
rooms?: Room[];
className?: string;
}
@replaceableComponent("views.rooms.RoomDetailList") @replaceableComponent("views.rooms.RoomDetailList")
export default class RoomDetailList extends React.Component { export default class RoomDetailList extends React.Component<IProps> {
static propTypes = { private getRows(): JSX.Element[] {
rooms: PropTypes.arrayOf(roomShape),
className: PropTypes.string,
};
getRows() {
if (!this.props.rooms) return []; if (!this.props.rooms) return [];
const RoomDetailRow = sdk.getComponent('rooms.RoomDetailRow');
return this.props.rooms.map((room, index) => { return this.props.rooms.map((room, index) => {
return <RoomDetailRow key={index} room={room} onClick={this.onDetailsClick} />; return <RoomDetailRow key={index} room={room} onClick={this.onDetailsClick} />;
}); });
} }
onDetailsClick = (ev, room) => { private onDetailsClick = (ev: React.MouseEvent, room: Room): void => {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: room.roomId, room_id: room.roomId,
room_alias: room.canonicalAlias || (room.aliases || [])[0], room_alias: room.getCanonicalAlias() || (room.getAltAliases() || [])[0],
}); });
}; };
render() { public render(): JSX.Element {
const rows = this.getRows(); const rows = this.getRows();
let rooms; let rooms;
if (rows.length === 0) { if (rows.length === 0) {

View file

@ -195,7 +195,7 @@ export default class RoomHeader extends React.Component<IProps> {
videoCallButton = videoCallButton =
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton" className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev) => ev.shiftKey ? onClick={(ev: React.MouseEvent<Element>) => ev.shiftKey ?
this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)} this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
title={_t("Video call")} />; title={_t("Video call")} />;
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2018-2020 New Vector Ltd Copyright 2018-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.
@ -15,41 +15,43 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import * as sdk from '../../../index'; import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
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 RoomUpgradeDialog from '../dialogs/RoomUpgradeDialog';
import AccessibleButton from '../elements/AccessibleButton';
interface IProps {
room: Room;
}
interface IState {
upgraded?: boolean;
}
@replaceableComponent("views.rooms.RoomUpgradeWarningBar") @replaceableComponent("views.rooms.RoomUpgradeWarningBar")
export default class RoomUpgradeWarningBar extends React.PureComponent { export default class RoomUpgradeWarningBar extends React.PureComponent<IProps, IState> {
static propTypes = { public componentDidMount(): void {
room: PropTypes.object.isRequired,
recommendation: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", ""); const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room }); this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
MatrixClientPeg.get().on("RoomState.events", this._onStateEvents); MatrixClientPeg.get().on("RoomState.events", this.onStateEvents);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.removeListener("RoomState.events", this._onStateEvents); cli.removeListener("RoomState.events", this.onStateEvents);
} }
} }
_onStateEvents = (event, state) => { private onStateEvents = (event: MatrixEvent, state: RoomState): void => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return; return;
} }
@ -60,14 +62,11 @@ export default class RoomUpgradeWarningBar extends React.PureComponent {
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room }); this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
}; };
onUpgradeClick = () => { private onUpgradeClick = (): void => {
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room: this.props.room }); Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room: this.props.room });
}; };
render() { public render(): JSX.Element {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let doUpgradeWarnings = ( let doUpgradeWarnings = (
<div> <div>
<div className="mx_RoomUpgradeWarningBar_body"> <div className="mx_RoomUpgradeWarningBar_body">

View file

@ -57,15 +57,16 @@ import { ActionPayload } from "../../../dispatcher/payloads";
function addReplyToMessageContent( function addReplyToMessageContent(
content: IContent, content: IContent,
repliedToEvent: MatrixEvent, replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator, permalinkCreator: RoomPermalinkCreator,
): void { ): void {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); const replyContent = ReplyThread.makeReplyMixIn(replyToEvent, replyInThread);
Object.assign(content, replyContent); Object.assign(content, replyContent);
// Part of Replies fallback support - prepend the text we're sending // Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to // with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(repliedToEvent, permalinkCreator); const nestedReply = ReplyThread.getNestedReplyText(replyToEvent, permalinkCreator);
if (nestedReply) { if (nestedReply) {
if (content.formatted_body) { if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body; content.formatted_body = nestedReply.html + content.formatted_body;
@ -77,8 +78,9 @@ function addReplyToMessageContent(
// exported for tests // exported for tests
export function createMessageContent( export function createMessageContent(
model: EditorModel, model: EditorModel,
permalinkCreator: RoomPermalinkCreator,
replyToEvent: MatrixEvent, replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator,
): IContent { ): IContent {
const isEmote = containsEmote(model); const isEmote = containsEmote(model);
if (isEmote) { if (isEmote) {
@ -101,7 +103,7 @@ export function createMessageContent(
} }
if (replyToEvent) { if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, permalinkCreator); addReplyToMessageContent(content, replyToEvent, replyInThread, permalinkCreator);
} }
return content; return content;
@ -129,6 +131,7 @@ interface IProps {
room: Room; room: Room;
placeholder?: string; placeholder?: string;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
replyInThread?: boolean;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
disabled?: boolean; disabled?: boolean;
onChange?(model: EditorModel): void; onChange?(model: EditorModel): void;
@ -357,7 +360,12 @@ export default class SendMessageComposer extends React.Component<IProps> {
if (cmd.category === CommandCategories.messages) { if (cmd.category === CommandCategories.messages) {
content = await this.runSlashCommand(cmd, args); content = await this.runSlashCommand(cmd, args);
if (replyToEvent) { if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator); addReplyToMessageContent(
content,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
);
} }
} else { } else {
this.runSlashCommand(cmd, args); this.runSlashCommand(cmd, args);
@ -400,7 +408,12 @@ export default class SendMessageComposer extends React.Component<IProps> {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
const { roomId } = this.props.room; const { roomId } = this.props.room;
if (!content) { if (!content) {
content = createMessageContent(this.model, this.props.permalinkCreator, replyToEvent); content = createMessageContent(
this.model,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
);
} }
// don't bother sending an empty message // don't bother sending an empty message
if (!content.body.trim()) return; if (!content.body.trim()) return;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016-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.
@ -15,23 +15,21 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
title?: string;
// `src` to an image. Optional.
icon?: string;
}
/* /*
* A stripped-down room header used for things like the user settings * A stripped-down room header used for things like the user settings
* and room directory. * and room directory.
*/ */
@replaceableComponent("views.rooms.SimpleRoomHeader") @replaceableComponent("views.rooms.SimpleRoomHeader")
export default class SimpleRoomHeader extends React.Component { export default class SimpleRoomHeader extends React.PureComponent<IProps> {
static propTypes = { public render(): JSX.Element {
title: PropTypes.string,
// `src` to an image. Optional.
icon: PropTypes.string,
};
render() {
let icon; let icon;
if (this.props.icon) { if (this.props.icon) {
icon = <img icon = <img

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd
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.
@ -17,19 +15,18 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.TopUnreadMessagesBar") interface IProps {
export default class TopUnreadMessagesBar extends React.Component { onScrollUpClick?: (e: React.MouseEvent) => void;
static propTypes = { onCloseClick?: (e: React.MouseEvent) => void;
onScrollUpClick: PropTypes.func, }
onCloseClick: PropTypes.func,
};
render() { @replaceableComponent("views.rooms.TopUnreadMessagesBar")
export default class TopUnreadMessagesBar extends React.PureComponent<IProps> {
public render(): JSX.Element {
return ( return (
<div className="mx_TopUnreadMessagesBar"> <div className="mx_TopUnreadMessagesBar">
<AccessibleButton <AccessibleButton

View file

@ -179,7 +179,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
try { try {
// stop any noises which might be happening // stop any noises which might be happening
await PlaybackManager.instance.playOnly(null); await PlaybackManager.instance.pauseAllExcept(null);
const recorder = VoiceRecordingStore.instance.startRecording(); const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start(); await recorder.start();

View file

@ -15,12 +15,19 @@ limitations under the License.
*/ */
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import classNames from "classnames"; import classNames from "classnames";
const AvatarSetting = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, removeAvatar }) => { interface IProps {
avatarUrl?: string;
avatarName: string; // name of user/room the avatar belongs to
uploadAvatar?: (e: React.MouseEvent) => void;
removeAvatar?: (e: React.MouseEvent) => void;
avatarAltText: string;
}
const AvatarSetting: React.FC<IProps> = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, removeAvatar }) => {
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const hoveringProps = { const hoveringProps = {
onMouseEnter: () => setIsHovering(true), onMouseEnter: () => setIsHovering(true),
@ -78,12 +85,4 @@ const AvatarSetting = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, rem
</div>; </div>;
}; };
AvatarSetting.propTypes = {
avatarUrl: PropTypes.string,
avatarName: PropTypes.string.isRequired, // name of user/room the avatar belongs to
uploadAvatar: PropTypes.func,
removeAvatar: PropTypes.func,
avatarAltText: PropTypes.string.isRequired,
};
export default AvatarSetting; export default AvatarSetting;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd 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.
@ -15,54 +15,65 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Spinner from '../elements/Spinner'; import Spinner from '../elements/Spinner';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import RoomAvatar from '../avatars/RoomAvatar';
import BaseAvatar from '../avatars/BaseAvatar';
interface IProps {
initialAvatarUrl?: string;
room?: Room;
// if false, you need to call changeAvatar.onFileSelected yourself.
showUploadSection?: boolean;
width?: number;
height?: number;
className?: string;
}
interface IState {
avatarUrl?: string;
errorText?: string;
phase?: Phases;
}
enum Phases {
Display = "display",
Uploading = "uploading",
Error = "error",
}
@replaceableComponent("views.settings.ChangeAvatar") @replaceableComponent("views.settings.ChangeAvatar")
export default class ChangeAvatar extends React.Component { export default class ChangeAvatar extends React.Component<IProps, IState> {
static propTypes = { public static defaultProps = {
initialAvatarUrl: PropTypes.string,
room: PropTypes.object,
// if false, you need to call changeAvatar.onFileSelected yourself.
showUploadSection: PropTypes.bool,
width: PropTypes.number,
height: PropTypes.number,
className: PropTypes.string,
};
static Phases = {
Display: "display",
Uploading: "uploading",
Error: "error",
};
static defaultProps = {
showUploadSection: true, showUploadSection: true,
className: "", className: "",
width: 80, width: 80,
height: 80, height: 80,
}; };
constructor(props) { private avatarSet = false;
constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
avatarUrl: this.props.initialAvatarUrl, avatarUrl: this.props.initialAvatarUrl,
phase: ChangeAvatar.Phases.Display, phase: Phases.Display,
}; };
} }
componentDidMount() { public componentDidMount(): void {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase // eslint-disable-next-line
public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
if (this.avatarSet) { if (this.avatarSet) {
// don't clobber what the user has just set // don't clobber what the user has just set
return; return;
@ -72,13 +83,13 @@ export default class ChangeAvatar extends React.Component {
}); });
} }
componentWillUnmount() { public componentWillUnmount(): void {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
} }
} }
onRoomStateEvents = (ev) => { private onRoomStateEvents = (ev: MatrixEvent) => {
if (!this.props.room) { if (!this.props.room) {
return; return;
} }
@ -94,18 +105,17 @@ export default class ChangeAvatar extends React.Component {
} }
}; };
setAvatarFromFile(file) { private setAvatarFromFile(file: File): Promise<{}> {
let newUrl = null; let newUrl = null;
this.setState({ this.setState({
phase: ChangeAvatar.Phases.Uploading, phase: Phases.Uploading,
}); });
const self = this; const httpPromise = MatrixClientPeg.get().uploadContent(file).then((url) => {
const httpPromise = MatrixClientPeg.get().uploadContent(file).then(function(url) {
newUrl = url; newUrl = url;
if (self.props.room) { if (this.props.room) {
return MatrixClientPeg.get().sendStateEvent( return MatrixClientPeg.get().sendStateEvent(
self.props.room.roomId, this.props.room.roomId,
'm.room.avatar', 'm.room.avatar',
{ url: url }, { url: url },
'', '',
@ -115,38 +125,37 @@ export default class ChangeAvatar extends React.Component {
} }
}); });
httpPromise.then(function() { httpPromise.then(() => {
self.setState({ this.setState({
phase: ChangeAvatar.Phases.Display, phase: Phases.Display,
avatarUrl: mediaFromMxc(newUrl).srcHttp, avatarUrl: mediaFromMxc(newUrl).srcHttp,
}); });
}, function(error) { }, () => {
self.setState({ this.setState({
phase: ChangeAvatar.Phases.Error, phase: Phases.Error,
}); });
self.onError(error); this.onError();
}); });
return httpPromise; return httpPromise;
} }
onFileSelected = (ev) => { private onFileSelected = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.avatarSet = true; this.avatarSet = true;
return this.setAvatarFromFile(ev.target.files[0]); return this.setAvatarFromFile(ev.target.files[0]);
}; };
onError = (error) => { private onError = (): void => {
this.setState({ this.setState({
errorText: _t("Failed to upload profile picture!"), errorText: _t("Failed to upload profile picture!"),
}); });
}; };
render() { public render(): JSX.Element {
let avatarImg; let avatarImg;
// Having just set an avatar we just display that since it will take a little // Having just set an avatar we just display that since it will take a little
// time to propagate through to the RoomAvatar. // time to propagate through to the RoomAvatar.
if (this.props.room && !this.avatarSet) { if (this.props.room && !this.avatarSet) {
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
avatarImg = <RoomAvatar avatarImg = <RoomAvatar
room={this.props.room} room={this.props.room}
width={this.props.width} width={this.props.width}
@ -154,7 +163,6 @@ export default class ChangeAvatar extends React.Component {
resizeMethod='crop' resizeMethod='crop'
/>; />;
} else { } else {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ? // XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ?
avatarImg = <BaseAvatar avatarImg = <BaseAvatar
width={this.props.width} width={this.props.width}
@ -178,8 +186,8 @@ export default class ChangeAvatar extends React.Component {
} }
switch (this.state.phase) { switch (this.state.phase) {
case ChangeAvatar.Phases.Display: case Phases.Display:
case ChangeAvatar.Phases.Error: case Phases.Error:
return ( return (
<div> <div>
<div className={this.props.className}> <div className={this.props.className}>
@ -188,7 +196,7 @@ export default class ChangeAvatar extends React.Component {
{ uploadSection } { uploadSection }
</div> </div>
); );
case ChangeAvatar.Phases.Uploading: case Phases.Uploading:
return ( return (
<Spinner /> <Spinner />
); );

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
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.
@ -17,14 +15,14 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import EditableTextContainer from "../elements/EditableTextContainer";
@replaceableComponent("views.settings.ChangeDisplayName") @replaceableComponent("views.settings.ChangeDisplayName")
export default class ChangeDisplayName extends React.Component { export default class ChangeDisplayName extends React.Component {
_getDisplayName = async () => { private getDisplayName = async (): Promise<string> => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
try { try {
const res = await cli.getProfileInfo(cli.getUserId()); const res = await cli.getProfileInfo(cli.getUserId());
@ -34,21 +32,20 @@ export default class ChangeDisplayName extends React.Component {
} }
}; };
_changeDisplayName = (newDisplayname) => { private changeDisplayName = (newDisplayname: string): Promise<{}> => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
return cli.setDisplayName(newDisplayname).catch(function(e) { return cli.setDisplayName(newDisplayname).catch(function() {
throw new Error("Failed to set display name", e); throw new Error("Failed to set display name");
}); });
}; };
render() { public render(): JSX.Element {
const EditableTextContainer = sdk.getComponent('elements.EditableTextContainer');
return ( return (
<EditableTextContainer <EditableTextContainer
getInitialValue={this._getDisplayName} getInitialValue={this.getDisplayName}
placeholder={_t("No display name")} placeholder={_t("No display name")}
blurToSubmit={true} blurToSubmit={true}
onSubmit={this._changeDisplayName} /> onSubmit={this.changeDisplayName} />
); );
} }
} }

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,52 +15,50 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { IMyDevice } from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
import DevicesPanelEntry from "./DevicesPanelEntry";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
interface IProps {
className?: string;
}
interface IState {
devices: IMyDevice[];
deviceLoadError?: string;
selectedDevices?: string[];
deleting?: boolean;
}
@replaceableComponent("views.settings.DevicesPanel") @replaceableComponent("views.settings.DevicesPanel")
export default class DevicesPanel extends React.Component { export default class DevicesPanel extends React.Component<IProps, IState> {
constructor(props) { private unmounted = false;
super(props);
this.state = { public componentDidMount(): void {
devices: undefined, this.loadDevices();
deviceLoadError: undefined,
selectedDevices: [],
deleting: false,
};
this._unmounted = false;
this._renderDevice = this._renderDevice.bind(this);
this._onDeviceSelectionToggled = this._onDeviceSelectionToggled.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this);
} }
componentDidMount() { public componentWillUnmount(): void {
this._loadDevices(); this.unmounted = true;
} }
componentWillUnmount() { private loadDevices(): void {
this._unmounted = true;
}
_loadDevices() {
MatrixClientPeg.get().getDevices().then( MatrixClientPeg.get().getDevices().then(
(resp) => { (resp) => {
if (this._unmounted) { return; } if (this.unmounted) { return; }
this.setState({ devices: resp.devices || [] }); this.setState({ devices: resp.devices || [] });
}, },
(error) => { (error) => {
if (this._unmounted) { return; } if (this.unmounted) { return; }
let errtxt; let errtxt;
if (error.httpStatus == 404) { if (error.httpStatus == 404) {
// 404 probably means the HS doesn't yet support the API. // 404 probably means the HS doesn't yet support the API.
@ -79,7 +76,7 @@ export default class DevicesPanel extends React.Component {
* compare two devices, sorting from most-recently-seen to least-recently-seen * compare two devices, sorting from most-recently-seen to least-recently-seen
* (and then, for stability, by device id) * (and then, for stability, by device id)
*/ */
_deviceCompare(a, b) { private deviceCompare(a: IMyDevice, b: IMyDevice): number {
// return < 0 if a comes before b, > 0 if a comes after b. // return < 0 if a comes before b, > 0 if a comes after b.
const lastSeenDelta = const lastSeenDelta =
(b.last_seen_ts || 0) - (a.last_seen_ts || 0); (b.last_seen_ts || 0) - (a.last_seen_ts || 0);
@ -91,8 +88,8 @@ export default class DevicesPanel extends React.Component {
return (idA < idB) ? -1 : (idA > idB) ? 1 : 0; return (idA < idB) ? -1 : (idA > idB) ? 1 : 0;
} }
_onDeviceSelectionToggled(device) { private onDeviceSelectionToggled = (device: IMyDevice): void => {
if (this._unmounted) { return; } if (this.unmounted) { return; }
const deviceId = device.device_id; const deviceId = device.device_id;
this.setState((state, props) => { this.setState((state, props) => {
@ -108,22 +105,21 @@ export default class DevicesPanel extends React.Component {
return { selectedDevices }; return { selectedDevices };
}); });
} };
_onDeleteClick() { private onDeleteClick = (): void => {
this.setState({ this.setState({
deleting: true, deleting: true,
}); });
this._makeDeleteRequest(null).catch((error) => { this.makeDeleteRequest(null).catch((error) => {
if (this._unmounted) { return; } if (this.unmounted) { return; }
if (error.httpStatus !== 401 || !error.data || !error.data.flows) { if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// doesn't look like an interactive-auth failure // doesn't look like an interactive-auth failure
throw error; throw error;
} }
// pop up an interactive auth dialog // pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const numDevices = this.state.selectedDevices.length; const numDevices = this.state.selectedDevices.length;
const dialogAesthetics = { const dialogAesthetics = {
@ -148,7 +144,7 @@ export default class DevicesPanel extends React.Component {
title: _t("Authentication"), title: _t("Authentication"),
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
authData: error.data, authData: error.data,
makeRequest: this._makeDeleteRequest.bind(this), makeRequest: this.makeDeleteRequest.bind(this),
aestheticsForStagePhases: { aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
@ -156,15 +152,16 @@ export default class DevicesPanel extends React.Component {
}); });
}).catch((e) => { }).catch((e) => {
console.error("Error deleting sessions", e); console.error("Error deleting sessions", e);
if (this._unmounted) { return; } if (this.unmounted) { return; }
}).finally(() => { }).finally(() => {
this.setState({ this.setState({
deleting: false, deleting: false,
}); });
}); });
} };
_makeDeleteRequest(auth) { // TODO: proper typing for auth
private makeDeleteRequest(auth?: any): Promise<any> {
return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then( return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then(
() => { () => {
// Remove the deleted devices from `devices`, reset selection to [] // Remove the deleted devices from `devices`, reset selection to []
@ -178,20 +175,16 @@ export default class DevicesPanel extends React.Component {
); );
} }
_renderDevice(device) { private renderDevice = (device: IMyDevice): JSX.Element => {
const DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry');
return <DevicesPanelEntry return <DevicesPanelEntry
key={device.device_id} key={device.device_id}
device={device} device={device}
selected={this.state.selectedDevices.includes(device.device_id)} selected={this.state.selectedDevices.includes(device.device_id)}
onDeviceToggled={this._onDeviceSelectionToggled} onDeviceToggled={this.onDeviceSelectionToggled}
/>; />;
} };
render() {
const Spinner = sdk.getComponent("elements.Spinner");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
public render(): JSX.Element {
if (this.state.deviceLoadError !== undefined) { if (this.state.deviceLoadError !== undefined) {
const classes = classNames(this.props.className, "error"); const classes = classNames(this.props.className, "error");
return ( return (
@ -204,15 +197,14 @@ export default class DevicesPanel extends React.Component {
const devices = this.state.devices; const devices = this.state.devices;
if (devices === undefined) { if (devices === undefined) {
// still loading // still loading
const classes = this.props.className; return <Spinner />;
return <Spinner className={classes} />;
} }
devices.sort(this._deviceCompare); devices.sort(this.deviceCompare);
const deleteButton = this.state.deleting ? const deleteButton = this.state.deleting ?
<Spinner w={22} h={22} /> : <Spinner w={22} h={22} /> :
<AccessibleButton onClick={this._onDeleteClick} kind="danger_sm"> <AccessibleButton onClick={this.onDeleteClick} kind="danger_sm">
{ _t("Delete %(count)s sessions", { count: this.state.selectedDevices.length }) } { _t("Delete %(count)s sessions", { count: this.state.selectedDevices.length }) }
</AccessibleButton>; </AccessibleButton>;
@ -227,12 +219,8 @@ export default class DevicesPanel extends React.Component {
{ this.state.selectedDevices.length > 0 ? deleteButton : null } { this.state.selectedDevices.length > 0 ? deleteButton : null }
</div> </div>
</div> </div>
{ devices.map(this._renderDevice) } { devices.map(this.renderDevice) }
</div> </div>
); );
} }
} }
DevicesPanel.propTypes = {
className: PropTypes.string,
};

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 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.
@ -15,30 +15,28 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { IMyDevice } from 'matrix-js-sdk/src/client';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { formatDate } from '../../../DateUtils'; import { formatDate } from '../../../DateUtils';
import StyledCheckbox from '../elements/StyledCheckbox'; import StyledCheckbox from '../elements/StyledCheckbox';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import EditableTextContainer from "../elements/EditableTextContainer";
interface IProps {
device?: IMyDevice;
onDeviceToggled?: (device: IMyDevice) => void;
selected?: boolean;
}
@replaceableComponent("views.settings.DevicesPanelEntry") @replaceableComponent("views.settings.DevicesPanelEntry")
export default class DevicesPanelEntry extends React.Component { export default class DevicesPanelEntry extends React.Component<IProps> {
constructor(props) { public static defaultProps = {
super(props); onDeviceToggled: () => {},
};
this._unmounted = false; private onDisplayNameChanged = (value: string): Promise<{}> => {
this.onDeviceToggled = this.onDeviceToggled.bind(this);
this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this);
}
componentWillUnmount() {
this._unmounted = true;
}
_onDisplayNameChanged(value) {
const device = this.props.device; const device = this.props.device;
return MatrixClientPeg.get().setDeviceDetails(device.device_id, { return MatrixClientPeg.get().setDeviceDetails(device.device_id, {
display_name: value, display_name: value,
@ -46,15 +44,13 @@ export default class DevicesPanelEntry extends React.Component {
console.error("Error setting session display name", e); console.error("Error setting session display name", e);
throw new Error(_t("Failed to set display name")); throw new Error(_t("Failed to set display name"));
}); });
} };
onDeviceToggled() { private onDeviceToggled = (): void => {
this.props.onDeviceToggled(this.props.device); this.props.onDeviceToggled(this.props.device);
} };
render() {
const EditableTextContainer = sdk.getComponent('elements.EditableTextContainer');
public render(): JSX.Element {
const device = this.props.device; const device = this.props.device;
let lastSeen = ""; let lastSeen = "";
@ -76,7 +72,7 @@ export default class DevicesPanelEntry extends React.Component {
</div> </div>
<div className="mx_DevicesPanel_deviceName"> <div className="mx_DevicesPanel_deviceName">
<EditableTextContainer initialValue={device.display_name} <EditableTextContainer initialValue={device.display_name}
onSubmit={this._onDisplayNameChanged} onSubmit={this.onDisplayNameChanged}
placeholder={device.device_id} placeholder={device.device_id}
/> />
</div> </div>
@ -90,12 +86,3 @@ export default class DevicesPanelEntry extends React.Component {
); );
} }
} }
DevicesPanelEntry.propTypes = {
device: PropTypes.object.isRequired,
onDeviceToggled: PropTypes.func,
};
DevicesPanelEntry.defaultProps = {
onDeviceToggled: function() {},
};

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 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,53 +15,55 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
import Spinner from "../elements/Spinner";
@replaceableComponent("views.settings.IntegrationManager") interface IProps {
export default class IntegrationManager extends React.Component {
static propTypes = {
// false to display an error saying that we couldn't connect to the integration manager // false to display an error saying that we couldn't connect to the integration manager
connected: PropTypes.bool.isRequired, connected: boolean;
// true to display a loading spinner // true to display a loading spinner
loading: PropTypes.bool.isRequired, loading: boolean;
// The source URL to load // The source URL to load
url: PropTypes.string, url?: string;
// callback when the manager is dismissed // callback when the manager is dismissed
onFinished: PropTypes.func.isRequired, onFinished: () => void;
}; }
static defaultProps = { interface IState {
errored: boolean;
}
@replaceableComponent("views.settings.IntegrationManager")
export default class IntegrationManager extends React.Component<IProps, IState> {
private dispatcherRef: string;
public static defaultProps = {
connected: true, connected: true,
loading: false, loading: false,
}; };
constructor(props) { public state = {
super(props);
this.state = {
errored: false, errored: false,
}; };
}
componentDidMount() { public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
document.addEventListener("keydown", this.onKeyDown); document.addEventListener("keydown", this.onKeyDown);
} }
componentWillUnmount() { public componentWillUnmount(): void {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown); document.removeEventListener("keydown", this.onKeyDown);
} }
onKeyDown = (ev) => { private onKeyDown = (ev: KeyboardEvent): void => {
if (ev.key === Key.ESCAPE) { if (ev.key === Key.ESCAPE) {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
@ -70,19 +71,18 @@ export default class IntegrationManager extends React.Component {
} }
}; };
onAction = (payload) => { private onAction = (payload: ActionPayload): void => {
if (payload.action === 'close_scalar') { if (payload.action === 'close_scalar') {
this.props.onFinished(); this.props.onFinished();
} }
}; };
onError = () => { private onError = (): void => {
this.setState({ errored: true }); this.setState({ errored: true });
}; };
render() { public render(): JSX.Element {
if (this.props.loading) { if (this.props.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return ( return (
<div className='mx_IntegrationManager_loading'> <div className='mx_IntegrationManager_loading'>
<h3>{ _t("Connecting to integration manager...") }</h3> <h3>{ _t("Connecting to integration manager...") }</h3>

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd 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.
@ -19,17 +19,30 @@ import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Field from "../elements/Field"; import Field from "../elements/Field";
import { getHostingLink } from '../../../utils/HostingLink'; import { getHostingLink } from '../../../utils/HostingLink';
import * as sdk from "../../../index";
import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import AccessibleButton from '../elements/AccessibleButton';
import AvatarSetting from './AvatarSetting';
interface IState {
userId?: string;
originalDisplayName?: string;
displayName?: string;
originalAvatarUrl?: string;
avatarUrl?: string | ArrayBuffer;
avatarFile?: File;
enableProfileSave?: boolean;
}
@replaceableComponent("views.settings.ProfileSettings") @replaceableComponent("views.settings.ProfileSettings")
export default class ProfileSettings extends React.Component { export default class ProfileSettings extends React.Component<{}, IState> {
constructor() { private avatarUpload: React.RefObject<HTMLInputElement> = createRef();
super();
constructor(props: {}) {
super(props);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
let avatarUrl = OwnProfileStore.instance.avatarMxc; let avatarUrl = OwnProfileStore.instance.avatarMxc;
@ -43,17 +56,15 @@ export default class ProfileSettings extends React.Component {
avatarFile: null, avatarFile: null,
enableProfileSave: false, enableProfileSave: false,
}; };
this._avatarUpload = createRef();
} }
_uploadAvatar = () => { private uploadAvatar = (): void => {
this._avatarUpload.current.click(); this.avatarUpload.current.click();
}; };
_removeAvatar = () => { private removeAvatar = (): void => {
// clear file upload field so same file can be selected // clear file upload field so same file can be selected
this._avatarUpload.current.value = ""; this.avatarUpload.current.value = "";
this.setState({ this.setState({
avatarUrl: null, avatarUrl: null,
avatarFile: null, avatarFile: null,
@ -61,7 +72,7 @@ export default class ProfileSettings extends React.Component {
}); });
}; };
_cancelProfileChanges = async (e) => { private cancelProfileChanges = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -74,7 +85,7 @@ export default class ProfileSettings extends React.Component {
}); });
}; };
_saveProfile = async (e) => { private saveProfile = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -82,7 +93,7 @@ export default class ProfileSettings extends React.Component {
this.setState({ enableProfileSave: false }); this.setState({ enableProfileSave: false });
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const newState = {}; const newState: IState = {};
const displayName = this.state.displayName.trim(); const displayName = this.state.displayName.trim();
try { try {
@ -115,14 +126,14 @@ export default class ProfileSettings extends React.Component {
this.setState(newState); this.setState(newState);
}; };
_onDisplayNameChanged = (e) => { private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
displayName: e.target.value, displayName: e.target.value,
enableProfileSave: true, enableProfileSave: true,
}); });
}; };
_onAvatarChanged = (e) => { private onAvatarChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) { if (!e.target.files || !e.target.files.length) {
this.setState({ this.setState({
avatarUrl: this.state.originalAvatarUrl, avatarUrl: this.state.originalAvatarUrl,
@ -144,7 +155,7 @@ export default class ProfileSettings extends React.Component {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
render() { public render(): JSX.Element {
const hostingSignupLink = getHostingLink('user-settings'); const hostingSignupLink = getHostingLink('user-settings');
let hostingSignup = null; let hostingSignup = null;
if (hostingSignupLink) { if (hostingSignupLink) {
@ -161,20 +172,18 @@ export default class ProfileSettings extends React.Component {
</span>; </span>;
} }
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AvatarSetting = sdk.getComponent('settings.AvatarSetting');
return ( return (
<form <form
onSubmit={this._saveProfile} onSubmit={this.saveProfile}
autoComplete="off" autoComplete="off"
noValidate={true} noValidate={true}
className="mx_ProfileSettings_profileForm" className="mx_ProfileSettings_profileForm"
> >
<input <input
type="file" type="file"
ref={this._avatarUpload} ref={this.avatarUpload}
className="mx_ProfileSettings_avatarUpload" className="mx_ProfileSettings_avatarUpload"
onChange={this._onAvatarChanged} onChange={this.onAvatarChanged}
accept="image/*" accept="image/*"
/> />
<div className="mx_ProfileSettings_profile"> <div className="mx_ProfileSettings_profile">
@ -185,7 +194,7 @@ export default class ProfileSettings extends React.Component {
type="text" type="text"
value={this.state.displayName} value={this.state.displayName}
autoComplete="off" autoComplete="off"
onChange={this._onDisplayNameChanged} onChange={this.onDisplayNameChanged}
/> />
<p> <p>
{ this.state.userId } { this.state.userId }
@ -193,22 +202,22 @@ export default class ProfileSettings extends React.Component {
</p> </p>
</div> </div>
<AvatarSetting <AvatarSetting
avatarUrl={this.state.avatarUrl} avatarUrl={this.state.avatarUrl.toString()}
avatarName={this.state.displayName || this.state.userId} avatarName={this.state.displayName || this.state.userId}
avatarAltText={_t("Profile picture")} avatarAltText={_t("Profile picture")}
uploadAvatar={this._uploadAvatar} uploadAvatar={this.uploadAvatar}
removeAvatar={this._removeAvatar} /> removeAvatar={this.removeAvatar} />
</div> </div>
<div className="mx_ProfileSettings_buttons"> <div className="mx_ProfileSettings_buttons">
<AccessibleButton <AccessibleButton
onClick={this._cancelProfileChanges} onClick={this.cancelProfileChanges}
kind="link" kind="link"
disabled={!this.state.enableProfileSave} disabled={!this.state.enableProfileSave}
> >
{ _t("Cancel") } { _t("Cancel") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton <AccessibleButton
onClick={this._saveProfile} onClick={this.saveProfile}
kind="primary" kind="primary"
disabled={!this.state.enableProfileSave} disabled={!this.state.enableProfileSave}
> >

View file

@ -172,7 +172,8 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
]; ];
static IMAGES_AND_VIDEOS_SETTINGS = [ static IMAGES_AND_VIDEOS_SETTINGS = [
'urlPreviewsEnabled', 'urlPreviewsEnabled',
'autoplayGifsAndVideos', 'autoplayGifs',
'autoplayVideo',
'showImages', 'showImages',
]; ];
static TIMELINE_SETTINGS = [ static TIMELINE_SETTINGS = [

View file

@ -62,6 +62,8 @@ export interface IOpts {
roomType?: RoomType | string; roomType?: RoomType | string;
historyVisibility?: HistoryVisibility; historyVisibility?: HistoryVisibility;
parentSpace?: Room; parentSpace?: Room;
// contextually only makes sense if parentSpace is specified, if true then will be added to parentSpace as suggested
suggested?: boolean;
joinRule?: JoinRule; joinRule?: JoinRule;
} }
@ -228,7 +230,7 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
} }
}).then(() => { }).then(() => {
if (opts.parentSpace) { if (opts.parentSpace) {
return SpaceStore.instance.addRoomToSpace(opts.parentSpace, roomId, [client.getDomain()], true); return SpaceStore.instance.addRoomToSpace(opts.parentSpace, roomId, [client.getDomain()], opts.suggested);
} }
if (opts.associatedWithCommunity) { if (opts.associatedWithCommunity) {
return GroupStore.addRoomToGroup(opts.associatedWithCommunity, roomId, false); return GroupStore.addRoomToGroup(opts.associatedWithCommunity, roomId, false);

View file

@ -834,7 +834,8 @@
"Show read receipts sent by other users": "Show read receipts sent by other users", "Show read receipts sent by other users": "Show read receipts sent by other users",
"Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)", "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)",
"Always show message timestamps": "Always show message timestamps", "Always show message timestamps": "Always show message timestamps",
"Autoplay GIFs and videos": "Autoplay GIFs and videos", "Autoplay GIFs": "Autoplay GIFs",
"Autoplay videos": "Autoplay videos",
"Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting", "Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting",
"Expand code blocks by default": "Expand code blocks by default", "Expand code blocks by default": "Expand code blocks by default",
"Show line numbers in code blocks": "Show line numbers in code blocks", "Show line numbers in code blocks": "Show line numbers in code blocks",

View file

@ -393,9 +393,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td('Always show message timestamps'), displayName: _td('Always show message timestamps'),
default: false, default: false,
}, },
"autoplayGifsAndVideos": { "autoplayGifs": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Autoplay GIFs and videos'), displayName: _td('Autoplay GIFs'),
default: false,
},
"autoplayVideo": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Autoplay videos'),
default: false, default: false,
}, },
"enableSyntaxHighlightLanguageDetection": { "enableSyntaxHighlightLanguageDetection": {

View file

@ -110,6 +110,21 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
return content ? content['enabled'] : null; return content ? content['enabled'] : null;
} }
// Special case for autoplaying videos and GIFs
if (["autoplayGifs", "autoplayVideo"].includes(settingName)) {
const settings = this.getSettings() || {};
const value = settings[settingName];
// Fallback to old combined setting
if (value === null || value === undefined) {
const oldCombinedValue = settings["autoplayGifsAndVideos"];
// Write, so that we can remove this in the future
this.setValue("autoplayGifs", roomId, oldCombinedValue);
this.setValue("autoplayVideo", roomId, oldCombinedValue);
return oldCombinedValue;
}
return value;
}
const settings = this.getSettings() || {}; const settings = this.getSettings() || {};
let preferredValue = settings[settingName]; let preferredValue = settings[settingName];

View file

@ -301,7 +301,10 @@ export class StopGapWidget extends EventEmitter {
// requests timeline capabilities in other rooms down the road. It's just easier to manage here. // requests timeline capabilities in other rooms down the road. It's just easier to manage here.
for (const room of MatrixClientPeg.get().getRooms()) { for (const room of MatrixClientPeg.get().getRooms()) {
// Timelines are most recent last // Timelines are most recent last
this.readUpToMap[room.roomId] = arrayFastClone(room.getLiveTimeline().getEvents()).reverse()[0].getId(); const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1];
if (!roomEvent) continue; // force later code to think the room is fresh
this.readUpToMap[room.roomId] = roomEvent.getId();
} }
// Attach listeners for feeding events - the underlying widget classes handle permissions for us // Attach listeners for feeding events - the underlying widget classes handle permissions for us

View file

@ -46,7 +46,7 @@ describe('<SendMessageComposer/>', () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("hello world", "insertText", { offset: 11, atNodeEnd: true }); model.update("hello world", "insertText", { offset: 11, atNodeEnd: true });
const content = createMessageContent(model, permalinkCreator); const content = createMessageContent(model, null, false, permalinkCreator);
expect(content).toEqual({ expect(content).toEqual({
body: "hello world", body: "hello world",
@ -58,7 +58,7 @@ describe('<SendMessageComposer/>', () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("hello *world*", "insertText", { offset: 13, atNodeEnd: true }); model.update("hello *world*", "insertText", { offset: 13, atNodeEnd: true });
const content = createMessageContent(model, permalinkCreator); const content = createMessageContent(model, null, false, permalinkCreator);
expect(content).toEqual({ expect(content).toEqual({
body: "hello *world*", body: "hello *world*",
@ -72,7 +72,7 @@ describe('<SendMessageComposer/>', () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("/me blinks __quickly__", "insertText", { offset: 22, atNodeEnd: true }); model.update("/me blinks __quickly__", "insertText", { offset: 22, atNodeEnd: true });
const content = createMessageContent(model, permalinkCreator); const content = createMessageContent(model, null, false, permalinkCreator);
expect(content).toEqual({ expect(content).toEqual({
body: "blinks __quickly__", body: "blinks __quickly__",
@ -86,7 +86,7 @@ describe('<SendMessageComposer/>', () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("//dev/null is my favourite place", "insertText", { offset: 32, atNodeEnd: true }); model.update("//dev/null is my favourite place", "insertText", { offset: 32, atNodeEnd: true });
const content = createMessageContent(model, permalinkCreator); const content = createMessageContent(model, null, false, permalinkCreator);
expect(content).toEqual({ expect(content).toEqual({
body: "/dev/null is my favourite place", body: "/dev/null is my favourite place",