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

 Conflicts:
	src/components/structures/LoggedInView.tsx
	src/stores/SpaceStore.tsx
This commit is contained in:
Michael Telatynski 2021-09-06 11:41:09 +01:00
commit a688e5b8b3
318 changed files with 7386 additions and 4032 deletions

View file

@ -15,34 +15,30 @@ limitations under the License.
*/
import React from "react";
import { formatSeconds } from "../../../DateUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export interface IProps {
seconds: number;
}
interface IState {
}
/**
* Simply converts seconds into minutes and seconds. Note that hours will not be
* displayed, making it possible to see "82:29".
*/
@replaceableComponent("views.audio_messages.Clock")
export default class Clock extends React.Component<IProps, IState> {
export default class Clock extends React.Component<IProps> {
public constructor(props) {
super(props);
}
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor;
}
public render() {
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
return <span className='mx_Clock'>{ minutes }:{ seconds }</span>;
return <span className='mx_Clock'>{ formatSeconds(this.props.seconds) }</span>;
}
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
import classNames from 'classnames';
import { MatrixClient } from "matrix-js-sdk/src/client";
import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
@ -74,33 +75,6 @@ import CaptchaForm from "./CaptchaForm";
* focus: set the input focus appropriately in the form.
*/
enum AuthType {
Password = "m.login.password",
Recaptcha = "m.login.recaptcha",
Terms = "m.login.terms",
Email = "m.login.email.identity",
Msisdn = "m.login.msisdn",
Sso = "m.login.sso",
SsoUnstable = "org.matrix.login.sso",
}
/* eslint-disable camelcase */
interface IAuthDict {
type?: AuthType;
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user?: string;
identifier?: any;
password?: string;
response?: string;
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds?: any;
threepidCreds?: any;
}
/* eslint-enable camelcase */
export const DEFAULT_PHASE = 0;
interface IAuthEntryProps {
@ -835,7 +809,26 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
}
}
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
export interface IStageComponentProps extends IAuthEntryProps {
clientSecret?: string;
stageParams?: Record<string, any>;
inputs?: IInputs;
stageState?: IStageStatus;
showContinue?: boolean;
continueText?: string;
continueKind?: string;
fail?(e: Error): void;
setEmailSid?(sid: string): void;
onCancel?(): void;
}
export interface IStageComponent extends React.ComponentClass<React.PropsWithRef<IStageComponentProps>> {
tryContinue?(): void;
attemptFailed?(): void;
focus?(): void;
}
export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
switch (loginType) {
case AuthType.Password:
return PasswordAuthEntry;

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`
viewUserOnClick?: boolean;
title?: string;
style?: any;
}
interface IState {

View file

@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import AccessibleButton from "../elements/AccessibleButton";
import * as React from "react";
import { createRef } from "react";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
@ -32,6 +33,8 @@ interface IState {
@replaceableComponent("views.context_menus.DialpadContextMenu")
export default class DialpadContextMenu extends React.Component<IProps, IState> {
private numberEntryFieldRef: React.RefObject<Field> = createRef();
constructor(props) {
super(props);
@ -40,9 +43,16 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
};
}
onDigitPress = (digit) => {
onDigitPress = (digit: string, ev: ButtonEvent) => {
this.props.call.sendDtmfDigit(digit);
this.setState({ value: this.state.value + digit });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
onCancelClick = () => {
@ -68,6 +78,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
</div>
<div className="mx_DialPadContextMenu_header">
<Field
ref={this.numberEntryFieldRef}
className="mx_DialPadContextMenu_dialled"
value={this.state.value}
autoFocus={true}

View file

@ -29,11 +29,13 @@ import BaseDialog from "./BaseDialog";
import Field from '../elements/Field';
import Spinner from "../elements/Spinner";
import DialogButtons from "../elements/DialogButtons";
import { sendSentryReport } from "../../../sentry";
interface IProps {
onFinished: (success: boolean) => void;
initialText?: string;
label?: string;
error?: Error;
}
interface IState {
@ -113,6 +115,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
});
}
});
sendSentryReport(this.state.text, this.state.issueUrl, this.props.error);
};
private onDownload = async (): Promise<void> => {
@ -200,8 +204,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
{ _t(
"Debug logs contain application usage data including your " +
"username, the IDs or aliases of the rooms or groups you " +
"have visited and the usernames of other users. They do " +
"not contain messages.",
"have visited, which UI elements you last interacted with, " +
"and the usernames of other users. They do not contain messages.",
) }
</p>
<p><b>
@ -211,7 +215,7 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
{
a: (sub) => <a
target="_blank"
href="https://github.com/vector-im/element-web/issues/new"
href="https://github.com/vector-im/element-web/issues/new/choose"
>
{ sub }
</a>,

View file

@ -39,11 +39,13 @@ interface IProps {
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
defaultEncrypted?: boolean;
onFinished(proceed: boolean, opts?: IOpts): void;
}
interface IState {
joinRule: JoinRule;
isPublic: boolean;
isEncrypted: boolean;
name: string;
topic: string;
@ -74,8 +76,9 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
const config = SdkConfig.get();
this.state = {
isPublic: this.props.defaultPublic || false,
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(),
joinRule,
isEncrypted: privateShouldBeEncrypted(),
name: this.props.defaultName || "",
topic: "",
alias: "",

View file

@ -79,7 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
}
try {
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule });
onFinished(true);
} catch (e) {

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import { AuthType, IAuthData } from 'matrix-js-sdk/src/interactive-auth';
import Analytics from '../../../Analytics';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -65,7 +66,7 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
this.initAuth(/* shouldErase= */false);
}
private onStagePhaseChange = (stage: string, phase: string): void => {
private onStagePhaseChange = (stage: AuthType, phase: string): void => {
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
body: _t("Confirm your account deactivation by using Single Sign On to prove your identity."),
@ -115,7 +116,10 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
this.setState({ errStr: _t("There was a problem communicating with the server. Please try again.") });
};
private onUIAuthComplete = (auth: any): void => {
private onUIAuthComplete = (auth: IAuthData): void => {
// XXX: this should be returning a promise to maintain the state inside the state machine correct
// but given that a deactivation is followed by a local logout and all object instances being thrown away
// this isn't done.
MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase).then(r => {
// Deactivation worked - logout & close this dialog
Analytics.trackEvent('Account', 'Deactivate Account');
@ -180,7 +184,9 @@ export default class DeactivateAccountDialog extends React.Component<IProps, ISt
<InteractiveAuth
matrixClient={MatrixClientPeg.get()}
authData={this.state.authData}
makeRequest={this.onUIAuthComplete}
// XXX: onUIAuthComplete breaches the expected method contract, it gets away with it because it
// knows the entire app is about to die as a result of the account deactivation.
makeRequest={this.onUIAuthComplete as any}
onAuthFinished={this.onUIAuthFinished}
onStagePhaseChange={this.onStagePhaseChange}
continueText={this.state.continueText}

View file

@ -28,7 +28,7 @@ import StyledRadioGroup from "../elements/StyledRadioGroup";
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose";
export default (props) => {
const [rating, setRating] = useState("");

View file

@ -55,7 +55,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import { getAddressType } from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings';
import { IInvite3PID } from "matrix-js-sdk/src/@types/requests";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
@ -394,6 +394,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
private closeCopiedTooltip: () => void;
private debounceTimer: number = null; // actually number because we're in the browser
private editorRef = createRef<HTMLInputElement>();
private numberEntryFieldRef: React.RefObject<Field> = createRef();
private unmounted = false;
constructor(props) {
@ -1283,13 +1284,27 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({ dialPadValue: ev.currentTarget.value });
};
private onDigitPress = digit => {
private onDigitPress = (digit: string, ev: ButtonEvent) => {
this.setState({ dialPadValue: this.state.dialPadValue + digit });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
private onDeletePress = () => {
private onDeletePress = (ev: ButtonEvent) => {
if (this.state.dialPadValue.length === 0) return;
this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
private onTabChange = (tabId: TabId) => {
@ -1543,6 +1558,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
let dialPadField;
if (this.state.dialPadValue.length !== 0) {
dialPadField = <Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
@ -1552,6 +1568,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
/>;
} else {
dialPadField = <Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}

View file

@ -80,7 +80,7 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.All);
const [state, setState] = useState<string>(RoomsToLeave.None);
useEffect(() => {
if (state === RoomsToLeave.All) {
@ -97,11 +97,11 @@ const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave
onChange={setState}
definitions={[
{
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020 - 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.
@ -20,6 +20,7 @@ import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import {
Capability,
isTimelineCapability,
Widget,
WidgetEventCapability,
WidgetKind,
@ -30,14 +31,7 @@ import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
}
function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) {
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}
import { lexicographicCompare } from "matrix-js-sdk/src/utils";
interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>;
@ -95,14 +89,24 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
};
private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) {
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({ approved });
this.props.onFinished({ approved, remember: this.state.rememberSelection });
}
public render() {
const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => {
// We specifically order the timeline capabilities down to the bottom. The capability text
// generation cares strongly about this.
const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => {
const isTimelineA = isTimelineCapability(capA);
const isTimelineB = isTimelineCapability(capB);
if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB);
if (isTimelineA && !isTimelineB) return 1;
if (!isTimelineA && isTimelineB) return -1;
if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB);
return 0;
});
const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{ text.byline }</span>

View file

@ -19,7 +19,7 @@ import React, { ReactHTML } from 'react';
import { Key } from '../../../Keyboard';
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.
@ -39,7 +39,7 @@ interface IProps extends React.InputHTMLAttributes<Element> {
tabIndex?: number;
disabled?: boolean;
className?: string;
onClick(e?: ButtonEvent): void;
onClick(e?: ButtonEvent): void | Promise<void>;
}
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {

View file

@ -218,6 +218,7 @@ export default class AppTile extends React.Component {
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
if (this._sgWidget) this._sgWidget.stop({ forceDestroy: true });
}
@ -307,7 +308,6 @@ export default class AppTile extends React.Component {
if (this.iframe) {
// Reload iframe
this.iframe.src = this._sgWidget.embedUrl;
this.setState({});
}
});
}
@ -333,7 +333,7 @@ export default class AppTile extends React.Component {
// this would only be for content hosted on the same origin as the element client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox " +
"allow-same-origin allow-scripts allow-presentation";
// Additional iframe feature pemissions
@ -443,25 +443,25 @@ export default class AppTile extends React.Component {
return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
{ this.props.showMenubar &&
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/>
</span>
</div> }
<div className="mx_AppTileMenuBar">
<span className="mx_AppTileMenuBarTitle" style={{ pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false) }}>
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ this.props.showPopout && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}
onClick={this._onPopoutWidgetClick}
/> }
<ContextMenuButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_menu"
label={_t("Options")}
isExpanded={this.state.menuDisplayed}
inputRef={this._contextMenuButton}
onClick={this._onContextMenuClick}
/>
</span>
</div> }
{ appTileBody }
</div>

View file

@ -15,11 +15,11 @@ limitations under the License.
*/
import * as React from "react";
import AccessibleButton from "./AccessibleButton";
import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
interface IProps {
// Callback for when the button is pressed
onBackspacePress: () => void;
onBackspacePress: (ev: ButtonEvent) => void;
}
export default class DialPadBackspaceButton extends React.PureComponent<IProps> {

View file

@ -71,12 +71,13 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
private onBugReport = (): void => {
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
label: 'react-soft-crash',
error: this.state.error,
});
};
render() {
if (this.state.error) {
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose";
let bugReportSection;
if (SdkConfig.get().bug_report_endpoint_url) {
@ -93,8 +94,9 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
"If you've submitted a bug via GitHub, debug logs can help " +
"us track down the problem. Debug logs contain application " +
"usage data including your username, the IDs or aliases of " +
"the rooms or groups you have visited and the usernames of " +
"other users. They do not contain messages.",
"the rooms or groups you have visited, which UI elements you " +
"last interacted with, and the usernames of other users. " +
"They do not contain messages.",
) }</p>
<AccessibleButton onClick={this.onBugReport} kind='primary'>
{ _t("Submit debug logs") }

View file

@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
interface IProps {
/**
@ -45,7 +46,7 @@ interface IProps {
/**
* The ID of the displayed user
*/
userId: string;
userId?: string;
/**
* The display name of the displayed user
@ -118,13 +119,16 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
}
public render() {
const event = this.fakeEvent(this.state);
const className = classnames(this.props.className, {
"mx_IRCLayout": this.props.layout == Layout.IRC,
"mx_GroupLayout": this.props.layout == Layout.Group,
"mx_EventTilePreview_loader": !this.props.userId,
});
if (!this.props.userId) return <div className={className}><Spinner /></div>;
const event = this.fakeEvent(this.state);
return <div className={className}>
<EventTile
mxEvent={event}

View file

@ -419,6 +419,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const avatar = (
<MemberAvatar
member={mxEvent.sender}
fallbackUserId={mxEvent.getSender()}
width={32}
height={32}
viewUserOnClick={true}

View file

@ -16,11 +16,13 @@ limitations under the License.
import classNames from "classnames";
import React from "react";
import { sanitizedHtmlNode } from "../../../HtmlUtils";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
reason: string;
htmlReason?: string;
}
interface IState {
@ -51,7 +53,7 @@ export default class InviteReason extends React.PureComponent<IProps, IState> {
});
return <div className={classes}>
<div className="mx_InviteReason_reason">{ this.props.reason }</div>
<div className="mx_InviteReason_reason">{ this.props.htmlReason ? sanitizedHtmlNode(this.props.htmlReason) : this.props.reason }</div>
<div className="mx_InviteReason_view"
onClick={this.onViewClick}
>

View file

@ -19,6 +19,7 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
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 SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
@ -206,15 +207,28 @@ export default class ReplyThread extends React.Component<IProps, IState> {
return { body, html };
}
public static makeReplyMixIn(ev: MatrixEvent) {
public static makeReplyMixIn(ev: MatrixEvent, replyInThread: boolean) {
if (!ev) return {};
return {
const replyMixin = {
'm.relates_to': {
'm.in_reply_to': {
'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(

View file

@ -1,27 +1,27 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
//see src/resizer for the actual resizing code, this is just the DOM for the resize handle
const ResizeHandle = (props) => {
interface IResizeHandleProps {
vertical?: boolean;
reverse?: boolean;
id?: string;
passRef?: React.RefObject<HTMLDivElement>;
}
const ResizeHandle: React.FC<IResizeHandleProps> = ({ vertical, reverse, id, passRef }) => {
const classNames = ['mx_ResizeHandle'];
if (props.vertical) {
if (vertical) {
classNames.push('mx_ResizeHandle_vertical');
} else {
classNames.push('mx_ResizeHandle_horizontal');
}
if (props.reverse) {
if (reverse) {
classNames.push('mx_ResizeHandle_reverse');
}
return (
<div className={classNames.join(' ')} data-id={props.id}><div /></div>
<div ref={passRef} className={classNames.join(' ')} data-id={id}><div /></div>
);
};
ResizeHandle.propTypes = {
vertical: PropTypes.bool,
reverse: PropTypes.bool,
id: PropTypes.string,
};
export default ResizeHandle;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import React, { createRef, KeyboardEventHandler } from "react";
import { _t } from '../../../languageHandler';
import withValidation from './Validation';
@ -28,6 +28,7 @@ interface IProps {
label?: string;
placeholder?: string;
disabled?: boolean;
onKeyDown?: KeyboardEventHandler;
onChange?(value: string): void;
}
@ -70,6 +71,8 @@ export default class RoomAliasField extends React.PureComponent<IProps, IState>
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength}
disabled={this.props.disabled}
autoComplete="off"
onKeyDown={this.props.onKeyDown}
/>
);
}

View file

@ -173,16 +173,16 @@ class EmojiPicker extends React.Component<IProps, IState> {
};
private onChangeFilter = (filter: string) => {
filter = filter.toLowerCase(); // filter is case insensitive stored lower-case
const lcFilter = filter.toLowerCase().trim(); // filter is case insensitive
for (const cat of this.categories) {
let emojis;
// If the new filter string includes the old filter string, we don't have to re-filter the whole dataset.
if (filter.includes(this.state.filter)) {
if (lcFilter.includes(this.state.filter)) {
emojis = this.memoizedDataByCategory[cat.id];
} else {
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
}
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter));
emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, lcFilter));
this.memoizedDataByCategory[cat.id] = emojis;
cat.enabled = emojis.length > 0;
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
@ -194,9 +194,12 @@ class EmojiPicker extends React.Component<IProps, IState> {
setTimeout(this.updateVisibility, 0);
};
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean =>
[emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)]
.some(x => x?.includes(filter));
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => {
return emoji.annotation.toLowerCase().includes(filter) ||
emoji.emoticon?.toLowerCase().includes(filter) ||
emoji.shortcodes.some(x => x.toLowerCase().includes(filter)) ||
emoji.unicode.split(ZERO_WIDTH_JOINER).includes(filter);
};
private onEnterFilter = () => {
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { createRef } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t, _td } from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper';
import AccessibleButton from '../elements/AccessibleButton';
@ -26,6 +26,9 @@ import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import classNames from 'classnames';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
import { formatCallTime } from "../../../DateUtils";
import Clock from "../audio_messages/Clock";
const MAX_NON_NARROW_WIDTH = 450 / 70 * 100;
interface IProps {
mxEvent: MatrixEvent;
@ -35,33 +38,53 @@ interface IProps {
interface IState {
callState: CallState | CustomCallState;
silenced: boolean;
narrow: boolean;
length: number;
}
const TEXTUAL_STATES: Map<CallState | CustomCallState, string> = new Map([
[CallState.Connected, _td("Connected")],
[CallState.Connecting, _td("Connecting")],
]);
export default class CallEvent extends React.PureComponent<IProps, IState> {
private wrapperElement = createRef<HTMLDivElement>();
private resizeObserver: ResizeObserver;
export default class CallEvent extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
callState: this.props.callEventGrouper.state,
silenced: false,
narrow: false,
length: 0,
};
}
componentDidMount() {
this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback);
this.resizeObserver.observe(this.wrapperElement.current);
}
componentWillUnmount() {
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver.disconnect();
}
private onLengthChanged = (length: number): void => {
this.setState({ length });
};
private resizeObserverCallback = (entries: ResizeObserverEntry[]): void => {
const wrapperElementEntry = entries.find((entry) => entry.target === this.wrapperElement.current);
if (!wrapperElementEntry) return;
this.setState({ narrow: wrapperElementEntry.contentRect.width < MAX_NON_NARROW_WIDTH });
};
private onSilencedChanged = (newState) => {
this.setState({ silenced: newState });
};
@ -82,21 +105,32 @@ export default class CallEvent extends React.Component<IProps, IState> {
);
}
private renderSilenceIcon(): JSX.Element {
const silenceClass = classNames({
"mx_CallEvent_iconButton": true,
"mx_CallEvent_unSilence": this.state.silenced,
"mx_CallEvent_silence": !this.state.silenced,
});
return (
<AccessibleTooltipButton
className={silenceClass}
onClick={this.props.callEventGrouper.toggleSilenced}
title={this.state.silenced ? _t("Sound on") : _t("Silence call")}
/>
);
}
private renderContent(state: CallState | CustomCallState): JSX.Element {
if (state === CallState.Ringing) {
const silenceClass = classNames({
"mx_CallEvent_iconButton": true,
"mx_CallEvent_unSilence": this.state.silenced,
"mx_CallEvent_silence": !this.state.silenced,
});
let silenceIcon;
if (!this.state.narrow) {
silenceIcon = this.renderSilenceIcon();
}
return (
<div className="mx_CallEvent_content">
<AccessibleTooltipButton
className={silenceClass}
onClick={this.props.callEventGrouper.toggleSilenced}
title={this.state.silenced ? _t("Sound on"): _t("Silence call")}
/>
{ silenceIcon }
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_reject"
onClick={this.props.callEventGrouper.rejectCall}
@ -145,7 +179,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
} else if (hangupReason === CallErrorCode.InviteTimeout) {
return (
<div className="mx_CallEvent_content">
{ _t("Missed call") }
{ _t("No answer") }
{ this.renderCallBackButton(_t("Call back")) }
</div>
);
@ -169,7 +203,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
} else if (hangupReason === CallErrorCode.UserBusy) {
reason = _t("The user you called is busy.");
} else {
reason = _t('Unknown failure: %(reason)s)', { reason: hangupReason });
reason = _t('Unknown failure: %(reason)s', { reason: hangupReason });
}
return (
@ -184,10 +218,17 @@ export default class CallEvent extends React.Component<IProps, IState> {
</div>
);
}
if (Array.from(TEXTUAL_STATES.keys()).includes(state)) {
if (state === CallState.Connected) {
return (
<div className="mx_CallEvent_content">
{ TEXTUAL_STATES.get(state) }
<Clock seconds={this.state.length} />
</div>
);
}
if (state === CallState.Connecting) {
return (
<div className="mx_CallEvent_content">
{ _t("Connecting") }
</div>
);
}
@ -215,35 +256,41 @@ export default class CallEvent extends React.Component<IProps, IState> {
const callState = this.state.callState;
const hangupReason = this.props.callEventGrouper.hangupReason;
const content = this.renderContent(callState);
const className = classNames({
mx_CallEvent: true,
const className = classNames("mx_CallEvent", {
mx_CallEvent_voice: isVoice,
mx_CallEvent_video: !isVoice,
mx_CallEvent_missed: (
callState === CustomCallState.Missed ||
(callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout)
),
mx_CallEvent_narrow: this.state.narrow,
mx_CallEvent_missed: callState === CustomCallState.Missed,
mx_CallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout,
mx_CallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected,
});
let silenceIcon;
if (this.state.narrow && this.state.callState === CallState.Ringing) {
silenceIcon = this.renderSilenceIcon();
}
return (
<div className={className}>
<div className="mx_CallEvent_info">
<MemberAvatar
member={event.sender}
width={32}
height={32}
/>
<div className="mx_CallEvent_info_basic">
<div className="mx_CallEvent_sender">
{ sender }
</div>
<div className="mx_CallEvent_type">
<div className="mx_CallEvent_type_icon" />
{ callType }
<div className="mx_CallEvent_wrapper" ref={this.wrapperElement}>
<div className={className}>
{ silenceIcon }
<div className="mx_CallEvent_info">
<MemberAvatar
member={event.sender}
width={32}
height={32}
/>
<div className="mx_CallEvent_info_basic">
<div className="mx_CallEvent_sender">
{ sender }
</div>
<div className="mx_CallEvent_type">
<div className="mx_CallEvent_type_icon" />
{ callType }
</div>
</div>
</div>
{ content }
</div>
{ content }
</div>
);
}

View file

@ -24,6 +24,8 @@ import { IMediaEventContent } from "../../../customisations/models/IMediaEventCo
import MFileBody from "./MFileBody";
import { IBodyProps } from "./IBodyProps";
import { PlaybackManager } from "../../../audio/PlaybackManager";
import { isVoiceMessage } from "../../../utils/EventUtils";
import { PlaybackQueue } from "../../../audio/PlaybackQueue";
interface IState {
error?: Error;
@ -67,6 +69,10 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
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.
}

View file

@ -178,7 +178,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
private onPlaceholderClick = async () => {
const mediaHelper = this.props.mediaEventHelper;
if (mediaHelper.media.isEncrypted) {
if (mediaHelper?.media.isEncrypted) {
await this.decryptFile();
this.downloadFile(this.fileName, this.linkText);
} else {
@ -192,7 +192,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
};
public render() {
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted;
const contentUrl = this.getContentUrl();
const fileSize = this.content.info ? this.content.info.size : null;
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";

View file

@ -47,6 +47,7 @@ interface IState {
};
hover: boolean;
showImage: boolean;
placeholder: 'no-image' | 'blurhash';
}
@replaceableComponent("views.messages.MImageBody")
@ -54,6 +55,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
static contextType = MatrixClientContext;
private unmounted = true;
private image = createRef<HTMLImageElement>();
private timeout?: number;
constructor(props: IBodyProps) {
super(props);
@ -68,6 +70,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
loadedImageDimensions: null,
hover: false,
showImage: SettingsStore.getValue("showImages"),
placeholder: 'no-image',
};
}
@ -126,7 +129,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: true });
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) {
return;
}
const imgElement = e.currentTarget;
@ -136,7 +139,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: false });
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) {
return;
}
const imgElement = e.currentTarget;
@ -144,12 +147,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
};
private onImageError = (): void => {
this.clearBlurhashTimeout();
this.setState({
imgError: true,
});
};
private onImageLoad = (): void => {
this.clearBlurhashTimeout();
this.props.onHeightChanged();
let loadedImageDimensions;
@ -265,6 +270,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}
}
private clearBlurhashTimeout() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
componentDidMount() {
this.unmounted = false;
this.context.on('sync', this.onClientSync);
@ -277,11 +289,24 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
this.downloadImage();
this.setState({ showImage: true });
} // else don't download anything because we don't want to display anything.
// Add a 150ms timer for blurhash to first appear.
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
this.clearBlurhashTimeout();
this.timeout = setTimeout(() => {
if (!this.state.imgLoaded || !this.state.imgError) {
this.setState({
placeholder: 'blurhash',
});
}
}, 150);
}
}
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener('sync', this.onClientSync);
this.clearBlurhashTimeout();
}
protected messageContent(
@ -374,13 +399,13 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
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>;
}
const classes = classNames({
'mx_MImageBody_thumbnail': true,
'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info[BLURHASH_FIELD],
'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
});
// This has incredibly broken types.
@ -433,8 +458,15 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody
protected getPlaceholder(width: number, height: number): JSX.Element {
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
if (blurhash) return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
if (blurhash) {
if (this.state.placeholder === 'no-image') {
return <div className="mx_no-image-placeholder" style={{ width: width, height: height }} />;
} else if (this.state.placeholder === 'blurhash') {
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
}
}
return (
<InlineSpinner w={32} h={32} />
);
@ -467,7 +499,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
const contentUrl = this.getContentUrl();
let thumbUrl;
if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
if (this.isGif() && SettingsStore.getValue("autoplayGifs")) {
thumbUrl = contentUrl;
} else {
thumbUrl = this.getThumbUrl();

View file

@ -43,7 +43,7 @@ export default class MStickerBody extends MImageBody {
// Placeholder to show in place of the sticker image if
// img onLoad hasn't fired yet.
protected getPlaceholder(width: number, height: number): JSX.Element {
if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
return <img src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
}

View file

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

View file

@ -19,14 +19,12 @@ import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import MVoiceMessageBody from "./MVoiceMessageBody";
import { IBodyProps } from "./IBodyProps";
import { isVoiceMessage } from "../../../utils/EventUtils";
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
public render() {
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
if (isVoiceMessage) {
if (isVoiceMessage(this.props.mxEvent)) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;

View file

@ -23,6 +23,8 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import { Action } from '../../../dispatcher/actions';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext";
@ -34,6 +36,7 @@ import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton";
import SettingsStore from '../../../settings/SettingsStore';
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -170,6 +173,17 @@ export default class MessageActionBar extends React.PureComponent {
});
};
onThreadClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
allowClose: false,
refireParams: {
event: this.props.mxEvent,
},
});
}
onEditClick = (ev) => {
dis.dispatch({
action: 'edit_event',
@ -254,12 +268,22 @@ export default class MessageActionBar extends React.PureComponent {
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>);
toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>
{ SettingsStore.getValue("feature_thread") && (
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")}
onClick={this.onThreadClick}
key="thread"
/>
) }
</>);
}
if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton

View file

@ -51,6 +51,7 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
private onBugReport = (): void => {
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
label: 'react-soft-crash-tile',
error: this.state.error,
});
};

View file

@ -220,6 +220,13 @@ const onRoomFilesClick = () => {
});
};
const onRoomThreadsClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadPanel,
});
};
const onRoomSettingsClick = () => {
defaultDispatcher.dispatch({ action: "open_room_settings" });
};
@ -273,6 +280,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
{ _t("Show files") }
</Button>
{ SettingsStore.getValue("feature_thread") && (
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
{ _t("Show threads") }
</Button>
) }
<Button className="mx_RoomSummaryCard_icon_share" onClick={onShareRoomClick}>
{ _t("Share room") }
</Button>

View file

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

View file

@ -25,7 +25,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter';
import { replaceableComponent } from "../../../utils/replaceableComponent";
const COMPOSER_SELECTED = 0;
const MAX_PROVIDER_MATCHES = 20;
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
@ -34,9 +33,9 @@ interface IProps {
// the query string for which to show autocomplete suggestions
query: string;
// method invoked with range and text content when completion is confirmed
onConfirm: (ICompletion) => void;
onConfirm: (completion: ICompletion) => void;
// method invoked when selected (if any) completion changes
onSelectionChange?: (ICompletion, number) => void;
onSelectionChange?: (partIndex: number) => void;
selection: ISelectionRange;
// The room in which we're autocompleting
room: Room;
@ -71,7 +70,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completionList: [],
// how far down the completion list we are (THIS IS 1-INDEXED!)
selectionOffset: COMPOSER_SELECTED,
selectionOffset: 1,
// whether we should show completions if they're available
shouldShowCompletions: true,
@ -86,7 +85,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
this.applyNewProps();
}
private applyNewProps(oldQuery?: string, oldRoom?: Room) {
private applyNewProps(oldQuery?: string, oldRoom?: Room): void {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy();
this.autocompleter = new Autocompleter(this.props.room);
@ -104,7 +103,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
this.autocompleter.destroy();
}
complete(query: string, selection: ISelectionRange) {
private complete(query: string, selection: ISelectionRange): Promise<void> {
this.queryRequested = query;
if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest);
@ -115,7 +114,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: COMPOSER_SELECTED,
selectionOffset: 1,
// Hide the autocomplete box
hide: true,
});
@ -135,7 +134,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
processQuery(query: string, selection: ISelectionRange) {
private processQuery(query: string, selection: ISelectionRange): Promise<void> {
return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES,
).then((completions) => {
@ -147,30 +146,35 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
processCompletions(completions: IProviderCompletions[]) {
private processCompletions(completions: IProviderCompletions[]): void {
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED;
let selectionOffset = 1;
if (completionList.length > 0) {
/* If the currently selected completion is still in the completion list,
try to find it and jump to it. If not, select composer.
*/
const currentSelection = this.state.selectionOffset === 0 ? null :
const currentSelection = this.state.selectionOffset <= 1 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
(completion) => completion.completion === currentSelection);
if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED;
selectionOffset = 1;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
}
let hide = this.state.hide;
let hide = true;
// If `completion.command.command` is truthy, then a provider has matched with the query
const anyMatches = completions.some((completion) => !!completion.command.command);
hide = !anyMatches;
if (anyMatches) {
hide = false;
if (this.props.onSelectionChange) {
this.props.onSelectionChange(selectionOffset - 1);
}
}
this.setState({
completions,
@ -182,25 +186,25 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
hasSelection(): boolean {
public hasSelection(): boolean {
return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
}
countCompletions(): number {
public countCompletions(): number {
return this.state.completionList.length;
}
// called from MessageComposerInput
moveSelection(delta: number) {
public moveSelection(delta: number): void {
const completionCount = this.countCompletions();
if (completionCount === 0) return; // there are no items to move the selection through
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1);
this.setSelection(index);
const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
this.setSelection(1 + index);
}
onEscape(e: KeyboardEvent): boolean {
public onEscape(e: KeyboardEvent): boolean {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
@ -213,16 +217,16 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
this.hide();
}
hide = () => {
private hide = (): void => {
this.setState({
hide: true,
selectionOffset: 0,
selectionOffset: 1,
completions: [],
completionList: [],
});
};
forceComplete() {
public forceComplete(): Promise<number> {
return new Promise((resolve) => {
this.setState({
forceComplete: true,
@ -235,8 +239,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}
onCompletionClicked = (selectionOffset: number): boolean => {
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
public onConfirmCompletion = (): void => {
this.onCompletionClicked(this.state.selectionOffset);
};
private onCompletionClicked = (selectionOffset: number): boolean => {
const count = this.countCompletions();
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
return false;
}
@ -246,10 +255,10 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
return true;
};
setSelection(selectionOffset: number) {
private setSelection(selectionOffset: number): void {
this.setState({ selectionOffset, hide: false });
if (this.props.onSelectionChange) {
this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
this.props.onSelectionChange(selectionOffset - 1);
}
}
@ -292,7 +301,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
{ completionResult.provider.renderCompletions(completions) }
</div>
@ -300,7 +309,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={this.containerRef}>
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
{ renderedCompletions }
</div>
) : null;

View file

@ -133,6 +133,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
showVisualBell: false,
};
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
@ -215,7 +216,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (isEmpty) {
this.formatBarRef.current.hide();
}
this.setState({ autoComplete: this.props.model.autoComplete });
this.setState({
autoComplete: this.props.model.autoComplete,
// if a change is happening then clear the showVisualBell
showVisualBell: diff ? false : this.state.showVisualBell,
});
this.historyManager.tryPush(this.props.model, selection, inputType, diff);
let isTyping = !this.props.model.isEmpty;
@ -435,7 +440,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const model = this.props.model;
let handled = false;
if (this.state.surroundWith && document.getSelection().type != "Caret") {
if (this.state.surroundWith && document.getSelection().type !== "Caret") {
// This surrounds the selected text with a character. This is
// intentionally left out of the keybinding manager as the keybinds
// here shouldn't be changeable
@ -456,6 +461,44 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
}
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
if (model.autoComplete?.hasCompletions()) {
const autoComplete = model.autoComplete;
switch (autocompleteAction) {
case AutocompleteAction.ForceComplete:
case AutocompleteAction.Complete:
autoComplete.confirmCompletion();
handled = true;
break;
case AutocompleteAction.PrevSelection:
autoComplete.selectPreviousSelection();
handled = true;
break;
case AutocompleteAction.NextSelection:
autoComplete.selectNextSelection();
handled = true;
break;
case AutocompleteAction.Cancel:
autoComplete.onEscape(event);
handled = true;
break;
default:
return; // don't preventDefault on anything else
}
} else if (autocompleteAction === AutocompleteAction.ForceComplete && !this.state.showVisualBell) {
// there is no current autocomplete window, try to open it
this.tabCompleteName();
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this.formatBarRef.current.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
return;
}
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case MessageComposerAction.FormatBold:
@ -507,42 +550,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
handled = true;
break;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
return;
}
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
if (model.autoComplete && model.autoComplete.hasCompletions()) {
const autoComplete = model.autoComplete;
switch (autocompleteAction) {
case AutocompleteAction.CompleteOrPrevSelection:
case AutocompleteAction.PrevSelection:
autoComplete.selectPreviousSelection();
handled = true;
break;
case AutocompleteAction.CompleteOrNextSelection:
case AutocompleteAction.NextSelection:
autoComplete.selectNextSelection();
handled = true;
break;
case AutocompleteAction.Cancel:
autoComplete.onEscape(event);
handled = true;
break;
default:
return; // don't preventDefault on anything else
}
} else if (autocompleteAction === AutocompleteAction.CompleteOrPrevSelection
|| autocompleteAction === AutocompleteAction.CompleteOrNextSelection) {
// there is no current autocomplete window, try to open it
this.tabCompleteName();
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this.formatBarRef.current.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
@ -577,6 +584,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ showVisualBell: true });
model.autoComplete.close();
}
} else {
this.setState({ showVisualBell: true });
}
} catch (err) {
console.error(err);
@ -592,9 +601,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.props.model.autoComplete.onComponentConfirm(completion);
};
private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => {
private onAutoCompleteSelectionChange = (completionIndex: number): void => {
this.modifiedFlag = true;
this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({ completionIndex });
};
@ -718,6 +726,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
};
const { completionIndex } = this.state;
const hasAutocomplete = Boolean(this.state.autoComplete);
let activeDescendant;
if (hasAutocomplete && completionIndex >= 0) {
activeDescendant = generateCompletionDomId(completionIndex);
}
return (<div className={wrapperClasses}>
{ autoComplete }
@ -736,10 +749,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
aria-label={this.props.label}
role="textbox"
aria-multiline="true"
aria-autocomplete="both"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={Boolean(this.state.autoComplete)}
aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined}
aria-expanded={hasAutocomplete}
aria-owns="mx_Autocomplete"
aria-activedescendant={activeDescendant}
dir="auto"
aria-disabled={this.props.disabled}
/>

View file

@ -43,11 +43,6 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
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 {
const html = mxEvent.getContent().formatted_body;
if (!html) {
@ -72,7 +67,7 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
if (isEmote) {
model = stripEmoteCommand(model);
}
const isReply = eventIsReply(editedEvent);
const isReply = !!editedEvent.replyEventId;
let plainPrefix = "";
let htmlPrefix = "";

View file

@ -21,6 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread } from 'matrix-js-sdk/src/models/thread';
import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler';
@ -55,6 +56,8 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import SettingsStore from "../../../settings/SettingsStore";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -240,6 +243,7 @@ interface IProps {
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations. Should be an empty object when the room
// first loads
// TODO: Proper typing for RR info
readReceiptMap?: any;
// A function which is used to check if the parent panel is being
@ -299,6 +303,9 @@ interface IProps {
// whether or not to display the sender
hideSender?: boolean;
// whether or not to display thread info
showThreadInfo?: boolean;
}
interface IState {
@ -315,6 +322,8 @@ interface IState {
reactions: Relations;
hover: boolean;
thread?: Thread;
}
@replaceableComponent("views.rooms.EventTile")
@ -351,6 +360,8 @@ export default class EventTile extends React.Component<IProps, IState> {
reactions: this.getReactions(),
hover: false,
thread: this.props.mxEvent?.getThread(),
};
// don't do RR animations until we are mounted
@ -451,8 +462,20 @@ export default class EventTile extends React.Component<IProps, IState> {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.once("Thread.ready", this.updateThread);
this.props.mxEvent.on("Thread.update", this.updateThread);
}
}
private updateThread = (thread) => {
this.setState({
thread,
});
this.forceUpdate();
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps) {
@ -463,7 +486,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
shouldComponentUpdate(nextProps, nextState) {
shouldComponentUpdate(nextProps, nextState, nextContext) {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@ -491,6 +514,43 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
private renderThreadInfo(): React.ReactNode {
if (!SettingsStore.getValue("feature_thread")) {
return null;
}
const thread = this.state.thread;
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (!thread || this.props.showThreadInfo === false) {
return null;
}
const avatars = Array.from(thread.participants).map((mxId: string) => {
const member = room.getMember(mxId);
return <MemberAvatar key={member.userId} member={member} width={14} height={14} />;
});
return (
<div
className="mx_ThreadInfo"
onClick={() => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
refireParams: {
event: this.props.mxEvent,
},
});
}}
>
<span className="mx_EventListSummary_avatars">
{ avatars }
</span>
{ thread.length - 1 } { thread.length === 2 ? 'reply' : 'replies' }
</div>
);
}
private onRoomReceipt = (ev, room) => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
@ -856,13 +916,19 @@ export default class EventTile extends React.Component<IProps, IState> {
render() {
const msgtype = this.props.mxEvent.getContent().msgtype;
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const eventType = this.props.mxEvent.getType() as EventType;
const {
tileHandler,
isBubbleMessage,
isInfoMessage,
isLeftAlignedBubbleMessage,
} = getEventDisplayInfo(this.props.mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
const { mxEvent } = this.props;
console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
console.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`);
return <div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
<div className="mx_EventTile_line">
{ _t('This event could not be displayed') }
@ -878,6 +944,7 @@ export default class EventTile extends React.Component<IProps, IState> {
const isEditing = !!this.props.editState;
const classes = classNames({
mx_EventTile_bubbleContainer: isBubbleMessage,
mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage,
mx_EventTile: true,
mx_EventTile_isEditing: isEditing,
mx_EventTile_info: isInfoMessage,
@ -886,7 +953,10 @@ export default class EventTile extends React.Component<IProps, IState> {
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.props.tileShape === TileShape.Notif ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_continuation: (
(this.props.tileShape ? '' : this.props.continuation) ||
eventType === EventType.CallInvite
),
mx_EventTile_last: this.props.last,
mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual,
@ -932,8 +1002,11 @@ export default class EventTile extends React.Component<IProps, IState> {
} else if (this.props.layout == Layout.IRC) {
avatarSize = 14;
needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) {
// no avatar or sender profile for continuation messages
} else if (
(this.props.continuation && this.props.tileShape !== TileShape.FileGrid) ||
eventType === EventType.CallInvite
) {
// no avatar or sender profile for continuation messages and call tiles
avatarSize = 0;
needsSenderProfile = false;
} else {
@ -1167,6 +1240,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo }
{ actionBar }
{ this.props.layout === Layout.IRC && (reactionsRow) }
{ this.renderThreadInfo() }
</div>
{ this.props.layout !== Layout.IRC && (reactionsRow) }
{ msgOption }

View file

@ -14,11 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
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({
'mx_JumpToBottomButton': true,
'mx_JumpToBottomButton_highlight': props.highlight,
@ -36,3 +43,5 @@ export default (props) => {
{ badge }
</div>);
};
export default JumpToBottomButton;

View file

@ -45,6 +45,8 @@ import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash';
import SpaceStore from "../../../stores/SpaceStore";
const getSearchQueryLSKey = (roomId: string) => `mx_MemberList_searchQuarry_${roomId}`;
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
@ -171,20 +173,27 @@ export default class MemberList extends React.Component<IProps, IState> {
}
private getMembersState(members: Array<RoomMember>): IState {
let searchQuery;
try {
searchQuery = window.localStorage.getItem(getSearchQueryLSKey(this.props.roomId));
} catch (error) {
console.warn("Failed to get last the MemberList search query", error);
}
// set the state after determining showPresence to make sure it's
// taken into account while rendering
return {
loading: false,
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join'),
filteredInvitedMembers: this.filterMembers(members, 'invite'),
filteredJoinedMembers: this.filterMembers(members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', searchQuery),
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
searchQuery: "",
searchQuery: searchQuery ?? "",
};
}
@ -414,6 +423,12 @@ export default class MemberList extends React.Component<IProps, IState> {
};
private onSearchQueryChanged = (searchQuery: string): void => {
try {
window.localStorage.setItem(getSearchQueryLSKey(this.props.roomId), searchQuery);
} catch (error) {
console.warn("Failed to set the last MemberList search query", error);
}
this.setState({
searchQuery,
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
@ -554,7 +569,9 @@ export default class MemberList extends React.Component<IProps, IState> {
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={_t('Filter room members')}
onSearch={this.onSearchQueryChanged} />
onSearch={this.onSearchQueryChanged}
initialValue={this.state.searchQuery}
/>
);
let previousPhase = RightPanelPhases.RoomSummary;

View file

@ -183,7 +183,10 @@ interface IProps {
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
replyInThread?: boolean;
showReplyPreview?: boolean;
e2eStatus?: E2EStatus;
compact?: boolean;
}
interface IState {
@ -201,6 +204,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
static defaultProps = {
replyInThread: false,
showReplyPreview: true,
compact: false,
};
constructor(props) {
super(props);
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
@ -362,7 +371,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.state.me && !this.props.compact ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
null,
@ -376,6 +385,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
room={this.props.room}
placeholder={this.renderPlaceholderText()}
permalinkCreator={this.props.permalinkCreator}
replyInThread={this.props.replyInThread}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
disabled={this.state.haveRecording}
@ -450,11 +460,19 @@ export default class MessageComposer extends React.Component<IProps, IState> {
/>;
}
const classes = classNames({
"mx_MessageComposer": true,
"mx_GroupLayout": true,
"mx_MessageComposer--compact": this.props.compact,
});
return (
<div className="mx_MessageComposer mx_GroupLayout">
<div className={classes}>
{ recordingTooltip }
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
{ this.props.showReplyPreview && (
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
) }
<div className="mx_MessageComposer_row">
{ controls }
</div>

View file

@ -36,6 +36,7 @@ import { showSpaceInvite } from "../../../utils/space";
import { privateShouldBeEncrypted } from "../../../createRoom";
import EventTileBubble from "../messages/EventTileBubble";
import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
@ -191,11 +192,21 @@ const NewRoomIntro = () => {
});
}
const sub2 = _t(
const subText = _t(
"Your private messages are normally encrypted, but this room isn't. "+
"Usually this is due to an unsupported device or method being used, " +
"like email invites. <a>Enable encryption in settings.</a>", {},
{ a: sub => <a onClick={openRoomSettings} href="#">{ sub }</a> },
"like email invites.",
);
let subButton;
if (room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, MatrixClientPeg.get())) {
subButton = (
<a onClick={openRoomSettings} href="#"> { _t("Enable encryption in settings.") }</a>
);
}
const subtitle = (
<span> { subText } { subButton } </span>
);
return <div className="mx_NewRoomIntro">
@ -204,7 +215,7 @@ const NewRoomIntro = () => {
<EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon_warning"
title={_t("End-to-end encryption isn't enabled")}
subtitle={sub2}
subtitle={subtitle}
/>
) }

View file

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

View file

@ -25,8 +25,9 @@ import MImageReplyBody from "../messages/MImageReplyBody";
import * as sdk from '../../../index';
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
import { replaceableComponent } from '../../../utils/replaceableComponent';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { getEventDisplayInfo, isVoiceMessage } from '../../../utils/EventUtils';
import MFileBody from "../messages/MFileBody";
import MVoiceMessageBody from "../messages/MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
@ -95,7 +96,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const msgType = mxEvent.getContent().msgtype;
const evType = mxEvent.getType() as EventType;
const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const { tileHandler, isInfoMessage } = getEventDisplayInfo(mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
@ -109,14 +110,14 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const EventTileType = sdk.getComponent(tileHandler);
const classes = classNames("mx_ReplyTile", {
mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(),
mx_ReplyTile_info: isInfoMessage && !mxEvent.isRedacted(),
mx_ReplyTile_audio: msgType === MsgType.Audio,
mx_ReplyTile_video: msgType === MsgType.Video,
});
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
permalink = this.props.permalinkCreator.forEvent(mxEvent.getId());
}
let sender;
@ -129,7 +130,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
if (needsSenderProfile) {
sender = <SenderProfile
mxEvent={this.props.mxEvent}
mxEvent={mxEvent}
enableFlair={false}
/>;
}
@ -137,7 +138,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const msgtypeOverrides = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: MFileBody,
[MsgType.Audio]: isVoiceMessage(mxEvent) ? MVoiceMessageBody : MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides = {
@ -151,14 +152,14 @@ export default class ReplyTile extends React.PureComponent<IProps> {
{ sender }
<EventTileType
ref="tile"
mxEvent={this.props.mxEvent}
mxEvent={mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
showUrlPreview={false}
overrideBodyTypes={msgtypeOverrides}
overrideEventTypes={evOverrides}
replacingEventId={this.props.mxEvent.replacingEventId()}
replacingEventId={mxEvent.replacingEventId()}
maxImageHeight={96} />
</a>
</div>

View file

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

View file

@ -195,7 +195,7 @@ export default class RoomHeader extends React.Component<IProps> {
videoCallButton =
<AccessibleTooltipButton
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)}
title={_t("Video call")} />;
}

View file

@ -38,7 +38,6 @@ import { StaticNotificationState } from "../../../stores/notifications/StaticNot
import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import SettingsStore from "../../../settings/SettingsStore";
import CustomRoomTagStore from "../../../stores/CustomRoomTagStore";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
@ -320,11 +319,6 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
private updateLists = () => {
const newLists = RoomListStore.instance.orderedLists;
if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log("new lists", newLists);
}
const previousListIds = Object.keys(this.state.sublists);
const newListIds = Object.keys(newLists).filter(t => {
if (!isCustomTag(t)) return true; // always include non-custom tags

View file

@ -28,6 +28,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason";
const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
Joining: "Joining",
@ -492,9 +494,13 @@ export default class RoomPreviewBar extends React.Component {
}
const myUserId = MatrixClientPeg.get().getUserId();
const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason;
if (reason) {
reasonElement = <InviteReason reason={reason} />;
const memberEventContent = this.props.room.currentState.getMember(myUserId).events.member.getContent();
if (memberEventContent.reason) {
reasonElement = <InviteReason
reason={memberEventContent.reason}
htmlReason={memberEventContent[MemberEventHtmlReasonField]}
/>;
}
primaryActionHandler = this.props.onJoinClick;

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");
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 PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
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")
export default class RoomUpgradeWarningBar extends React.PureComponent {
static propTypes = {
room: PropTypes.object.isRequired,
recommendation: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
export default class RoomUpgradeWarningBar extends React.PureComponent<IProps, IState> {
public componentDidMount(): void {
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
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();
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) {
return;
}
@ -60,14 +62,11 @@ export default class RoomUpgradeWarningBar extends React.PureComponent {
this.setState({ upgraded: tombstone && tombstone.getContent().replacement_room });
};
onUpgradeClick = () => {
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
private onUpgradeClick = (): void => {
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room: this.props.room });
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
public render(): JSX.Element {
let doUpgradeWarnings = (
<div>
<div className="mx_RoomUpgradeWarningBar_body">

View file

@ -57,15 +57,16 @@ import { ActionPayload } from "../../../dispatcher/payloads";
function addReplyToMessageContent(
content: IContent,
repliedToEvent: MatrixEvent,
replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator,
): void {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
const replyContent = ReplyThread.makeReplyMixIn(replyToEvent, replyInThread);
Object.assign(content, replyContent);
// Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(repliedToEvent, permalinkCreator);
const nestedReply = ReplyThread.getNestedReplyText(replyToEvent, permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
@ -77,8 +78,9 @@ function addReplyToMessageContent(
// exported for tests
export function createMessageContent(
model: EditorModel,
permalinkCreator: RoomPermalinkCreator,
replyToEvent: MatrixEvent,
replyInThread: boolean,
permalinkCreator: RoomPermalinkCreator,
): IContent {
const isEmote = containsEmote(model);
if (isEmote) {
@ -101,7 +103,7 @@ export function createMessageContent(
}
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, permalinkCreator);
addReplyToMessageContent(content, replyToEvent, replyInThread, permalinkCreator);
}
return content;
@ -129,6 +131,7 @@ interface IProps {
room: Room;
placeholder?: string;
permalinkCreator: RoomPermalinkCreator;
replyInThread?: boolean;
replyToEvent?: MatrixEvent;
disabled?: boolean;
onChange?(model: EditorModel): void;
@ -357,7 +360,12 @@ export default class SendMessageComposer extends React.Component<IProps> {
if (cmd.category === CommandCategories.messages) {
content = await this.runSlashCommand(cmd, args);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator);
addReplyToMessageContent(
content,
replyToEvent,
this.props.replyInThread,
this.props.permalinkCreator,
);
}
} else {
this.runSlashCommand(cmd, args);
@ -400,7 +408,12 @@ export default class SendMessageComposer extends React.Component<IProps> {
const startTime = CountlyAnalytics.getTimestamp();
const { roomId } = this.props.room;
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
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");
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 PropTypes from 'prop-types';
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
* and room directory.
*/
@replaceableComponent("views.rooms.SimpleRoomHeader")
export default class SimpleRoomHeader extends React.Component {
static propTypes = {
title: PropTypes.string,
// `src` to an image. Optional.
icon: PropTypes.string,
};
render() {
export default class SimpleRoomHeader extends React.PureComponent<IProps> {
public render(): JSX.Element {
let icon;
if (this.props.icon) {
icon = <img

View file

@ -15,22 +15,25 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { Room } from 'matrix-js-sdk/src/models/room';
import { _t, _td } from '../../../languageHandler';
import AppTile from '../elements/AppTile';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import WidgetUtils from '../../../utils/WidgetUtils';
import WidgetUtils, { IWidgetEvent } from '../../../utils/WidgetUtils';
import PersistedElement from "../elements/PersistedElement";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenu } from "../../structures/ContextMenu";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
import { WidgetType } from "../../../widgets/WidgetType";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Action } from "../../../dispatcher/actions";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
import ScalarAuthClient from '../../../ScalarAuthClient';
import GenericElementContextMenu from "../context_menus/GenericElementContextMenu";
// This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
// We sit in a context menu, so this should be given to the context menu.
@ -39,27 +42,35 @@ const STICKERPICKER_Z_INDEX = 3500;
// Key to store the widget's AppTile under in PersistedElement
const PERSISTED_ELEMENT_KEY = "stickerPicker";
interface IProps {
room: Room;
}
interface IState {
showStickers: boolean;
imError: string;
stickerpickerX: number;
stickerpickerY: number;
stickerpickerChevronOffset?: number;
stickerpickerWidget: IWidgetEvent;
widgetId: string;
}
@replaceableComponent("views.rooms.Stickerpicker")
export default class Stickerpicker extends React.PureComponent {
export default class Stickerpicker extends React.PureComponent<IProps, IState> {
static currentWidget;
constructor(props) {
private dispatcherRef: string;
private prevSentVisibility: boolean;
private popoverWidth = 300;
private popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
private scalarClient: ScalarAuthClient = null;
constructor(props: IProps) {
super(props);
this._onShowStickersClick = this._onShowStickersClick.bind(this);
this._onHideStickersClick = this._onHideStickersClick.bind(this);
this._launchManageIntegrations = this._launchManageIntegrations.bind(this);
this._removeStickerpickerWidgets = this._removeStickerpickerWidgets.bind(this);
this._updateWidget = this._updateWidget.bind(this);
this._onWidgetAction = this._onWidgetAction.bind(this);
this._onResize = this._onResize.bind(this);
this._onFinished = this._onFinished.bind(this);
this.popoverWidth = 300;
this.popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
this.scalarClient = null;
this.state = {
showStickers: false,
imError: null,
@ -70,7 +81,7 @@ export default class Stickerpicker extends React.PureComponent {
};
}
_acquireScalarClient() {
private acquireScalarClient(): Promise<void | ScalarAuthClient> {
if (this.scalarClient) return Promise.resolve(this.scalarClient);
// TODO: Pick the right manager for the widget
if (IntegrationManagers.sharedInstance().hasManager()) {
@ -79,15 +90,15 @@ export default class Stickerpicker extends React.PureComponent {
this.forceUpdate();
return this.scalarClient;
}).catch((e) => {
this._imError(_td("Failed to connect to integration manager"), e);
this.imError(_td("Failed to connect to integration manager"), e);
});
} else {
IntegrationManagers.sharedInstance().openNoManagerDialog();
}
}
async _removeStickerpickerWidgets() {
const scalarClient = await this._acquireScalarClient();
private removeStickerpickerWidgets = async (): Promise<void> => {
const scalarClient = await this.acquireScalarClient();
console.log('Removing Stickerpicker widgets');
if (this.state.widgetId) {
if (scalarClient) {
@ -109,36 +120,36 @@ export default class Stickerpicker extends React.PureComponent {
}).catch((e) => {
console.error('Failed to remove sticker picker widget', e);
});
}
};
componentDidMount() {
public componentDidMount(): void {
// Close the sticker picker when the window resizes
window.addEventListener('resize', this._onResize);
window.addEventListener('resize', this.onResize);
this.dispatcherRef = dis.register(this._onWidgetAction);
this.dispatcherRef = dis.register(this.onWidgetAction);
// Track updates to widget state in account data
MatrixClientPeg.get().on('accountData', this._updateWidget);
MatrixClientPeg.get().on('accountData', this.updateWidget);
// Initialise widget state from current account data
this._updateWidget();
this.updateWidget();
}
componentWillUnmount() {
public componentWillUnmount(): void {
const client = MatrixClientPeg.get();
if (client) client.removeListener('accountData', this._updateWidget);
if (client) client.removeListener('accountData', this.updateWidget);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('resize', this.onResize);
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
}
componentDidUpdate(prevProps, prevState) {
this._sendVisibilityToWidget(this.state.showStickers);
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
this.sendVisibilityToWidget(this.state.showStickers);
}
_imError(errorMsg, e) {
private imError(errorMsg: string, e: Error): void {
console.error(errorMsg, e);
this.setState({
showStickers: false,
@ -146,7 +157,7 @@ export default class Stickerpicker extends React.PureComponent {
});
}
_updateWidget() {
private updateWidget = (): void => {
const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets()[0];
if (!stickerpickerWidget) {
Stickerpicker.currentWidget = null;
@ -175,9 +186,9 @@ export default class Stickerpicker extends React.PureComponent {
stickerpickerWidget,
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
});
}
};
_onWidgetAction(payload) {
private onWidgetAction = (payload: ActionPayload): void => {
switch (payload.action) {
case "user_widget_updated":
this.forceUpdate();
@ -191,11 +202,11 @@ export default class Stickerpicker extends React.PureComponent {
this.setState({ showStickers: false });
break;
}
}
};
_defaultStickerpickerContent() {
private defaultStickerpickerContent(): JSX.Element {
return (
<AccessibleButton onClick={this._launchManageIntegrations}
<AccessibleButton onClick={this.launchManageIntegrations}
className='mx_Stickers_contentPlaceholder'>
<p>{ _t("You don't currently have any stickerpacks enabled") }</p>
<p className='mx_Stickers_addLink'>{ _t("Add some now") }</p>
@ -204,29 +215,29 @@ export default class Stickerpicker extends React.PureComponent {
);
}
_errorStickerpickerContent() {
private errorStickerpickerContent(): JSX.Element {
return (
<div style={{ "text-align": "center" }} className="error">
<div style={{ textAlign: "center" }} className="error">
<p> { this.state.imError } </p>
</div>
);
}
_sendVisibilityToWidget(visible) {
private sendVisibilityToWidget(visible: boolean): void {
if (!this.state.stickerpickerWidget) return;
const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id);
if (messaging && visible !== this._prevSentVisibility) {
if (messaging && visible !== this.prevSentVisibility) {
messaging.updateVisibility(visible).catch(err => {
console.error("Error updating widget visibility: ", err);
});
this._prevSentVisibility = visible;
this.prevSentVisibility = visible;
}
}
_getStickerpickerContent() {
public getStickerpickerContent(): JSX.Element {
// Handle integration manager errors
if (this.state._imError) {
return this._errorStickerpickerContent();
if (this.state.imError) {
return this.errorStickerpickerContent();
}
// Stickers
@ -239,12 +250,11 @@ export default class Stickerpicker extends React.PureComponent {
// Use a separate ReactDOM tree to render the AppTile separately so that it persists and does
// not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still
// updated.
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Load stickerpack content
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("Stickerpack");
// FIXME: could this use the same code as other apps?
const stickerApp = {
@ -275,12 +285,12 @@ export default class Stickerpicker extends React.PureComponent {
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
onEditClick={this.launchManageIntegrations}
onDeleteClick={this.removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
onMinimiseClick={this.onHideStickersClick}
handleMinimisePointerEvents={true}
userWidget={true}
/>
@ -290,7 +300,7 @@ export default class Stickerpicker extends React.PureComponent {
);
} else {
// Default content to show if stickerpicker widget not added
stickersContent = this._defaultStickerpickerContent();
stickersContent = this.defaultStickerpickerContent();
}
return stickersContent;
}
@ -300,7 +310,7 @@ export default class Stickerpicker extends React.PureComponent {
* Show the sticker picker overlay
* If no stickerpacks have been added, show a link to the integration manager add sticker packs page.
*/
_onShowStickersClick(e) {
private onShowStickersClick = (e: React.MouseEvent<HTMLElement>): void => {
if (!SettingsStore.getValue("integrationProvisioning")) {
// Intercept this case and spawn a warning.
return IntegrationManagers.sharedInstance().showDisabledDialog();
@ -308,7 +318,7 @@ export default class Stickerpicker extends React.PureComponent {
// XXX: Simplify by using a context menu that is positioned relative to the sticker picker button
const buttonRect = e.target.getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
let x = buttonRect.right + window.pageXOffset - 41;
@ -324,50 +334,50 @@ export default class Stickerpicker extends React.PureComponent {
// Offset the chevron location, which is relative to the left of the context menu
// (10 = offset when context menu would not be displayed off viewport)
// (2 = context menu borders)
const stickerPickerChevronOffset = Math.max(10, 2 + window.pageXOffset + buttonRect.left - x);
const stickerpickerChevronOffset = Math.max(10, 2 + window.pageXOffset + buttonRect.left - x);
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
this.setState({
showStickers: true,
stickerPickerX: x,
stickerPickerY: y,
stickerPickerChevronOffset,
stickerpickerX: x,
stickerpickerY: y,
stickerpickerChevronOffset,
});
}
};
/**
* Trigger hiding of the sticker picker overlay
* @param {Event} ev Event that triggered the function call
*/
_onHideStickersClick(ev) {
private onHideStickersClick = (ev: React.MouseEvent): void => {
if (this.state.showStickers) {
this.setState({ showStickers: false });
}
}
};
/**
* Called when the window is resized
*/
_onResize() {
private onResize = (): void => {
if (this.state.showStickers) {
this.setState({ showStickers: false });
}
}
};
/**
* The stickers picker was hidden
*/
_onFinished() {
private onFinished = (): void => {
if (this.state.showStickers) {
this.setState({ showStickers: false });
}
}
};
/**
* Launch the integration manager on the stickers integration page
*/
_launchManageIntegrations = () => {
private launchManageIntegrations = (): void => {
// TODO: Open the right integration manager for the widget
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(
@ -384,7 +394,7 @@ export default class Stickerpicker extends React.PureComponent {
}
};
render() {
public render(): JSX.Element {
let stickerPicker;
let stickersButton;
const className = classNames(
@ -400,26 +410,24 @@ export default class Stickerpicker extends React.PureComponent {
id='stickersButton'
key="controls_hide_stickers"
className={className}
onClick={this._onHideStickersClick}
active={this.state.showStickers.toString()}
onClick={this.onHideStickersClick}
title={_t("Hide Stickers")}
/>;
const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
stickerPicker = <ContextMenu
chevronOffset={this.state.stickerPickerChevronOffset}
chevronFace="bottom"
left={this.state.stickerPickerX}
top={this.state.stickerPickerY}
chevronOffset={this.state.stickerpickerChevronOffset}
chevronFace={ChevronFace.Bottom}
left={this.state.stickerpickerX}
top={this.state.stickerpickerY}
menuWidth={this.popoverWidth}
menuHeight={this.popoverHeight}
onFinished={this._onFinished}
onFinished={this.onFinished}
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
zIndex={STICKERPICKER_Z_INDEX}
>
<GenericElementContextMenu element={this._getStickerpickerContent()} onResize={this._onFinished} />
<GenericElementContextMenu element={this.getStickerpickerContent()} onResize={this.onFinished} />
</ContextMenu>;
} else {
// Show show-stickers button
@ -428,7 +436,7 @@ export default class Stickerpicker extends React.PureComponent {
id='stickersButton'
key="controls_show_stickers"
className="mx_MessageComposer_button mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
onClick={this.onShowStickersClick}
title={_t("Show Stickers")}
/>;
}

View file

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

View file

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

View file

@ -15,12 +15,19 @@ limitations under the License.
*/
import React, { useState } from "react";
import PropTypes from "prop-types";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
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 hoveringProps = {
onMouseEnter: () => setIsHovering(true),
@ -78,12 +85,4 @@ const AvatarSetting = ({ avatarUrl, avatarAltText, avatarName, uploadAvatar, rem
</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;

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");
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 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 * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Spinner from '../elements/Spinner';
import { replaceableComponent } from "../../../utils/replaceableComponent";
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")
export default class ChangeAvatar extends React.Component {
static propTypes = {
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 = {
export default class ChangeAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
showUploadSection: true,
className: "",
width: 80,
height: 80,
};
constructor(props) {
private avatarSet = false;
constructor(props: IProps) {
super(props);
this.state = {
avatarUrl: this.props.initialAvatarUrl,
phase: ChangeAvatar.Phases.Display,
phase: Phases.Display,
};
}
componentDidMount() {
public componentDidMount(): void {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
}
// 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) {
// don't clobber what the user has just set
return;
@ -72,13 +83,13 @@ export default class ChangeAvatar extends React.Component {
});
}
componentWillUnmount() {
public componentWillUnmount(): void {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
}
onRoomStateEvents = (ev) => {
private onRoomStateEvents = (ev: MatrixEvent) => {
if (!this.props.room) {
return;
}
@ -94,18 +105,17 @@ export default class ChangeAvatar extends React.Component {
}
};
setAvatarFromFile(file) {
private setAvatarFromFile(file: File): Promise<{}> {
let newUrl = null;
this.setState({
phase: ChangeAvatar.Phases.Uploading,
phase: Phases.Uploading,
});
const self = this;
const httpPromise = MatrixClientPeg.get().uploadContent(file).then(function(url) {
const httpPromise = MatrixClientPeg.get().uploadContent(file).then((url) => {
newUrl = url;
if (self.props.room) {
if (this.props.room) {
return MatrixClientPeg.get().sendStateEvent(
self.props.room.roomId,
this.props.room.roomId,
'm.room.avatar',
{ url: url },
'',
@ -115,38 +125,37 @@ export default class ChangeAvatar extends React.Component {
}
});
httpPromise.then(function() {
self.setState({
phase: ChangeAvatar.Phases.Display,
httpPromise.then(() => {
this.setState({
phase: Phases.Display,
avatarUrl: mediaFromMxc(newUrl).srcHttp,
});
}, function(error) {
self.setState({
phase: ChangeAvatar.Phases.Error,
}, () => {
this.setState({
phase: Phases.Error,
});
self.onError(error);
this.onError();
});
return httpPromise;
}
onFileSelected = (ev) => {
private onFileSelected = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.avatarSet = true;
return this.setAvatarFromFile(ev.target.files[0]);
};
onError = (error) => {
private onError = (): void => {
this.setState({
errorText: _t("Failed to upload profile picture!"),
});
};
render() {
public render(): JSX.Element {
let avatarImg;
// Having just set an avatar we just display that since it will take a little
// time to propagate through to the RoomAvatar.
if (this.props.room && !this.avatarSet) {
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
avatarImg = <RoomAvatar
room={this.props.room}
width={this.props.width}
@ -154,7 +163,6 @@ export default class ChangeAvatar extends React.Component {
resizeMethod='crop'
/>;
} 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 ?
avatarImg = <BaseAvatar
width={this.props.width}
@ -178,8 +186,8 @@ export default class ChangeAvatar extends React.Component {
}
switch (this.state.phase) {
case ChangeAvatar.Phases.Display:
case ChangeAvatar.Phases.Error:
case Phases.Display:
case Phases.Error:
return (
<div>
<div className={this.props.className}>
@ -188,7 +196,7 @@ export default class ChangeAvatar extends React.Component {
{ uploadSection }
</div>
);
case ChangeAvatar.Phases.Uploading:
case Phases.Uploading:
return (
<Spinner />
);

View file

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

View file

@ -24,36 +24,41 @@ import Spinner from '../elements/Spinner';
import InteractiveAuthDialog from '../dialogs/InteractiveAuthDialog';
import ConfirmDestroyCrossSigningDialog from '../dialogs/security/ConfirmDestroyCrossSigningDialog';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixEvent } from 'matrix-js-sdk/src';
import SetupEncryptionDialog from '../dialogs/security/SetupEncryptionDialog';
import { accessSecretStorage } from '../../../SecurityManager';
interface IState {
error?: Error;
crossSigningPublicKeysOnDevice?: boolean;
crossSigningPrivateKeysInStorage?: boolean;
masterPrivateKeyCached?: boolean;
selfSigningPrivateKeyCached?: boolean;
userSigningPrivateKeyCached?: boolean;
homeserverSupportsCrossSigning?: boolean;
crossSigningReady?: boolean;
}
@replaceableComponent("views.settings.CrossSigningPanel")
export default class CrossSigningPanel extends React.PureComponent {
export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
private unmounted = false;
constructor(props) {
super(props);
this._unmounted = false;
this.state = {
error: null,
crossSigningPublicKeysOnDevice: null,
crossSigningPrivateKeysInStorage: null,
masterPrivateKeyCached: null,
selfSigningPrivateKeyCached: null,
userSigningPrivateKeyCached: null,
homeserverSupportsCrossSigning: null,
crossSigningReady: null,
};
this.state = {};
}
componentDidMount() {
public componentDidMount() {
const cli = MatrixClientPeg.get();
cli.on("accountData", this.onAccountData);
cli.on("userTrustStatusChanged", this.onStatusChanged);
cli.on("crossSigning.keysChanged", this.onStatusChanged);
this._getUpdatedStatus();
this.getUpdatedStatus();
}
componentWillUnmount() {
this._unmounted = true;
public componentWillUnmount() {
this.unmounted = true;
const cli = MatrixClientPeg.get();
if (!cli) return;
cli.removeListener("accountData", this.onAccountData);
@ -61,28 +66,37 @@ export default class CrossSigningPanel extends React.PureComponent {
cli.removeListener("crossSigning.keysChanged", this.onStatusChanged);
}
onAccountData = (event) => {
private onAccountData = (event: MatrixEvent): void => {
const type = event.getType();
if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) {
this._getUpdatedStatus();
this.getUpdatedStatus();
}
};
_onBootstrapClick = () => {
this._bootstrapCrossSigning({ forceReset: false });
private onBootstrapClick = () => {
if (this.state.crossSigningPrivateKeysInStorage) {
Modal.createTrackedDialog(
"Verify session", "Verify session", SetupEncryptionDialog,
{}, null, /* priority = */ false, /* static = */ true,
);
} else {
// Trigger the flow to set up secure backup, which is what this will do when in
// the appropriate state.
accessSecretStorage();
}
};
onStatusChanged = () => {
this._getUpdatedStatus();
private onStatusChanged = () => {
this.getUpdatedStatus();
};
async _getUpdatedStatus() {
private async getUpdatedStatus(): Promise<void> {
const cli = MatrixClientPeg.get();
const pkCache = cli.getCrossSigningCacheCallbacks();
const crossSigning = cli.crypto.crossSigningInfo;
const secretStorage = cli.crypto.secretStorage;
const crossSigningPublicKeysOnDevice = crossSigning.getId();
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
const crossSigningPublicKeysOnDevice = Boolean(crossSigning.getId());
const crossSigningPrivateKeysInStorage = Boolean(await crossSigning.isStoredInSecretStorage(secretStorage));
const masterPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("master"));
const selfSigningPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("self_signing"));
const userSigningPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("user_signing"));
@ -110,8 +124,8 @@ export default class CrossSigningPanel extends React.PureComponent {
* 3. All keys are loaded and there's nothing to do.
* @param {bool} [forceReset] Bootstrap again even if keys already present
*/
_bootstrapCrossSigning = async ({ forceReset = false }) => {
this.setState({ error: null });
private bootstrapCrossSigning = async ({ forceReset = false }): Promise<void> => {
this.setState({ error: undefined });
try {
const cli = MatrixClientPeg.get();
await cli.bootstrapCrossSigning({
@ -135,20 +149,20 @@ export default class CrossSigningPanel extends React.PureComponent {
this.setState({ error: e });
console.error("Error bootstrapping cross-signing", e);
}
if (this._unmounted) return;
this._getUpdatedStatus();
}
if (this.unmounted) return;
this.getUpdatedStatus();
};
_resetCrossSigning = () => {
private resetCrossSigning = (): void => {
Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
onFinished: (act) => {
if (!act) return;
this._bootstrapCrossSigning({ forceReset: true });
this.bootstrapCrossSigning({ forceReset: true });
},
});
}
};
render() {
public render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const {
error,
@ -173,10 +187,14 @@ export default class CrossSigningPanel extends React.PureComponent {
summarisedStatus = <p>{ _t(
"Your homeserver does not support cross-signing.",
) }</p>;
} else if (crossSigningReady) {
} else if (crossSigningReady && crossSigningPrivateKeysInStorage) {
summarisedStatus = <p> { _t(
"Cross-signing is ready for use.",
) }</p>;
} else if (crossSigningReady && !crossSigningPrivateKeysInStorage) {
summarisedStatus = <p> { _t(
"Cross-signing is ready but keys are not backed up.",
) }</p>;
} else if (crossSigningPrivateKeysInStorage) {
summarisedStatus = <p>{ _t(
"Your account has a cross-signing identity in secret storage, " +
@ -207,16 +225,20 @@ export default class CrossSigningPanel extends React.PureComponent {
// TODO: determine how better to expose this to users in addition to prompts at login/toast
if (!keysExistEverywhere && homeserverSupportsCrossSigning) {
let buttonCaption = _t("Set up Secure Backup");
if (crossSigningPrivateKeysInStorage) {
buttonCaption = _t("Verify this session");
}
actions.push(
<AccessibleButton key="setup" kind="primary" onClick={this._onBootstrapClick}>
{ _t("Set up") }
<AccessibleButton key="setup" kind="primary" onClick={this.onBootstrapClick}>
{ buttonCaption }
</AccessibleButton>,
);
}
if (keysExistAnywhere) {
actions.push(
<AccessibleButton key="reset" kind="danger" onClick={this._resetCrossSigning}>
<AccessibleButton key="reset" kind="danger" onClick={this.resetCrossSigning}>
{ _t("Reset") }
</AccessibleButton>,
);

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016 - 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.
@ -16,52 +15,58 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { IMyDevice } from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
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")
export default class DevicesPanel extends React.Component {
constructor(props) {
export default class DevicesPanel extends React.Component<IProps, IState> {
private unmounted = false;
constructor(props: IProps) {
super(props);
this.state = {
devices: undefined,
deviceLoadError: undefined,
devices: [],
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() {
this._loadDevices();
public componentDidMount(): void {
this.loadDevices();
}
componentWillUnmount() {
this._unmounted = true;
public componentWillUnmount(): void {
this.unmounted = true;
}
_loadDevices() {
private loadDevices(): void {
MatrixClientPeg.get().getDevices().then(
(resp) => {
if (this._unmounted) { return; }
if (this.unmounted) { return; }
this.setState({ devices: resp.devices || [] });
},
(error) => {
if (this._unmounted) { return; }
if (this.unmounted) { return; }
let errtxt;
if (error.httpStatus == 404) {
// 404 probably means the HS doesn't yet support the API.
@ -79,7 +84,7 @@ export default class DevicesPanel extends React.Component {
* compare two devices, sorting from most-recently-seen to least-recently-seen
* (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.
const lastSeenDelta =
(b.last_seen_ts || 0) - (a.last_seen_ts || 0);
@ -91,8 +96,8 @@ export default class DevicesPanel extends React.Component {
return (idA < idB) ? -1 : (idA > idB) ? 1 : 0;
}
_onDeviceSelectionToggled(device) {
if (this._unmounted) { return; }
private onDeviceSelectionToggled = (device: IMyDevice): void => {
if (this.unmounted) { return; }
const deviceId = device.device_id;
this.setState((state, props) => {
@ -108,22 +113,21 @@ export default class DevicesPanel extends React.Component {
return { selectedDevices };
});
}
};
_onDeleteClick() {
private onDeleteClick = (): void => {
this.setState({
deleting: true,
});
this._makeDeleteRequest(null).catch((error) => {
if (this._unmounted) { return; }
this.makeDeleteRequest(null).catch((error) => {
if (this.unmounted) { return; }
if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// doesn't look like an interactive-auth failure
throw error;
}
// pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const numDevices = this.state.selectedDevices.length;
const dialogAesthetics = {
@ -148,7 +152,7 @@ export default class DevicesPanel extends React.Component {
title: _t("Authentication"),
matrixClient: MatrixClientPeg.get(),
authData: error.data,
makeRequest: this._makeDeleteRequest.bind(this),
makeRequest: this.makeDeleteRequest.bind(this),
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
@ -156,15 +160,16 @@ export default class DevicesPanel extends React.Component {
});
}).catch((e) => {
console.error("Error deleting sessions", e);
if (this._unmounted) { return; }
if (this.unmounted) { return; }
}).finally(() => {
this.setState({
deleting: false,
});
});
}
};
_makeDeleteRequest(auth) {
// TODO: proper typing for auth
private makeDeleteRequest(auth?: any): Promise<any> {
return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then(
() => {
// Remove the deleted devices from `devices`, reset selection to []
@ -178,20 +183,16 @@ export default class DevicesPanel extends React.Component {
);
}
_renderDevice(device) {
const DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry');
private renderDevice = (device: IMyDevice): JSX.Element => {
return <DevicesPanelEntry
key={device.device_id}
device={device}
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) {
const classes = classNames(this.props.className, "error");
return (
@ -204,15 +205,14 @@ export default class DevicesPanel extends React.Component {
const devices = this.state.devices;
if (devices === undefined) {
// still loading
const classes = this.props.className;
return <Spinner className={classes} />;
return <Spinner />;
}
devices.sort(this._deviceCompare);
devices.sort(this.deviceCompare);
const deleteButton = this.state.deleting ?
<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 }) }
</AccessibleButton>;
@ -227,12 +227,8 @@ export default class DevicesPanel extends React.Component {
{ this.state.selectedDevices.length > 0 ? deleteButton : null }
</div>
</div>
{ devices.map(this._renderDevice) }
{ devices.map(this.renderDevice) }
</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");
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 PropTypes from 'prop-types';
import { IMyDevice } from 'matrix-js-sdk/src/client';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { formatDate } from '../../../DateUtils';
import StyledCheckbox from '../elements/StyledCheckbox';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import EditableTextContainer from "../elements/EditableTextContainer";
interface IProps {
device?: IMyDevice;
onDeviceToggled?: (device: IMyDevice) => void;
selected?: boolean;
}
@replaceableComponent("views.settings.DevicesPanelEntry")
export default class DevicesPanelEntry extends React.Component {
constructor(props) {
super(props);
export default class DevicesPanelEntry extends React.Component<IProps> {
public static defaultProps = {
onDeviceToggled: () => {},
};
this._unmounted = false;
this.onDeviceToggled = this.onDeviceToggled.bind(this);
this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this);
}
componentWillUnmount() {
this._unmounted = true;
}
_onDisplayNameChanged(value) {
private onDisplayNameChanged = (value: string): Promise<{}> => {
const device = this.props.device;
return MatrixClientPeg.get().setDeviceDetails(device.device_id, {
display_name: value,
@ -46,15 +44,13 @@ export default class DevicesPanelEntry extends React.Component {
console.error("Error setting session display name", e);
throw new Error(_t("Failed to set display name"));
});
}
};
onDeviceToggled() {
private onDeviceToggled = (): void => {
this.props.onDeviceToggled(this.props.device);
}
render() {
const EditableTextContainer = sdk.getComponent('elements.EditableTextContainer');
};
public render(): JSX.Element {
const device = this.props.device;
let lastSeen = "";
@ -76,7 +72,7 @@ export default class DevicesPanelEntry extends React.Component {
</div>
<div className="mx_DevicesPanel_deviceName">
<EditableTextContainer initialValue={device.display_name}
onSubmit={this._onDisplayNameChanged}
onSubmit={this.onDisplayNameChanged}
placeholder={device.device_id}
/>
</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 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
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 PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ActionPayload } from '../../../dispatcher/payloads';
import Spinner from "../elements/Spinner";
interface IProps {
// false to display an error saying that we couldn't connect to the integration manager
connected: boolean;
// true to display a loading spinner
loading: boolean;
// The source URL to load
url?: string;
// callback when the manager is dismissed
onFinished: () => void;
}
interface IState {
errored: boolean;
}
@replaceableComponent("views.settings.IntegrationManager")
export default class IntegrationManager extends React.Component {
static propTypes = {
// false to display an error saying that we couldn't connect to the integration manager
connected: PropTypes.bool.isRequired,
export default class IntegrationManager extends React.Component<IProps, IState> {
private dispatcherRef: string;
// true to display a loading spinner
loading: PropTypes.bool.isRequired,
// The source URL to load
url: PropTypes.string,
// callback when the manager is dismissed
onFinished: PropTypes.func.isRequired,
};
static defaultProps = {
public static defaultProps = {
connected: true,
loading: false,
};
constructor(props) {
super(props);
public state = {
errored: false,
};
this.state = {
errored: false,
};
}
componentDidMount() {
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener("keydown", this.onKeyDown);
}
componentWillUnmount() {
public componentWillUnmount(): void {
dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown = (ev) => {
private onKeyDown = (ev: KeyboardEvent): void => {
if (ev.key === Key.ESCAPE) {
ev.stopPropagation();
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') {
this.props.onFinished();
}
};
onError = () => {
private onError = (): void => {
this.setState({ errored: true });
};
render() {
public render(): JSX.Element {
if (this.props.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return (
<div className='mx_IntegrationManager_loading'>
<h3>{ _t("Connecting to integration manager...") }</h3>

View file

@ -26,7 +26,7 @@ import { Layout } from "../../../settings/Layout";
import { SettingLevel } from "../../../settings/SettingLevel";
interface IProps {
userId: string;
userId?: string;
displayName: string;
avatarUrl: string;
messagePreviewText: string;

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

View file

@ -37,6 +37,8 @@ import RoomUpgradeWarningDialog from '../../../dialogs/RoomUpgradeWarningDialog'
import { upgradeRoom } from "../../../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../../../utils/arrays";
import SettingsFlag from '../../../elements/SettingsFlag';
import createRoom, { IOpts } from '../../../../../createRoom';
import CreateRoomDialog from '../../../dialogs/CreateRoomDialog';
interface IProps {
roomId: string;
@ -129,7 +131,40 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
if (refreshWhenTypes.includes(e.getType() as EventType)) this.forceUpdate();
};
private onEncryptionChange = () => {
private onEncryptionChange = async () => {
if (this.state.joinRule == "public") {
const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
title: _t('Are you sure you want to add encryption to this public room?'),
description: <div>
<p> { _t(
"<b>It's not recommended to add encryption to public rooms.</b>" +
"Anyone can find and join public rooms, so anyone can read messages in them. " +
"You'll get none of the benefits of encryption, and you won't be able to turn it " +
"off later. Encrypting messages in a public room will make receiving and sending " +
"messages slower.",
null,
{ "b": (sub) => <b>{ sub }</b> },
) } </p>
<p> { _t(
"To avoid these issues, create a <a>new encrypted room</a> for " +
"the conversation you plan to have.",
null,
{ "a": (sub) => <a
className="mx_linkButton"
onClick={() => {
dialog.close();
this.createNewRoom(false, true);
}}> { sub } </a> },
) } </p>
</div>,
});
const { finished } = dialog;
const [confirm] = await finished;
if (!confirm) return;
}
Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, {
title: _t('Enable encryption?'),
description: _t(
@ -194,6 +229,43 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
}
}
if (
this.state.encrypted &&
this.state.joinRule !== JoinRule.Public &&
joinRule === JoinRule.Public
) {
const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
title: _t("Are you sure you want to make this encrypted room public?"),
description: <div>
<p> { _t(
"<b>It's not recommended to make encrypted rooms public.</b> " +
"It will mean anyone can find and join the room, so anyone can read messages. " +
"You'll get none of the benefits of encryption. Encrypting messages in a public " +
"room will make receiving and sending messages slower.",
null,
{ "b": (sub) => <b>{ sub }</b> },
) } </p>
<p> { _t(
"To avoid these issues, create a <a>new public room</a> for the conversation " +
"you plan to have.",
null,
{
"a": (sub) => <a
className="mx_linkButton"
onClick={() => {
dialog.close();
this.createNewRoom(true, false);
}}> { sub } </a>,
},
) } </p>
</div>,
});
const { finished } = dialog;
const [confirm] = await finished;
if (!confirm) return;
}
if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return;
const content: IContent = {
@ -254,6 +326,20 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
});
};
private createNewRoom = async (defaultPublic: boolean, defaultEncrypted: boolean) => {
const modal = Modal.createTrackedDialog<[boolean, IOpts]>(
"Create Room",
"Create room after trying to make an E2EE room public",
CreateRoomDialog,
{ defaultPublic, defaultEncrypted },
);
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
await createRoom(opts);
}
return shouldCreate;
};
private onHistoryRadioToggle = (history: HistoryVisibility) => {
const beforeHistory = this.state.history;
if (beforeHistory === history) return;

View file

@ -67,7 +67,7 @@ interface IState extends IThemeState {
showAdvanced: boolean;
layout: Layout;
// User profile data for the message preview
userId: string;
userId?: string;
displayName: string;
avatarUrl: string;
}
@ -92,8 +92,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false,
layout: SettingsStore.getValue("layout"),
userId: "@erim:fink.fink",
displayName: "Erimayas Fink",
userId: null,
displayName: null,
avatarUrl: null,
};
}

View file

@ -268,7 +268,8 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
"If you've submitted a bug via GitHub, debug logs can help " +
"us track down the problem. Debug logs contain application " +
"usage data including your username, the IDs or aliases of " +
"the rooms or groups you have visited and the usernames of " +
"the rooms or groups you have visited, which UI elements you " +
"last interacted with, and the usernames of " +
"other users. They do not contain messages.",
) }
<div className='mx_HelpUserSettingsTab_debugButton'>

View file

@ -88,7 +88,6 @@ export default class LabsUserSettingsTab extends React.Component {
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
<SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} />
<SettingsFlag name="advancedRoomListLogging" level={SettingLevel.DEVICE} />
{ hiddenReadReceipts }
</div>;
}

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, useState } from "react";
import React, { ComponentProps, RefObject, SyntheticEvent, KeyboardEvent, useContext, useRef, useState } from "react";
import classNames from "classnames";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
@ -38,6 +38,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserSettingsDialog";
import { Key } from "../../../Keyboard";
export const createSpace = async (
name: string,
@ -157,6 +158,12 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
const cli = useContext(MatrixClientContext);
const domain = cli.getDomain();
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === Key.ENTER) {
onSubmit(ev);
}
};
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
<SpaceAvatar avatarUrl={avatarUrl} setAvatar={setAvatar} avatarDisabled={busy} />
@ -172,9 +179,11 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
}
setName(newName);
}}
onKeyDown={onKeyDown}
ref={nameFieldRef}
onValidate={spaceNameValidator}
disabled={busy}
autoComplete="off"
/>
{ showAliasField
@ -186,6 +195,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
label={_t("Address")}
disabled={busy}
onKeyDown={onKeyDown}
/>
: null
}

View file

@ -14,7 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
import React, {
ComponentProps,
Dispatch,
ReactNode,
SetStateAction,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
@ -43,6 +52,7 @@ import IconizedContextMenu, {
} from "../context_menus/IconizedContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import UIStore from "../../../stores/UIStore";
const useSpaces = (): [Room[], Room[], Room | null] => {
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
@ -214,6 +224,11 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
const SpacePanel = () => {
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
const ref = useRef<HTMLUListElement>();
useLayoutEffect(() => {
UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
}, []);
const onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true;
@ -288,6 +303,7 @@ const SpacePanel = () => {
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Spaces")}
ref={ref}
>
<Droppable droppableId="top-level-spaces">
{ (provided, snapshot) => (

View file

@ -72,7 +72,7 @@ export default class AudioFeed extends React.Component<IProps, IState> {
}
};
private playMedia() {
private async playMedia() {
const element = this.element.current;
if (!element) return;
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
@ -90,7 +90,7 @@ export default class AudioFeed extends React.Component<IProps, IState> {
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play();
await element.load();
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
}

View file

@ -32,7 +32,7 @@ export default class AudioFeedArrayForCall extends React.Component<IProps, IStat
super(props);
this.state = {
feeds: [],
feeds: this.props.call.getRemoteFeeds(),
};
}

View file

@ -111,7 +111,7 @@ export default class CallView extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
const { primary, secondary } = this.getOrderedFeeds(this.props.call.getFeeds());
const { primary, secondary } = CallView.getOrderedFeeds(this.props.call.getFeeds());
this.state = {
isLocalOnHold: this.props.call.isLocalOnHold(),
@ -147,7 +147,16 @@ export default class CallView extends React.Component<IProps, IState> {
dis.unregister(this.dispatcherRef);
}
public componentDidUpdate(prevProps) {
static getDerivedStateFromProps(props: IProps): Partial<IState> {
const { primary, secondary } = CallView.getOrderedFeeds(props.call.getFeeds());
return {
primaryFeed: primary,
secondaryFeeds: secondary,
};
}
public componentDidUpdate(prevProps: IProps): void {
if (this.props.call === prevProps.call) return;
this.setState({
@ -201,7 +210,7 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onFeedsChanged = (newFeeds: Array<CallFeed>) => {
const { primary, secondary } = this.getOrderedFeeds(newFeeds);
const { primary, secondary } = CallView.getOrderedFeeds(newFeeds);
this.setState({
primaryFeed: primary,
secondaryFeeds: secondary,
@ -226,7 +235,7 @@ export default class CallView extends React.Component<IProps, IState> {
this.buttonsRef.current?.showControls();
};
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
static getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
let primary;
// Try to use a screensharing as primary, a remote one if possible

View file

@ -21,7 +21,6 @@ import classNames from "classnames";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
import CallContextMenu from "../../context_menus/CallContextMenu";
import DialpadContextMenu from "../../context_menus/DialpadContextMenu";
import AccessibleButton from "../../elements/AccessibleButton";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { Alignment } from "../../elements/Tooltip";
import {
@ -211,10 +210,12 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
let sidebarButton;
if (this.props.buttonsVisibility.sidebar) {
sidebarButton = (
<AccessibleButton
<AccessibleTooltipButton
className={sidebarButtonClasses}
onClick={this.props.handlers.onToggleSidebarClick}
aria-label={this.props.buttonsState.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
title={this.props.buttonsState.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import * as React from "react";
import AccessibleButton from "../elements/AccessibleButton";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
@ -30,12 +30,12 @@ interface IButtonProps {
kind: DialPadButtonKind;
digit?: string;
digitSubtext?: string;
onButtonPress: (string) => void;
onButtonPress: (digit: string, ev: ButtonEvent) => void;
}
class DialPadButton extends React.PureComponent<IButtonProps> {
onClick = () => {
this.props.onButtonPress(this.props.digit);
onClick = (ev: ButtonEvent) => {
this.props.onButtonPress(this.props.digit, ev);
};
render() {
@ -54,10 +54,10 @@ class DialPadButton extends React.PureComponent<IButtonProps> {
}
interface IProps {
onDigitPress: (string) => void;
onDigitPress: (digit: string, ev: ButtonEvent) => void;
hasDial: boolean;
onDeletePress?: (string) => void;
onDialPress?: (string) => void;
onDeletePress?: (ev: ButtonEvent) => void;
onDialPress?: () => void;
}
@replaceableComponent("views.voip.DialPad")

View file

@ -15,7 +15,8 @@ limitations under the License.
*/
import * as React from "react";
import AccessibleButton from "../elements/AccessibleButton";
import { createRef } from "react";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import Field from "../elements/Field";
import DialPad from './DialPad';
import dis from '../../../dispatcher/dispatcher';
@ -34,6 +35,8 @@ interface IState {
@replaceableComponent("views.voip.DialPadModal")
export default class DialpadModal extends React.PureComponent<IProps, IState> {
private numberEntryFieldRef: React.RefObject<Field> = createRef();
constructor(props) {
super(props);
this.state = {
@ -54,13 +57,27 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
this.onDialPress();
};
onDigitPress = (digit) => {
onDigitPress = (digit: string, ev: ButtonEvent) => {
this.setState({ value: this.state.value + digit });
// Keep the number field focused so that keyboard entry is still available.
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
onDeletePress = () => {
onDeletePress = (ev: ButtonEvent) => {
if (this.state.value.length === 0) return;
this.setState({ value: this.state.value.slice(0, -1) });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
onDialPress = async () => {
@ -82,6 +99,7 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
let dialPadField;
if (this.state.value.length !== 0) {
dialPadField = <Field
ref={this.numberEntryFieldRef}
className="mx_DialPadModal_field"
id="dialpad_number"
value={this.state.value}
@ -91,6 +109,7 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
/>;
} else {
dialPadField = <Field
ref={this.numberEntryFieldRef}
className="mx_DialPadModal_field"
id="dialpad_number"
value={this.state.value}

View file

@ -45,6 +45,7 @@ interface IProps {
interface IState {
audioMuted: boolean;
videoMuted: boolean;
speaking: boolean;
}
@replaceableComponent("views.voip.VideoFeed")
@ -57,6 +58,7 @@ export default class VideoFeed extends React.PureComponent<IProps, IState> {
this.state = {
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
speaking: false,
};
}
@ -103,16 +105,24 @@ export default class VideoFeed extends React.PureComponent<IProps, IState> {
if (oldFeed) {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
if (this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
this.props.feed.removeListener(CallFeedEvent.Speaking, this.onSpeaking);
this.props.feed.measureVolumeActivity(false);
}
this.stopMedia();
}
if (newFeed) {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
if (this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
this.props.feed.addListener(CallFeedEvent.Speaking, this.onSpeaking);
this.props.feed.measureVolumeActivity(true);
}
this.playMedia();
}
}
private playMedia() {
private async playMedia() {
const element = this.element;
if (!element) return;
// We play audio in AudioFeed, not here
@ -129,7 +139,7 @@ export default class VideoFeed extends React.PureComponent<IProps, IState> {
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play();
await element.play();
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
}
@ -162,6 +172,10 @@ export default class VideoFeed extends React.PureComponent<IProps, IState> {
});
};
private onSpeaking = (speaking: boolean): void => {
this.setState({ speaking });
};
private onResize = (e) => {
if (this.props.onResize && !this.props.feed.isLocal()) {
this.props.onResize(e);
@ -173,6 +187,7 @@ export default class VideoFeed extends React.PureComponent<IProps, IState> {
const wrapperClasses = classnames("mx_VideoFeed", {
mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_speaking: this.state.speaking,
});
const micIconClasses = classnames("mx_VideoFeed_mic", {
mx_VideoFeed_mic_muted: this.state.audioMuted,