Merge branch 'develop' into improve-image-view

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-03-14 08:30:28 +01:00
commit b27a8e3c3a
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
489 changed files with 13551 additions and 3187 deletions

View file

@ -35,7 +35,9 @@ import PercentageDistributor from "../../../resizer/distributors/percentage";
import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayoutStore";
import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers";
import {useStateCallback} from "../../../hooks/useStateCallback";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.AppsDrawer")
export default class AppsDrawer extends React.Component {
static propTypes = {
userId: PropTypes.string.isRequired,
@ -53,6 +55,8 @@ export default class AppsDrawer extends React.Component {
this.state = {
apps: this._getApps(),
resizingVertical: false, // true when changing the height of the apps drawer
resizingHorizontal: false, // true when chagning the distribution of the width between widgets
};
this._resizeContainer = null;
@ -85,13 +89,16 @@ export default class AppsDrawer extends React.Component {
}
onIsResizing = (resizing) => {
this.setState({ resizing });
// This one is the vertical, ie. change height of apps drawer
this.setState({ resizingVertical: resizing });
if (!resizing) {
this._relaxResizer();
}
};
_createResizer() {
// This is the horizontal one, changing the distribution of the width between the app tiles
// (ie. a vertical resize handle because, the handle itself is vertical...)
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
@ -100,6 +107,7 @@ export default class AppsDrawer extends React.Component {
const collapseConfig = {
onResizeStart: () => {
this._resizeContainer.classList.add("mx_AppsDrawer_resizing");
this.setState({ resizingHorizontal: true });
},
onResizeStop: () => {
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
@ -107,6 +115,7 @@ export default class AppsDrawer extends React.Component {
this.props.room, Container.Top,
this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size),
);
this.setState({ resizingHorizontal: false });
},
};
// pass a truthy container for now, we won't call attach until we update it
@ -162,6 +171,10 @@ export default class AppsDrawer extends React.Component {
}
};
isResizing() {
return this.state.resizingVertical || this.state.resizingHorizontal;
}
onAction = (action) => {
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
@ -209,6 +222,7 @@ export default class AppsDrawer extends React.Component {
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
pointerEvents={this.isResizing() ? 'none' : undefined}
/>);
});

View file

@ -23,6 +23,7 @@ import {Room} from 'matrix-js-sdk/src/models/room';
import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter';
import {replaceableComponent} from "../../../utils/replaceableComponent";
const COMPOSER_SELECTED = 0;
@ -49,6 +50,7 @@ interface IState {
forceComplete: boolean;
}
@replaceableComponent("views.rooms.Autocomplete")
export default class Autocomplete extends React.PureComponent<IProps, IState> {
autocompleter: Autocompleter;
queryRequested: string;

View file

@ -17,11 +17,8 @@ limitations under the License.
import React from 'react';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import { Room } from 'matrix-js-sdk/src/models/room'
import * as sdk from '../../../index';
import dis from "../../../dispatcher/dispatcher";
import * as ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
@ -29,6 +26,8 @@ import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {UIFeature} from "../../../settings/UIFeature";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom';
import {objectHasDiff} from "../../../utils/objects";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
// js-sdk room object
@ -36,9 +35,6 @@ interface IProps {
userId: string,
showApps: boolean, // Render apps
// set to true to show the file drop target
draggingFile: boolean,
// maxHeight attribute for the aux panel and the video
// therein
maxHeight: number,
@ -63,6 +59,7 @@ interface IState {
counters: Counter[],
}
@replaceableComponent("views.rooms.AuxPanel")
export default class AuxPanel extends React.Component<IProps, IState> {
static defaultProps = {
showApps: true,
@ -89,8 +86,7 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
shouldComponentUpdate(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
return objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState);
}
componentDidUpdate(prevProps, prevState) {
@ -150,21 +146,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let fileDropTarget = null;
if (this.props.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel" title={_t("Drop File Here")}>
<TintableSvg src={require("../../../../res/img/upload-big.svg")} width="45" height="59" />
<br />
{ _t("Drop file here to upload") }
</div>
</div>
);
}
const callView = (
<CallViewForRoom
roomId={this.props.room.roomId}
@ -247,7 +228,6 @@ export default class AuxPanel extends React.Component<IProps, IState> {
<AutoHideScrollbar className={classes} style={style} >
{ stateViews }
{ appsDrawer }
{ fileDropTarget }
{ callView }
{ this.props.children }
</AutoHideScrollbar>

View file

@ -46,6 +46,7 @@ import {IDiff} from "../../../editor/diff";
import AutocompleteWrapperModel from "../../../editor/autocomplete";
import DocumentPosition from "../../../editor/position";
import {ICompletion} from "../../../autocomplete/Autocompleter";
import {replaceableComponent} from "../../../utils/replaceableComponent";
// matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
@ -105,6 +106,7 @@ interface IState {
completionIndex?: number;
}
@replaceableComponent("views.rooms.BasicMessageEditor")
export default class BasicMessageEditor extends React.Component<IProps, IState> {
private editorRef = createRef<HTMLDivElement>();
private autocompleteRef = createRef<Autocomplete>();

View file

@ -34,6 +34,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -102,6 +103,7 @@ function createEditContent(model, editedEvent) {
}, contentBody);
}
@replaceableComponent("views.rooms.EditMessageComposer")
export default class EditMessageComposer extends React.Component {
static propTypes = {
// the message event being edited

View file

@ -23,6 +23,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import { _t } from '../../../languageHandler';
import classNames from "classnames";
import E2EIcon from './E2EIcon';
import {replaceableComponent} from "../../../utils/replaceableComponent";
const PRESENCE_CLASS = {
"offline": "mx_EntityTile_offline",
@ -50,6 +51,7 @@ function presenceClassForMember(presenceState, lastActiveAgo, showPresence) {
}
}
@replaceableComponent("views.rooms.EntityTile")
class EntityTile extends React.Component {
static propTypes = {
name: PropTypes.string,

View file

@ -22,7 +22,7 @@ import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import classNames from "classnames";
import {EventType} from "matrix-js-sdk/src/@types/event";
import { _t, _td } from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
@ -32,13 +32,15 @@ import {EventStatus} from 'matrix-js-sdk';
import {formatTime} from "../../../DateUtils";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units";
import {WidgetType} from "../../../widgets/WidgetType";
import RoomAvatar from "../avatars/RoomAvatar";
import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStore";
import {objectHasDiff} from "../../../utils/objects";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
@ -146,6 +148,7 @@ const MAX_READ_AVATARS = 5;
// | '--------------------------------------' |
// '----------------------------------------------------------'
@replaceableComponent("views.rooms.EventTile")
export default class EventTile extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
@ -171,6 +174,9 @@ export default class EventTile extends React.Component {
// targeting)
lastInSection: PropTypes.bool,
// True if the event is the last successful (sent) event.
isLastSuccessful: PropTypes.bool,
/* true if this is search context (which has the effect of greying out
* the text
*/
@ -264,6 +270,81 @@ export default class EventTile extends React.Component {
this._tile = createRef();
this._replyThread = createRef();
// Throughout the component we manage a read receipt listener to see if our tile still
// qualifies for a "sent" or "sending" state (based on their relevant conditions). We
// don't want to over-subscribe to the read receipt events being fired, so we use a flag
// to determine if we've already subscribed and use a combination of other flags to find
// out if we should even be subscribed at all.
this._isListeningForReceipts = false;
}
/**
* When true, the tile qualifies for some sort of special read receipt. This could be a 'sending'
* or 'sent' receipt, for example.
* @returns {boolean}
* @private
*/
get _isEligibleForSpecialReceipt() {
// First, if there are other read receipts then just short-circuit this.
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
if (!this.props.mxEvent) return false;
// Sanity check (should never happen, but we shouldn't explode if it does)
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
if (!room) return false;
// Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for
// special read receipts.
const myUserId = MatrixClientPeg.get().getUserId();
if (this.props.mxEvent.getSender() !== myUserId) return false;
// Finally, determine if the type is relevant to the user. This notably excludes state
// events and pretty much anything that can't be sent by the composer as a message. For
// those we rely on local echo giving the impression of things changing, and expect them
// to be quick.
const simpleSendableEvents = [
EventType.Sticker,
EventType.RoomMessage,
EventType.RoomMessageEncrypted,
];
if (!simpleSendableEvents.includes(this.props.mxEvent.getType())) return false;
// Default case
return true;
}
get _shouldShowSentReceipt() {
// If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false;
// We only show the 'sent' receipt on the last successful event.
if (!this.props.lastSuccessful) return false;
// Check to make sure the sending state is appropriate. A null/undefined send status means
// that the message is 'sent', so we're just double checking that it's explicitly not sent.
if (this.props.eventSendStatus && this.props.eventSendStatus !== 'sent') return false;
// If anyone has read the event besides us, we don't want to show a sent receipt.
const receipts = this.props.readReceipts || [];
const myUserId = MatrixClientPeg.get().getUserId();
if (receipts.some(r => r.userId !== myUserId)) return false;
// Finally, we should show a receipt.
return true;
}
get _shouldShowSendingReceipt() {
// If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false;
// Check the event send status to see if we are pending. Null/undefined status means the
// message was sent, so check for that and 'sent' explicitly.
if (!this.props.eventSendStatus || this.props.eventSendStatus === 'sent') return false;
// Default to showing - there's no other event properties/behaviours we care about at
// this point.
return true;
}
// TODO: [REACT-WARNING] Move into constructor
@ -281,6 +362,11 @@ export default class EventTile extends React.Component {
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
}
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
client.on("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = true;
}
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -294,7 +380,7 @@ export default class EventTile extends React.Component {
}
shouldComponentUpdate(nextProps, nextState) {
if (!ObjectUtils.shallowEqual(this.state, nextState)) {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@ -305,12 +391,42 @@ export default class EventTile extends React.Component {
const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
client.removeListener("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = false;
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
}
}
componentDidUpdate(prevProps, prevState, snapshot) {
// If we're not listening for receipts and expect to be, register a listener.
if (!this._isListeningForReceipts && (this._shouldShowSentReceipt || this._shouldShowSendingReceipt)) {
this.context.on("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = true;
}
}
_onRoomReceipt = (ev, room) => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (room !== tileRoom) return;
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt && !this._isListeningForReceipts) {
return;
}
// We force update because we have no state or prop changes to queue up, instead relying on
// the getters we use here to determine what needs rendering.
this.forceUpdate(() => {
// Per elsewhere in this file, we can remove the listener once we will have no further purpose for it.
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt) {
this.context.removeListener("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = false;
}
});
};
/** called when the event is decrypted after we show it.
*/
_onDecrypted = () => {
@ -454,6 +570,10 @@ export default class EventTile extends React.Component {
};
getReadAvatars() {
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
}
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
return (<span className="mx_EventTile_readAvatars" />);
@ -692,7 +812,7 @@ export default class EventTile extends React.Component {
mx_EventTile_isEditing: isEditing,
mx_EventTile_info: isInfoMessage,
mx_EventTile_12hr: this.props.isTwelveHour,
mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting',
// Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
@ -768,15 +888,10 @@ export default class EventTile extends React.Component {
}
if (needsSenderProfile) {
let text = null;
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
if (msgtype === 'm.image') text = _td('%(senderName)s sent an image');
else if (msgtype === 'm.video') text = _td('%(senderName)s sent a video');
else if (msgtype === 'm.file') text = _td('%(senderName)s uploaded a file');
sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair && !text}
text={text} />;
enableFlair={this.props.enableFlair} />;
} else {
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={this.props.enableFlair} />;
}
@ -952,9 +1067,6 @@ export default class EventTile extends React.Component {
return (
<div className={classes} tabIndex={-1} aria-live={ariaLive} aria-atomic="true">
{ ircTimestamp }
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ sender }
{ ircPadlock }
<div className="mx_EventTile_line">
@ -974,6 +1086,9 @@ export default class EventTile extends React.Component {
{ reactionsRow }
{ actionBar }
</div>
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids
@ -1066,7 +1181,6 @@ class E2ePadlock extends React.Component {
render() {
let tooltip = null;
if (this.state.hover) {
const Tooltip = sdk.getComponent("elements.Tooltip");
tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} dir="auto" />;
}
@ -1081,3 +1195,56 @@ class E2ePadlock extends React.Component {
);
}
}
interface ISentReceiptProps {
messageState: string; // TODO: Types for message sending state
}
interface ISentReceiptState {
hover: boolean;
}
class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptState> {
constructor() {
super();
this.state = {
hover: false,
};
}
onHoverStart = () => {
this.setState({hover: true});
};
onHoverEnd = () => {
this.setState({hover: false});
};
render() {
const isSent = !this.props.messageState || this.props.messageState === 'sent';
const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent,
});
let tooltip = null;
if (this.state.hover) {
let label = _t("Sending your message...");
if (this.props.messageState === 'encrypting') {
label = _t("Encrypting your message...");
} else if (isSent) {
label = _t("Your message was sent");
}
// The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated
// with the read receipt.
tooltip = <Tooltip className="mx_EventTile_readAvatars_receiptTooltip" label={label} yOffset={20} />;
}
return <span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{tooltip}
</span>
</span>;
}
}

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.
@ -28,7 +28,7 @@ interface IProps {
isSelected: boolean;
displayName: string;
avatar: React.ReactElement;
notificationState: NotificationState;
notificationState?: NotificationState;
onClick: () => void;
}
@ -36,8 +36,7 @@ interface IState {
hover: boolean;
}
// TODO: Remove with community invites in the room list: https://github.com/vector-im/element-web/issues/14456
export default class TemporaryTile extends React.Component<IProps, IState> {
export default class ExtraTile extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
@ -57,18 +56,21 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
public render(): React.ReactElement {
// XXX: We copy classes because it's easier
const classes = classNames({
'mx_ExtraTile': true,
'mx_RoomTile': true,
'mx_TemporaryTile': true,
'mx_RoomTile_selected': this.props.isSelected,
'mx_RoomTile_minimized': this.props.isMinimized,
});
const badge = (
<NotificationBadge
notification={this.props.notificationState}
forceCount={false}
/>
);
let badge;
if (this.props.notificationState) {
badge = (
<NotificationBadge
notification={this.props.notificationState}
forceCount={false}
/>
);
}
let name = this.props.displayName;
if (typeof name !== 'string') name = '';
@ -76,7 +78,7 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
const nameClasses = classNames({
"mx_RoomTile_name": true,
"mx_RoomTile_nameHasUnreadEvents": this.props.notificationState.isUnread,
"mx_RoomTile_nameHasUnreadEvents": this.props.notificationState?.isUnread,
});
let nameContainer = (

View file

@ -19,8 +19,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {Key} from '../../../Keyboard';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.ForwardMessage")
export default class ForwardMessage extends React.Component {
static propTypes = {
onCancelClick: PropTypes.func.isRequired,

View file

@ -25,7 +25,10 @@ import * as sdk from "../../../index";
import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils";
import { _t } from "../../../languageHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component {
static propTypes = {
link: PropTypes.string.isRequired, // the URL being previewed
@ -81,7 +84,7 @@ export default class LinkPreviewWidget extends React.Component {
let src = p["og:image"];
if (src && src.startsWith("mxc://")) {
src = MatrixClientPeg.get().mxcUrlToHttp(src);
src = mediaFromMxc(src).srcHttp;
}
const params = {
@ -107,9 +110,11 @@ export default class LinkPreviewWidget extends React.Component {
if (!SettingsStore.getValue("showImages")) {
image = null; // Don't render a button to show the image, just hide it outright
}
const imageMaxWidth = 320; const imageMaxHeight = 240;
const imageMaxWidth = 100;
const imageMaxHeight = 100;
if (image && image.startsWith("mxc://")) {
image = MatrixClientPeg.get().mxcUrlToHttp(image, imageMaxWidth, imageMaxHeight);
// We deliberately don't want a square here, so use the source HTTP thumbnail function
image = mediaFromMxc(image).getThumbnailOfSourceHttp(imageMaxWidth, imageMaxHeight, 'scale');
}
let thumbHeight = imageMaxHeight;
@ -134,10 +139,6 @@ export default class LinkPreviewWidget extends React.Component {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_LinkPreviewWidget">
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}>
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
{ img }
<div className="mx_LinkPreviewWidget_caption">
<div className="mx_LinkPreviewWidget_title"><a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a></div>
@ -146,6 +147,10 @@ export default class LinkPreviewWidget extends React.Component {
{ description }
</div>
</div>
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}>
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
</div>
);
}

View file

@ -27,6 +27,9 @@ import * as sdk from "../../../index";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -36,6 +39,7 @@ const SHOW_MORE_INCREMENT = 100;
// matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
@replaceableComponent("views.rooms.MemberList")
export default class MemberList extends React.Component {
constructor(props) {
super(props);
@ -450,22 +454,14 @@ export default class MemberList extends React.Component {
let inviteButton;
if (room && room.getMyMembership() === 'join') {
// assume we can invite until proven false
let canInvite = true;
const plEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const me = room.getMember(cli.getUserId());
if (plEvent && me) {
const content = plEvent.getContent();
if (content && content.invite > me.powerLevel) {
canInvite = false;
}
}
const canInvite = room.canInvite(cli.getUserId());
let inviteButtonText = _t("Invite to this room");
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
@ -493,12 +489,26 @@ export default class MemberList extends React.Component {
onSearch={ this.onSearchQueryChanged } />
);
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>;
}
return <BaseCard
className="mx_MemberList"
header={inviteButton}
header={<React.Fragment>
{ scopeHeader }
{ inviteButton }
</React.Fragment>}
footer={footer}
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
previousPhase={previousPhase}
>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}

View file

@ -23,7 +23,9 @@ import dis from "../../../dispatcher/dispatcher";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import {Action} from "../../../dispatcher/actions";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.MemberTile")
export default class MemberTile extends React.Component {
static propTypes = {
member: PropTypes.any.isRequired, // RoomMember

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-2018, 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.
@ -19,7 +17,6 @@ import React, {createRef} from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
@ -33,11 +30,9 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
import {UIFeature} from "../../../settings/UIFeature";
import WidgetStore from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import { PlaceCallType } from "../../../CallHandler";
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent";
function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -50,95 +45,18 @@ ComposerAvatar.propTypes = {
me: PropTypes.object.isRequired,
};
function CallButton(props) {
const onVoiceCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: PlaceCallType.Voice,
room_id: props.roomId,
});
};
return (<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voicecall"
onClick={onVoiceCallClick}
title={_t('Voice call')}
/>);
}
CallButton.propTypes = {
roomId: PropTypes.string.isRequired,
};
function VideoCallButton(props) {
const onCallClick = (ev) => {
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video,
room_id: props.roomId,
});
};
return <AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_videocall"
onClick={onCallClick}
title={_t('Video call')}
/>;
}
VideoCallButton.propTypes = {
roomId: PropTypes.string.isRequired,
};
function HangupButton(props) {
const onHangupClick = () => {
if (props.isConference) {
dis.dispatch({
action: props.canEndConference ? 'end_conference' : 'hangup_conference',
room_id: props.roomId,
});
return;
}
const call = CallHandler.sharedInstance().getCallForRoom(props.roomId);
if (!call) {
return;
}
const action = call.state === CallState.Ringing ? 'reject' : 'hangup';
dis.dispatch({
action,
// hangup the call for this room. NB. We use the room in props as the room ID
// as call.roomId may be the 'virtual room', and the dispatch actions always
// use the user-facing room (there was a time when we deliberately used
// call.roomId and *not* props.roomId, but that was for the old
// style Freeswitch conference calls and those times are gone.)
room_id: props.roomId,
});
};
let tooltip = _t("Hangup");
if (props.isConference && props.canEndConference) {
tooltip = _t("End conference");
}
const canLeaveConference = !props.isConference ? true : props.isInConference;
function SendButton(props) {
return (
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_hangup"
onClick={onHangupClick}
title={tooltip}
disabled={!canLeaveConference}
className="mx_MessageComposer_sendMessage"
onClick={props.onClick}
title={_t('Send message')}
/>
);
}
HangupButton.propTypes = {
roomId: PropTypes.string.isRequired,
isConference: PropTypes.bool.isRequired,
canEndConference: PropTypes.bool,
isInConference: PropTypes.bool,
SendButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
const EmojiButton = ({addEmoji}) => {
@ -251,6 +169,7 @@ class UploadButton extends React.Component {
}
}
@replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component {
constructor(props) {
super(props);
@ -265,9 +184,9 @@ export default class MessageComposer extends React.Component {
this.state = {
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room),
joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
isComposerEmpty: true,
};
}
@ -396,6 +315,16 @@ export default class MessageComposer extends React.Component {
});
}
sendMessage = () => {
this.messageComposerInput._sendMessage();
}
onChange = (model) => {
this.setState({
isComposerEmpty: model.isEmpty,
});
}
render() {
const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
@ -405,12 +334,7 @@ export default class MessageComposer extends React.Component {
];
if (!this.state.tombstone && this.state.canSendMessages) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
// complex because of conference calls.
const SendMessageComposer = sdk.getComponent("rooms.SendMessageComposer");
const callInProgress = this.props.callState && this.props.callState !== 'ended';
controls.push(
<SendMessageComposer
@ -421,6 +345,7 @@ export default class MessageComposer extends React.Component {
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
/>,
<UploadButton key="controls_upload" roomId={this.props.room.roomId} />,
<EmojiButton key="emoji_button" addEmoji={this.addEmoji} />,
@ -431,28 +356,10 @@ export default class MessageComposer extends React.Component {
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
}
if (this.state.showCallButtons) {
if (this.state.hasConference) {
const canEndConf = WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
controls.push(
<HangupButton
key="controls_hangup"
roomId={this.props.room.roomId}
isConference={true}
canEndConference={canEndConf}
isInConference={this.state.joinedConference}
/>,
);
} else if (callInProgress) {
controls.push(
<HangupButton key="controls_hangup" roomId={this.props.room.roomId} isConference={false} />,
);
} else {
controls.push(
<CallButton key="controls_call" roomId={this.props.room.roomId} />,
<VideoCallButton key="controls_videocall" roomId={this.props.room.roomId} />,
);
}
if (!this.state.isComposerEmpty) {
controls.push(
<SendButton key="controls_send" onClick={this.sendMessage} />,
);
}
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];

View file

@ -19,7 +19,9 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.MessageComposerFormatBar")
export default class MessageComposerFormatBar extends React.PureComponent {
static propTypes = {
onAction: PropTypes.func.isRequired,

View file

@ -100,15 +100,8 @@ const NewRoomIntro = () => {
});
}
let canInvite = inRoom;
const powerLevels = room.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
const me = room.getMember(cli.getUserId());
if (powerLevels && me && powerLevels.invite > me.powerLevel) {
canInvite = false;
}
let buttons;
if (canInvite) {
if (room.canInvite(cli.getUserId())) {
const onInviteClick = () => {
dis.dispatch({ action: "view_invite", roomId });
};

View file

@ -21,6 +21,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
notification: NotificationState;
@ -48,6 +49,7 @@ interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
}
@replaceableComponent("views.rooms.NotificationBadge")
export default class NotificationBadge extends React.PureComponent<XOR<IProps, IClickableProps>, IState> {
private countWatcherRef: string;

View file

@ -23,7 +23,9 @@ import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from '../../../languageHandler';
import {formatFullDate} from '../../../DateUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.PinnedEventTile")
export default class PinnedEventTile extends React.Component {
static propTypes = {
mxRoom: PropTypes.object.isRequired,

View file

@ -22,7 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton";
import PinnedEventTile from "./PinnedEventTile";
import { _t } from '../../../languageHandler';
import PinningUtils from "../../../utils/PinningUtils";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.PinnedEventsPanel")
export default class PinnedEventsPanel extends React.Component {
static propTypes = {
// The Room from the js-sdk we're going to show pinned events for

View file

@ -18,8 +18,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.PresenceLabel")
export default class PresenceLabel extends React.Component {
static propTypes = {
// number of milliseconds ago this user was last active.

View file

@ -23,6 +23,7 @@ import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor";
import * as sdk from "../../../index";
import {toPx} from "../../../utils/units";
import {replaceableComponent} from "../../../utils/replaceableComponent";
let bounce = false;
try {
@ -32,7 +33,8 @@ try {
} catch (e) {
}
export default class ReadReceiptMarker extends React.Component {
@replaceableComponent("views.rooms.ReadReceiptMarker")
export default class ReadReceiptMarker extends React.PureComponent {
static propTypes = {
// the RoomMember to show the RR for
member: PropTypes.object,
@ -155,7 +157,15 @@ export default class ReadReceiptMarker extends React.Component {
// then shift to the rightmost column,
// and then it will drop down to its resting position
startStyles.push({ top: startTopOffset+'px', left: '0px' });
//
// XXX: We use a small left value to trick velocity-animate into actually animating.
// This is a very annoying bug where if it thinks there's no change to `left` then it'll
// skip applying it, thus making our read receipt at +14px instead of +0px like it
// should be. This does cause a tiny amount of drift for read receipts, however with a
// value so small it's not perceived by a user.
// Note: Any smaller values (or trying to interchange units) might cause read receipts to
// fail to fall down or cause gaps.
startStyles.push({ top: startTopOffset+'px', left: '1px' });
enterTransitionOpts.push({
duration: bounce ? Math.min(Math.log(Math.abs(startTopOffset)) * 200, 3000) : 300,
easing: bounce ? 'easeOutBounce' : 'easeOutCubic',

View file

@ -23,6 +23,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import PropTypes from "prop-types";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import {UIFeature} from "../../../settings/UIFeature";
import {replaceableComponent} from "../../../utils/replaceableComponent";
function cancelQuoting() {
dis.dispatch({
@ -31,6 +32,7 @@ function cancelQuoting() {
});
}
@replaceableComponent("views.rooms.ReplyPreview")
export default class ReplyPreview extends React.Component {
static propTypes = {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,

View file

@ -27,6 +27,7 @@ import RoomListStore from "../../../stores/room-list/RoomListStore";
import { DefaultTagID } from "../../../stores/room-list/models";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
}
@ -42,6 +43,7 @@ interface IState {
skipFirst: boolean;
}
@replaceableComponent("views.rooms.RoomBreadcrumbs")
export default class RoomBreadcrumbs extends React.PureComponent<IProps, IState> {
private isMounted = true;

View file

@ -22,7 +22,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import {roomShape} from './RoomDetailRow';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.RoomDetailList")
export default class RoomDetailList extends React.Component {
static propTypes = {
rooms: PropTypes.arrayOf(roomShape),

View file

@ -18,9 +18,9 @@ import * as sdk from '../../../index';
import React, {createRef} from 'react';
import { _t } from '../../../languageHandler';
import { linkifyElement } from '../../../HtmlUtils';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import PropTypes from 'prop-types';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
export function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
@ -39,6 +39,7 @@ export const roomShape = PropTypes.shape({
guestCanJoin: PropTypes.bool,
});
@replaceableComponent("views.rooms.RoomDetailRow")
export default class RoomDetailRow extends React.Component {
static propTypes = {
room: roomShape,
@ -98,13 +99,14 @@ export default class RoomDetailRow extends React.Component {
{ guestJoin }
</div>) : <div />;
let avatarUrl = null;
if (room.avatarUrl) avatarUrl = mediaFromMxc(room.avatarUrl).getSquareThumbnailHttp(24);
return <tr key={room.roomId} onClick={this.onClick} onMouseDown={this.props.onMouseDown}>
<td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={24} height={24} resizeMethod='crop'
name={name} idName={name}
url={getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
room.avatarUrl, 24, 24, "crop")} />
url={avatarUrl} />
</td>
<td className="mx_RoomDirectory_roomDescription">
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;

View file

@ -15,14 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import RateLimitedFunc from '../../../ratelimitedfunc';
import { linkifyElement } from '../../../HtmlUtils';
import {CancelButton} from './SimpleRoomHeader';
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
@ -30,7 +29,12 @@ import E2EIcon from './E2EIcon';
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import {DefaultTagID} from "../../../stores/room-list/models";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import {PlaceCallType} from "../../../CallHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.RoomHeader")
export default class RoomHeader extends React.Component {
static propTypes = {
room: PropTypes.object,
@ -44,6 +48,7 @@ export default class RoomHeader extends React.Component {
e2eStatus: PropTypes.string,
onAppsClick: PropTypes.func,
appsShown: PropTypes.bool,
onCallPlaced: PropTypes.func, // (PlaceCallType) => void;
};
static defaultProps = {
@ -52,35 +57,13 @@ export default class RoomHeader extends React.Component {
onCancelClick: null,
};
constructor(props) {
super(props);
this._topic = createRef();
}
componentDidMount() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._onRoomStateEvents);
cli.on("Room.accountData", this._onRoomAccountData);
// When a room name occurs, RoomState.events is fired *before*
// room.name is updated. So we have to listen to Room.name as well as
// RoomState.events.
if (this.props.room) {
this.props.room.on("Room.name", this._onRoomNameChange);
}
}
componentDidUpdate() {
if (this._topic.current) {
linkifyElement(this._topic.current);
}
}
componentWillUnmount() {
if (this.props.room) {
this.props.room.removeListener("Room.name", this._onRoomNameChange);
}
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._onRoomStateEvents);
@ -109,10 +92,6 @@ export default class RoomHeader extends React.Component {
this.forceUpdate();
}, 500);
_onRoomNameChange = (room) => {
this.forceUpdate();
};
_hasUnreadPins() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
@ -170,29 +149,28 @@ export default class RoomHeader extends React.Component {
}
}
let roomName = _t("Join Room");
let oobName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
roomName = this.props.oobData.name;
} else if (this.props.room) {
roomName = this.props.room.name;
oobName = this.props.oobData.name;
}
const textClasses = classNames('mx_RoomHeader_nametext', { mx_RoomHeader_settingsHint: settingsHint });
const name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}>
<div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>
<RoomName room={this.props.room}>
{(name) => {
const roomName = name || oobName;
return <div dir="auto" className={textClasses} title={roomName}>{ roomName }</div>;
}}
</RoomName>
{ searchStatus }
</div>;
let topic;
if (this.props.room) {
const ev = this.props.room.currentState.getStateEvents('m.room.topic', '');
if (ev) {
topic = ev.getContent().topic;
}
}
const topicElement =
<div className="mx_RoomHeader_topic" ref={this._topic} title={topic} dir="auto">{ topic }</div>;
const topicElement = <RoomTopic room={this.props.room}>
{(topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto">
{ topic }
</div>}
</RoomTopic>;
let roomAvatar;
if (this.props.room) {
@ -252,8 +230,26 @@ export default class RoomHeader extends React.Component {
title={_t("Search")} />;
}
let voiceCallButton;
let videoCallButton;
if (this.props.inRoom && SettingsStore.getValue("showCallButtonsInComposer")) {
voiceCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={() => this.props.onCallPlaced(PlaceCallType.Voice)}
title={_t("Voice call")} />;
videoCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev) => this.props.onCallPlaced(
ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video)}
title={_t("Video call")} />;
}
const rightRow =
<div className="mx_RoomHeader_buttons">
{ videoCallButton }
{ voiceCallButton }
{ pinnedEventsButton }
{ forgetButton }
{ appsButton }

View file

@ -16,9 +16,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import React, { ReactComponentElement } from "react";
import { Dispatcher } from "flux";
import { Room } from "matrix-js-sdk/src/models/room";
import * as fbEmitter from "fbemitter";
import { _t, _td } from "../../../languageHandler";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
@ -33,7 +34,7 @@ import RoomSublist from "./RoomSublist";
import { ActionPayload } from "../../../dispatcher/payloads";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar";
import TemporaryTile from "./TemporaryTile";
import ExtraTile from "./ExtraTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Action } from "../../../dispatcher/actions";
@ -47,6 +48,12 @@ import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../con
import AccessibleButton from "../elements/AccessibleButton";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import CallHandler from "../../../CallHandler";
import SpaceStore, { SUGGESTED_ROOMS } from "../../../stores/SpaceStore";
import { showAddExistingRooms, showCreateNewRoom } from "../../../utils/space";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import RoomAvatar from "../avatars/RoomAvatar";
import { ISpaceSummaryRoom } from "../../structures/SpaceRoomDirectory";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
@ -60,6 +67,8 @@ interface IProps {
interface IState {
sublists: ITagMap;
isNameFiltering: boolean;
currentRoomId?: string;
suggestedRooms: ISpaceSummaryRoom[];
}
const TAG_ORDER: TagID[] = [
@ -72,6 +81,7 @@ const TAG_ORDER: TagID[] = [
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
DefaultTagID.Archived,
];
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
@ -152,6 +162,50 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
defaultHidden: false,
addRoomLabel: _td("Add room"),
addRoomContextMenu: (onFinished: () => void) => {
if (SpaceStore.instance.activeSpace) {
const canAddRooms = SpaceStore.instance.activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
MatrixClientPeg.get().getUserId());
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
showCreateNewRoom(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to create new rooms in this space")}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
showAddExistingRooms(MatrixClientPeg.get(), SpaceStore.instance.activeSpace);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to add rooms to this space")}
/>
<IconizedContextMenuOption
label={_t("Explore space rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFinished();
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
</IconizedContextMenuOptionList>;
}
return <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
@ -195,6 +249,12 @@ const TAG_AESTHETICS: ITagAestheticsMap = {
isInvite: false,
defaultHidden: true,
},
[DefaultTagID.Suggested]: {
sectionLabel: _td("Suggested Rooms"),
isInvite: false,
defaultHidden: false,
},
};
function customTagAesthetics(tagId: TagID): ITagAesthetics {
@ -209,10 +269,12 @@ function customTagAesthetics(tagId: TagID): ITagAesthetics {
};
}
@replaceableComponent("views.rooms.RoomList")
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef;
private customTagStoreRef;
private tagAesthetics: ITagAestheticsMap;
private roomStoreToken: fbEmitter.EventSubscription;
constructor(props: IProps) {
super(props);
@ -220,6 +282,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
this.state = {
sublists: {},
isNameFiltering: !!RoomListStore.instance.getFirstNameFilterCondition(),
suggestedRooms: SpaceStore.instance.suggestedRooms,
};
// shallow-copy from the template as we need to make modifications to it
@ -227,20 +290,30 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
this.updateDmAddRoomAction();
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
}
public componentDidMount(): void {
SpaceStore.instance.on(SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists);
this.updateLists(); // trigger the first update
}
public componentWillUnmount() {
SpaceStore.instance.off(SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
defaultDispatcher.unregister(this.dispatcherRef);
if (this.customTagStoreRef) this.customTagStoreRef.remove();
if (this.roomStoreToken) this.roomStoreToken.remove();
}
private onRoomViewStoreUpdate = () => {
this.setState({
currentRoomId: RoomViewStore.getRoomId(),
});
};
private updateDmAddRoomAction() {
const dmTagAesthetics = objectShallowClone(TAG_AESTHETICS[DefaultTagID.DM]);
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
@ -272,7 +345,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
private getRoomDelta = (roomId: string, delta: number, unread = false) => {
const lists = RoomListStore.instance.orderedLists;
const rooms: Room = [];
const rooms: Room[] = [];
TAG_ORDER.forEach(t => {
let listRooms = lists[t];
@ -293,6 +366,10 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
return room;
};
private updateSuggestedRooms = (suggestedRooms: ISpaceSummaryRoom[]) => {
this.setState({ suggestedRooms });
};
private updateLists = () => {
const newLists = RoomListStore.instance.orderedLists;
if (SettingsStore.getValue("advancedRoomListLogging")) {
@ -347,7 +424,44 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
dis.dispatch({ action: Action.ViewRoomDirectory, initialText });
};
private renderCommunityInvites(): TemporaryTile[] {
private renderSuggestedRooms(): ReactComponentElement<typeof ExtraTile>[] {
return this.state.suggestedRooms.map(room => {
const name = room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room");
const avatar = (
<RoomAvatar
oobData={{
name,
avatarUrl: room.avatar_url,
}}
width={32}
height={32}
resizeMethod="crop"
/>
);
const viewRoom = () => {
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
oobData: {
avatarUrl: room.avatar_url,
name,
},
});
};
return (
<ExtraTile
isMinimized={this.props.isMinimized}
isSelected={this.state.currentRoomId === room.room_id}
displayName={name}
avatar={avatar}
onClick={viewRoom}
key={`suggestedRoomTile_${room.room_id}`}
/>
);
});
}
private renderCommunityInvites(): ReactComponentElement<typeof ExtraTile>[] {
// TODO: Put community invites in a more sensible place (not in the room list)
// See https://github.com/vector-im/element-web/issues/14456
return MatrixClientPeg.get().getGroups().filter(g => {
@ -368,7 +482,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
});
};
return (
<TemporaryTile
<ExtraTile
isMinimized={this.props.isMinimized}
isSelected={false}
displayName={g.name}
@ -400,7 +514,14 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
for (const orderedTagId of tagOrder) {
const orderedRooms = this.state.sublists[orderedTagId] || [];
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
let extraTiles = null;
if (orderedTagId === DefaultTagID.Invite) {
extraTiles = this.renderCommunityInvites();
} else if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
}
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed
@ -423,7 +544,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraBadTilesThatShouldntExist={extraTiles}
extraTiles={extraTiles}
/>);
}

View file

@ -27,6 +27,7 @@ import SdkConfig from "../../../SdkConfig";
import IdentityAuthClient from '../../../IdentityAuthClient';
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
@ -45,6 +46,7 @@ const MessageCase = Object.freeze({
OtherError: "OtherError",
});
@replaceableComponent("views.rooms.RoomPreviewBar")
export default class RoomPreviewBar extends React.Component {
static propTypes = {
onJoinClick: PropTypes.func,

View file

@ -17,7 +17,7 @@ limitations under the License.
*/
import * as React from "react";
import {createRef} from "react";
import { createRef, ReactComponentElement } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from 'classnames';
import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
@ -48,9 +48,10 @@ import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNo
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
import { objectExcluding, objectHasDiff } from "../../../utils/objects";
import TemporaryTile from "./TemporaryTile";
import ExtraTile from "./ExtraTile";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import IconizedContextMenu from "../context_menus/IconizedContextMenu";
import {replaceableComponent} from "../../../utils/replaceableComponent";
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
@ -73,9 +74,7 @@ interface IProps {
onResize: () => void;
showSkeleton?: boolean;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this.
extraBadTilesThatShouldntExist?: TemporaryTile[];
extraTiles?: ReactComponentElement<typeof ExtraTile>[];
// TODO: Account for https://github.com/vector-im/element-web/issues/14179
}
@ -95,9 +94,10 @@ interface IState {
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number;
rooms: Room[];
filteredExtraTiles?: TemporaryTile[];
filteredExtraTiles?: ReactComponentElement<typeof ExtraTile>[];
}
@replaceableComponent("views.rooms.RoomSublist")
export default class RoomSublist extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
@ -153,12 +153,12 @@ export default class RoomSublist extends React.Component<IProps, IState> {
return padding;
}
private get extraTiles(): TemporaryTile[] | null {
private get extraTiles(): ReactComponentElement<typeof ExtraTile>[] | null {
if (this.state.filteredExtraTiles) {
return this.state.filteredExtraTiles;
}
if (this.props.extraBadTilesThatShouldntExist) {
return this.props.extraBadTilesThatShouldntExist;
if (this.props.extraTiles) {
return this.props.extraTiles;
}
return null;
}
@ -177,7 +177,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>) {
const prevExtraTiles = prevState.filteredExtraTiles || prevProps.extraBadTilesThatShouldntExist;
const prevExtraTiles = prevState.filteredExtraTiles || prevProps.extraTiles;
// as the rooms can come in one by one we need to reevaluate
// the amount of available rooms to cap the amount of requested visible rooms by the layout
if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) {
@ -200,8 +200,8 @@ export default class RoomSublist extends React.Component<IProps, IState> {
// If we're supposed to handle extra tiles, take the performance hit and re-render all the
// time so we don't have to consider them as part of the visible room optimization.
const prevExtraTiles = this.props.extraBadTilesThatShouldntExist || [];
const nextExtraTiles = (nextState.filteredExtraTiles || nextProps.extraBadTilesThatShouldntExist) || [];
const prevExtraTiles = this.props.extraTiles || [];
const nextExtraTiles = (nextState.filteredExtraTiles || nextProps.extraTiles) || [];
if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) {
return true;
}
@ -249,10 +249,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private onListsUpdated = () => {
const stateUpdates: IState & any = {}; // &any is to avoid a cast on the initializer
if (this.props.extraBadTilesThatShouldntExist) {
if (this.props.extraTiles) {
const nameCondition = RoomListStore.instance.getFirstNameFilterCondition();
if (nameCondition) {
stateUpdates.filteredExtraTiles = this.props.extraBadTilesThatShouldntExist
stateUpdates.filteredExtraTiles = this.props.extraTiles
.filter(t => nameCondition.matches(t.props.displayName || ""));
} else if (this.state.filteredExtraTiles) {
stateUpdates.filteredExtraTiles = null;

View file

@ -51,6 +51,7 @@ import IconizedContextMenu, {
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
room: Room;
@ -78,6 +79,7 @@ const contextMenuBelow = (elementRect: PartialDOMRect) => {
return {left, top, chevronFace};
};
@replaceableComponent("views.rooms.RoomTile")
export default class RoomTile extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private roomTileRef = createRef<HTMLDivElement>();

View file

@ -21,7 +21,9 @@ import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.RoomUpgradeWarningBar")
export default class RoomUpgradeWarningBar extends React.Component {
static propTypes = {
room: PropTypes.object.isRequired,

View file

@ -21,7 +21,9 @@ import classNames from "classnames";
import { _t } from '../../../languageHandler';
import {Key} from "../../../Keyboard";
import DesktopBuildsNotice, {WarningKind} from "../elements/DesktopBuildsNotice";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.SearchBar")
export default class SearchBar extends React.Component {
constructor(props) {
super(props);

View file

@ -21,7 +21,9 @@ import * as sdk from '../../../index';
import {haveTileForEvent} from "./EventTile";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.SearchResultTile")
export default class SearchResultTile extends React.Component {
static propTypes = {
// a matrix-js-sdk SearchResult containing the details of this result

View file

@ -48,6 +48,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import EMOJI_REGEX from 'emojibase-regex';
import {replaceableComponent} from "../../../utils/replaceableComponent";
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -111,12 +112,14 @@ export function isQuickReaction(model) {
return false;
}
@replaceableComponent("views.rooms.SendMessageComposer")
export default class SendMessageComposer extends React.Component {
static propTypes = {
room: PropTypes.object.isRequired,
placeholder: PropTypes.string,
permalinkCreator: PropTypes.object.isRequired,
replyToEvent: PropTypes.object,
onChange: PropTypes.func,
};
static contextType = MatrixClientContext;
@ -403,7 +406,9 @@ export default class SendMessageComposer extends React.Component {
this._editorRef.clearUndoHistory();
this._editorRef.focus();
this._clearStoredEditorState();
dis.dispatch({action: "scroll_to_bottom"});
if (SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({action: "scroll_to_bottom"});
}
}
componentWillUnmount() {
@ -536,10 +541,15 @@ export default class SendMessageComposer extends React.Component {
}
}
onChange = () => {
if (this.props.onChange) this.props.onChange(this.model);
}
render() {
return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this._onKeyDown}>
<BasicMessageComposer
onChange={this.onChange}
ref={this._setEditorRef}
model={this.model}
room={this.props.room}

View file

@ -19,6 +19,7 @@ import PropTypes from 'prop-types';
import AccessibleButton from '../elements/AccessibleButton';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
// cancel button which is shared between room header and simple room header
export function CancelButton(props) {
@ -36,6 +37,7 @@ export function CancelButton(props) {
* 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,

View file

@ -30,6 +30,7 @@ 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";
// 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.
@ -38,6 +39,7 @@ const STICKERPICKER_Z_INDEX = 3500;
// Key to store the widget's AppTile under in PersistedElement
const PERSISTED_ELEMENT_KEY = "stickerPicker";
@replaceableComponent("views.rooms.Stickerpicker")
export default class Stickerpicker extends React.Component {
static currentWidget;

View file

@ -23,7 +23,11 @@ import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import {isValid3pidInvite} from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.ThirdPartyMemberInfo")
export default class ThirdPartyMemberInfo extends React.Component {
static propTypes = {
event: PropTypes.instanceOf(MatrixEvent).isRequired,
@ -32,14 +36,14 @@ export default class ThirdPartyMemberInfo extends React.Component {
constructor(props) {
super(props);
const room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = room.currentState.getStateEvents("m.room.power_levels", "");
this.room = MatrixClientPeg.get().getRoom(this.props.event.getRoomId());
const me = this.room.getMember(MatrixClientPeg.get().getUserId());
const powerLevels = this.room.currentState.getStateEvents("m.room.power_levels", "");
let kickLevel = powerLevels ? powerLevels.getContent().kick : 50;
if (typeof(kickLevel) !== 'number') kickLevel = 50;
const sender = room.getMember(this.props.event.getSender());
const sender = this.room.getMember(this.props.event.getSender());
this.state = {
stateKey: this.props.event.getStateKey(),
@ -119,9 +123,18 @@ export default class ThirdPartyMemberInfo extends React.Component {
);
}
let scopeHeader;
if (this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} />
</div>;
}
// We shamelessly rip off the MemberInfo styles here.
return (
<div className="mx_MemberInfo" role="tabpanel">
{ scopeHeader }
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel"
onClick={this.onCancel}

View file

@ -20,7 +20,9 @@ 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,

View file

@ -21,7 +21,9 @@ import * as WhoIsTyping from '../../../WhoIsTyping';
import Timer from '../../../utils/Timer';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import MemberAvatar from '../avatars/MemberAvatar';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.WhoIsTypingTile")
export default class WhoIsTypingTile extends React.Component {
static propTypes = {
// the room this statusbar is representing.