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

 Conflicts:
	src/components/views/avatars/RoomAvatar.tsx
	test/stores/SpaceStore-test.ts
	test/test-utils.js
This commit is contained in:
Michael Telatynski 2021-07-19 16:47:31 +01:00
commit de42a00ca4
104 changed files with 2492 additions and 2123 deletions

View file

@ -33,6 +33,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { normalizeWheelEvent } from "../../../utils/Mouse";
import { IDialogProps } from '../dialogs/IDialogProps';
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
@ -43,14 +44,13 @@ const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10;
interface IProps {
interface IProps extends IDialogProps {
src: string; // the source of the image being displayed
name?: string; // the main title ('name') for the image
link?: string; // the link (if any) applied to the name of the image
width?: number; // width of the image src in pixels
height?: number; // height of the image src in pixels
fileSize?: number; // size of the image src in bytes
onFinished(): void; // callback when the lightbox is dismissed
// the event (if any) that the Image is displaying. Used for event-specific stuff like
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
@ -488,8 +488,8 @@ export default class ImageView extends React.Component<IProps, IState> {
>
<img
src={this.props.src}
title={this.props.name}
style={style}
alt={this.props.name}
ref={this.image}
className="mx_ImageView_image"
draggable={true}

View file

@ -14,72 +14,72 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher';
import { wantsDateSeparator } from '../../../DateUtils';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import { LayoutPropType } from "../../../settings/Layout";
import { Layout } from "../../../settings/Layout";
import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html";
import { UIFeature } from "../../../settings/UIFeature";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile";
import Spinner from './Spinner';
import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { Room } from 'matrix-js-sdk/src/models/room';
interface IProps {
// the latest event in this chain of replies
parentEv?: MatrixEvent;
// called when the ReplyThread contents has changed, including EventTiles thereof
onHeightChanged: () => void;
permalinkCreator: RoomPermalinkCreator;
// Specifies which layout to use.
layout?: Layout;
// Whether to always show a timestamp
alwaysShowTimestamps?: boolean;
}
interface IState {
// The loaded events to be rendered as linear-replies
events: MatrixEvent[];
// The latest loaded event which has not yet been shown
loadedEv: MatrixEvent;
// Whether the component is still loading more events
loading: boolean;
// Whether as error was encountered fetching a replied to event.
err: boolean;
}
// This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
// be low as each event being loaded (after the first) is triggered by an explicit user action.
@replaceableComponent("views.elements.ReplyThread")
export default class ReplyThread extends React.Component {
static propTypes = {
// the latest event in this chain of replies
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onHeightChanged: PropTypes.func.isRequired,
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use.
layout: LayoutPropType,
// Whether to always show a timestamp
alwaysShowTimestamps: PropTypes.bool,
};
export default class ReplyThread extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private unmounted = false;
private room: Room;
constructor(props, context) {
super(props, context);
this.state = {
// The loaded events to be rendered as linear-replies
events: [],
// The latest loaded event which has not yet been shown
loadedEv: null,
// Whether the component is still loading more events
loading: true,
// Whether as error was encountered fetching a replied to event.
err: false,
};
this.unmounted = false;
this.context.on("Event.replaced", this.onEventReplaced);
this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.room.on("Room.redaction", this.onRoomRedaction);
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
this.onQuoteClick = this.onQuoteClick.bind(this);
this.canCollapse = this.canCollapse.bind(this);
this.collapse = this.collapse.bind(this);
}
static getParentEventId(ev) {
public static getParentEventId(ev: MatrixEvent): string {
if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now
@ -95,7 +95,7 @@ export default class ReplyThread extends React.Component {
}
// Part of Replies fallback support
static stripPlainReply(body) {
public static stripPlainReply(body: string): string {
// Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift();
@ -105,7 +105,7 @@ export default class ReplyThread extends React.Component {
}
// Part of Replies fallback support
static stripHTMLReply(html) {
public static stripHTMLReply(html: string): string {
// Sanitize the original HTML for inclusion in <mx-reply>. We allow
// any HTML, since the original sender could use special tags that we
// don't recognize, but want to pass along to any recipients who do
@ -127,7 +127,10 @@ export default class ReplyThread extends React.Component {
}
// Part of Replies fallback support
static getNestedReplyText(ev, permalinkCreator) {
public static getNestedReplyText(
ev: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): { body: string, html: string } {
if (!ev) return null;
let { body, formatted_body: html } = ev.getContent();
@ -203,7 +206,7 @@ export default class ReplyThread extends React.Component {
return { body, html };
}
static makeReplyMixIn(ev) {
public static makeReplyMixIn(ev: MatrixEvent) {
if (!ev) return {};
return {
'm.relates_to': {
@ -214,10 +217,15 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, alwaysShowTimestamps) {
if (!ReplyThread.getParentEventId(parentEv)) {
return null;
}
public static makeThread(
parentEv: MatrixEvent,
onHeightChanged: () => void,
permalinkCreator: RoomPermalinkCreator,
ref: React.RefObject<ReplyThread>,
layout: Layout,
alwaysShowTimestamps: boolean,
): JSX.Element {
if (!ReplyThread.getParentEventId(parentEv)) return null;
return <ReplyThread
parentEv={parentEv}
onHeightChanged={onHeightChanged}
@ -238,37 +246,9 @@ export default class ReplyThread extends React.Component {
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener("Event.replaced", this.onEventReplaced);
if (this.room) {
this.room.removeListener("Room.redaction", this.onRoomRedaction);
this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
}
}
updateForEventId = (eventId) => {
if (this.state.events.some(event => event.getId() === eventId)) {
this.forceUpdate();
}
};
onEventReplaced = (ev) => {
if (this.unmounted) return;
// If one of the events we are rendering gets replaced, force a re-render
this.updateForEventId(ev.getId());
};
onRoomRedaction = (ev) => {
if (this.unmounted) return;
const eventId = ev.getAssociatedId();
if (!eventId) return;
// If one of the events we are rendering gets redacted, force a re-render
this.updateForEventId(eventId);
};
async initialize() {
private async initialize(): Promise<void> {
const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
@ -287,7 +267,7 @@ export default class ReplyThread extends React.Component {
}
}
async getNextEvent(ev) {
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
try {
const inReplyToEventId = ReplyThread.getParentEventId(ev);
return await this.getEvent(inReplyToEventId);
@ -296,7 +276,7 @@ export default class ReplyThread extends React.Component {
}
}
async getEvent(eventId) {
private async getEvent(eventId: string): Promise<MatrixEvent> {
if (!eventId) return null;
const event = this.room.findEventById(eventId);
if (event) return event;
@ -313,15 +293,15 @@ export default class ReplyThread extends React.Component {
return this.room.findEventById(eventId);
}
canCollapse() {
public canCollapse = (): boolean => {
return this.state.events.length > 1;
}
};
collapse() {
public collapse = (): void => {
this.initialize();
}
};
async onQuoteClick() {
private onQuoteClick = async (): Promise<void> => {
const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
@ -335,6 +315,10 @@ export default class ReplyThread extends React.Component {
});
dis.fire(Action.FocusSendMessageComposer);
};
private getReplyThreadColorClass(ev: MatrixEvent): string {
return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread");
}
render() {
@ -349,9 +333,8 @@ export default class ReplyThread extends React.Component {
</blockquote>;
} else if (this.state.loadedEv) {
const ev = this.state.loadedEv;
const Pill = sdk.getComponent('elements.Pill');
const room = this.context.getRoom(ev.getRoomId());
header = <blockquote className="mx_ReplyThread">
header = <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`}>
{
_t('<a>In reply to</a> <pill>', {}, {
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
@ -367,33 +350,15 @@ export default class ReplyThread extends React.Component {
}
</blockquote>;
} else if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
header = <Spinner w={16} h={16} />;
}
const EventTile = sdk.getComponent('views.rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const evTiles = this.state.events.map((ev) => {
let dateSep = null;
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
{ dateSep }
<EventTile
return <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`} key={ev.getId()}>
<ReplyTile
mxEvent={ev}
tileShape={TileShape.Reply}
onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator}
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
as="div"
/>
</blockquote>;
});

View file

@ -1,39 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import { _t } from "../../../languageHandler";
const Spinner = ({ w = 32, h = 32, message }) => (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
<div
className="mx_Spinner_icon"
style={{ width: w, height: h }}
aria-label={_t("Loading...")}
></div>
</div>
);
Spinner.propTypes = {
w: PropTypes.number,
h: PropTypes.number,
message: PropTypes.node,
};
export default Spinner;

View file

@ -0,0 +1,45 @@
/*
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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from "../../../languageHandler";
interface IProps {
w?: number;
h?: number;
message?: string;
}
export default class Spinner extends React.PureComponent<IProps> {
public static defaultProps: Partial<IProps> = {
w: 32,
h: 32,
};
public render() {
const { w, h, message } = this.props;
return (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
<div
className="mx_Spinner_icon"
style={{ width: w, height: h }}
aria-label={_t("Loading...")}
/>
</div>
);
}
}

View file

@ -20,6 +20,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
outlined?: boolean;
// If true (default), the children will be contained within a <label> element
// If false, they'll be in a div. Putting interactive components that have labels
// themselves in labels can cause strange bugs like https://github.com/vector-im/element-web/issues/18031
childrenInLabel?: boolean;
}
interface IState {
@ -29,10 +33,11 @@ interface IState {
export default class StyledRadioButton extends React.PureComponent<IProps, IState> {
public static readonly defaultProps = {
className: '',
childrenInLabel: true,
};
public render() {
const { children, className, disabled, outlined, ...otherProps } = this.props;
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
const _className = classnames(
'mx_RadioButton',
className,
@ -42,12 +47,27 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
"mx_RadioButton_checked": this.props.checked,
"mx_RadioButton_outlined": outlined,
});
return <label className={_className}>
const radioButton = <React.Fragment>
<input type='radio' disabled={disabled} {...otherProps} />
{/* Used to render the radio button circle */}
<div><div /></div>
<div className="mx_RadioButton_content">{children}</div>
<div className="mx_RadioButton_spacer" />
</label>;
</React.Fragment>;
if (childrenInLabel) {
return <label className={_className}>
{radioButton}
<div className="mx_RadioButton_content">{children}</div>
<div className="mx_RadioButton_spacer" />
</label>;
} else {
return <div className={_className}>
<label className="mx_RadioButton_innerLabel">
{radioButton}
</label>
<div className="mx_RadioButton_content">{children}</div>
<div className="mx_RadioButton_spacer" />
</div>;
}
}
}

View file

@ -0,0 +1,91 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, FormEvent } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field from "./Field";
import { _t } from "../../../languageHandler";
import AccessibleButton from "./AccessibleButton";
interface IProps {
tags: string[];
onAdd: (tag: string) => void;
onRemove: (tag: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
}
interface IState {
newTag: string;
}
/**
* A simple, controlled, composer for entering string tags. Contains a simple
* input, add button, and per-tag remove button.
*/
@replaceableComponent("views.elements.TagComposer")
export default class TagComposer extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
newTag: "",
};
}
private onInputChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ newTag: ev.target.value });
};
private onAdd = (ev: FormEvent) => {
ev.preventDefault();
if (!this.state.newTag) return;
this.props.onAdd(this.state.newTag);
this.setState({ newTag: "" });
};
private onRemove(tag: string) {
// We probably don't need to proxy this, but for
// sanity of `this` we'll do so anyways.
this.props.onRemove(tag);
}
public render() {
return <div className='mx_TagComposer'>
<form className='mx_TagComposer_input' onSubmit={this.onAdd}>
<Field
value={this.state.newTag}
onChange={this.onInputChange}
label={this.props.label || _t("Keyword")}
placeholder={this.props.placeholder || _t("New keyword")}
disabled={this.props.disabled}
autoComplete="off"
/>
<AccessibleButton onClick={this.onAdd} kind='primary' disabled={this.props.disabled}>
{ _t("Add") }
</AccessibleButton>
</form>
<div className='mx_TagComposer_tags'>
{ this.props.tags.map((t, i) => (<div className='mx_TagComposer_tag' key={i}>
<span>{ t }</span>
<AccessibleButton onClick={this.onRemove.bind(this, t)} disabled={this.props.disabled} />
</div>)) }
</div>
</div>;
}
}