Merge branch 't3chguy/ts/8' into text-for-event-perf

This commit is contained in:
Robin Townsend 2021-06-24 18:39:36 -04:00
commit f9fe28a6ad
179 changed files with 5561 additions and 3317 deletions

View file

@ -15,12 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { HTMLAttributes, WheelEvent } from "react";
interface IProps {
interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onScroll"> {
className?: string;
onScroll?: () => void;
onWheel?: () => void;
onScroll?: (event: Event) => void;
onWheel?: (event: WheelEvent) => void;
style?: React.CSSProperties
tabIndex?: number,
wrappedRef?: (ref: HTMLDivElement) => void;
@ -52,14 +52,18 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
}
public render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props;
return (<div
{...otherProps}
ref={this.containerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
style={style}
className={["mx_AutoHideScrollbar", className].join(" ")}
onWheel={onWheel}
tabIndex={tabIndex}
>
{ this.props.children }
{ children }
</div>);
}
}

View file

@ -24,7 +24,6 @@ import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
@ -83,7 +82,7 @@ class GroupFilterPanel extends React.Component {
}
};
onMouseDown = e => {
onClick = e => {
// only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) {
dis.dispatch({action: 'deselect_tags'});
@ -151,28 +150,15 @@ class GroupFilterPanel extends React.Component {
return <div className={classes} onClick={this.onClearFilterClick}>
<AutoHideScrollbar
className="mx_GroupFilterPanel_scroller"
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown}
onClick={this.onClick}
>
<Droppable
droppableId="tag-panel-droppable"
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
{ tags }
<div>
{createButton}
</div>
{ provided.placeholder }
</div>
) }
</Droppable>
<div className="mx_GroupFilterPanel_tagTileContainer">
{ this.renderGlobalIcon() }
{ tags }
<div>
{ createButton }
</div>
</div>
</AutoHideScrollbar>
</div>;
}

View file

@ -185,21 +185,24 @@ export default class IndicatorScrollbar extends React.Component {
};
render() {
// eslint-disable-next-line no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
const leftOverflowIndicator = this.props.trackHorizontalOverflow
const leftOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = this.props.trackHorizontalOverflow
const rightOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
onWheel={this.onMouseWheel}
{...this.props}
{...otherProps}
>
{ leftOverflowIndicator }
{ this.props.children }
{ children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
}

View file

@ -19,19 +19,16 @@ limitations under the License.
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import {Key} from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import MediaDeviceHandler from '../../MediaDeviceHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import { IMatrixClientCreds } from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
@ -170,7 +167,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
CallMediaHandler.loadDevices();
MediaDeviceHandler.loadDevices();
fixupColorFonts();
@ -569,50 +566,6 @@ class LoggedInView extends React.Component<IProps, IState> {
}
};
_onDragEnd = (result) => {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
}
const dest = result.destination.droppableId;
if (dest === 'tag-panel-droppable') {
// Could be "GroupTile +groupId:domain"
const draggableId = result.draggableId.split(' ').pop();
// Dispatch synchronously so that the GroupFilterPanel receives an
// optimistic update from GroupFilterOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this._matrixClient,
draggableId,
result.destination.index,
), true);
} else if (dest.startsWith('room-sub-list-droppable_')) {
this._onRoomTileEndDrag(result);
}
};
_onRoomTileEndDrag = (result) => {
let newTag = result.destination.droppableId.split('_')[1];
let prevTag = result.source.droppableId.split('_')[1];
if (newTag === 'undefined') newTag = undefined;
if (prevTag === 'undefined') prevTag = undefined;
const roomId = result.draggableId.split('_')[1];
const oldIndex = result.source.index;
const newIndex = result.destination.index;
dis.dispatch(RoomListActions.tagRoom(
this._matrixClient,
this._matrixClient.getRoom(roomId),
prevTag, newTag,
oldIndex, newIndex,
), true);
};
render() {
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
@ -679,17 +632,15 @@ class LoggedInView extends React.Component<IProps, IState> {
aria-hidden={this.props.hideToSRUsers}
>
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
</div>
</DragDropContext>
<div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
<ResizeHandle />
{ pageElement }
</div>
</div>
<CallContainer />
<NonUrgentToastContainer />

View file

@ -48,7 +48,7 @@ import createRoom, {IOpts} from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore";
import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { startAnyRegistrationFlow } from "../../Registration";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
@ -1461,7 +1461,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total));
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation

View file

@ -82,8 +82,7 @@ export default class MyGroups extends React.Component {
</p>
<p>
{ _t(
"To set up a filter, drag a community avatar over to the filter panel on " +
"the far left hand side of the screen. You can click on an avatar in the " +
"You can click on an avatar in the " +
"filter panel at any time to see only the rooms and people associated " +
"with that community.",
) }

View file

@ -22,6 +22,7 @@ import BaseCard from "../views/right_panel/BaseCard";
import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile";
interface IProps {
onClose(): void;
@ -48,7 +49,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview={false}
tileShape="notif"
tileShape={TileShape.Notif}
empty={emptyState}
alwaysShowTimestamps={true}
/>

View file

@ -207,9 +207,9 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
this.getMoreRooms();
};
private getMoreRooms() {
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve();
private getMoreRooms(): Promise<boolean> {
if (this.state.selectedCommunityId) return Promise.resolve(false); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve(false);
this.setState({
loading: true,
@ -239,12 +239,12 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
// if the filter or server has changed since this request was sent,
// throw away the result (don't even clear the busy flag
// since we must still have a request in flight)
return;
return false;
}
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
return false;
}
if (this.state.filterString) {
@ -264,14 +264,13 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
filterString != this.state.filterString ||
roomServer != this.state.roomServer ||
nextBatch != this.nextBatch) {
// as above: we don't care about errors for old
// requests either
return;
// as above: we don't care about errors for old requests either
return false;
}
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
return false;
}
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
@ -337,11 +336,10 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
}
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
// If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault();
this.removeFromDirectory(room);
} else {
this.showRoom(room);
}
};
@ -568,11 +566,11 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
let avatarUrl = null;
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
return [
<div key={ `${room.room_id}_avatar` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_avatar` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomAvatar"
>
<BaseAvatar
@ -584,42 +582,50 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
url={avatarUrl}
/>
</div>,
<div key={ `${room.room_id}_description` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_description` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomDescription"
>
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
<div
className="mx_RoomDirectory_name"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ name }
</div>&nbsp;
<div
className="mx_RoomDirectory_topic"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
dangerouslySetInnerHTML={{ __html: topic }}
/>
<div className="mx_RoomDirectory_alias">{ getDisplayAliasForRoom(room) }</div>
<div
className="mx_RoomDirectory_alias"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ getDisplayAliasForRoom(room) }
</div>
</div>,
<div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_memberCount` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomMemberCount"
>
{ room.num_joined_members }
</div>,
<div key={ `${room.room_id}_preview` }
onClick={(ev) => this.onRoomClicked(room, ev)}
<div
key={ `${room.room_id}_preview` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_preview"
>
{previewButton}
{ previewButton }
</div>,
<div key={ `${room.room_id}_join` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={ `${room.room_id}_join` }
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_join"
>
{joinOrViewButton}
{ joinOrViewButton }
</div>,
];
}

View file

@ -23,7 +23,7 @@ limitations under the License.
import React, { createRef } from 'react';
import classNames from 'classnames';
import { Room } from "matrix-js-sdk/src/models/room";
import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import { EventSubscription } from "fbemitter";
@ -60,7 +60,7 @@ import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import SearchBar from "../views/rooms/SearchBar";
import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
@ -139,7 +139,7 @@ export interface IState {
draggingFile: boolean;
searching: boolean;
searchTerm?: string;
searchScope?: "All" | "Room";
searchScope?: SearchScope;
searchResults?: XOR<{}, {
count: number;
highlights: string[];
@ -172,11 +172,7 @@ export interface IState {
// We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: {
version: string;
needsUpgrade: boolean;
urgent: boolean;
};
upgradeRecommendation?: IRecommendedVersion;
canReact: boolean;
canReply: boolean;
layout: Layout;
@ -1134,7 +1130,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}
}
private onSearchResultsFillRequest = (backwards: boolean) => {
private onSearchResultsFillRequest = (backwards: boolean): Promise<boolean> => {
if (!backwards) {
return Promise.resolve(false);
}
@ -1272,7 +1268,7 @@ export default class RoomView extends React.Component<IProps, IState> {
});
}
private onSearch = (term: string, scope) => {
private onSearch = (term: string, scope: SearchScope) => {
this.setState({
searchTerm: term,
searchScope: scope,
@ -1293,14 +1289,14 @@ export default class RoomView extends React.Component<IProps, IState> {
this.searchId = new Date().getTime();
let roomId;
if (scope === "Room") roomId = this.state.room.roomId;
if (scope === SearchScope.Room) roomId = this.state.room.roomId;
debuglog("sending search request");
const searchPromise = eventSearch(term, roomId);
this.handleSearchResult(searchPromise);
};
private handleSearchResult(searchPromise: Promise<any>) {
private handleSearchResult(searchPromise: Promise<any>): Promise<boolean> {
// keep a record of the current search id, so that if the search terms
// change before we get a response, we can ignore the results.
const localSearchId = this.searchId;
@ -1313,7 +1309,7 @@ export default class RoomView extends React.Component<IProps, IState> {
debuglog("search complete");
if (this.unmounted || !this.state.searching || this.searchId != localSearchId) {
console.error("Discarding stale search results");
return;
return false;
}
// postgres on synapse returns us precise details of the strings
@ -1345,6 +1341,7 @@ export default class RoomView extends React.Component<IProps, IState> {
description: ((error && error.message) ? error.message :
_t("Server may be unavailable, overloaded, or search timed out :(")),
});
return false;
}).finally(() => {
this.setState({
searchInProgress: false,
@ -2063,7 +2060,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = (<JumpToBottomButton
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}

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.
@ -14,17 +14,18 @@ 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, CSSProperties, ReactNode, SyntheticEvent, KeyboardEvent } from "react";
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {getKeyBindingsManager, RoomAction} from "../../KeyBindingsManager";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager";
import ResizeNotifier from "../../utils/ResizeNotifier";
const DEBUG_SCROLL = false;
// The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight.
// See getExcessHeight.
const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
@ -43,6 +44,75 @@ if (DEBUG_SCROLL) {
debuglog = function() {};
}
interface IProps {
/* stickyBottom: if set to true, then once the user hits the bottom of
* the list, any new children added to the list will cause the list to
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom?: boolean;
/* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom.
* XXX: It's likely this is unnecessary and can be derived from
* stickyBottom, but I'm adding an extra parameter to ensure
* behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line.
*/
startAtBottom?: boolean;
/* className: classnames to add to the top-level div
*/
className?: string;
/* style: styles to add to the top-level div
*/
style?: CSSProperties;
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier?: ResizeNotifier;
/* fixedChildren: allows for children to be passed which are rendered outside
* of the wrapper
*/
fixedChildren?: ReactNode;
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
* false) of the list.
*
* This should return a promise; no more calls will be made until the
* promise completes.
*
* The promise should resolve to true if there is more data to be
* retrieved in this direction (in which case onFillRequest may be
* called again immediately), or false if there is no more data in this
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest?(backwards: boolean): Promise<boolean>;
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
* without causing pagination to occur.
*
* This function should accept a boolean, which is true to indicate the back/top
* of the panel and false otherwise, and a scroll token, which refers to the
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll?(event: Event): void;
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll?(event: SyntheticEvent): void;
}
/* This component implements an intelligent scrolling list.
*
* It wraps a list of <li> children; when items are added to the start or end
@ -84,97 +154,54 @@ if (DEBUG_SCROLL) {
* offset as normal.
*/
export interface IScrollState {
stuckAtBottom: boolean;
trackedNode?: HTMLElement;
trackedScrollToken?: string;
bottomOffset?: number;
pixelOffset?: number;
}
interface IPreventShrinkingState {
offsetFromBottom: number;
offsetNode: HTMLElement;
}
@replaceableComponent("structures.ScrollPanel")
export default class ScrollPanel extends React.Component {
static propTypes = {
/* stickyBottom: if set to true, then once the user hits the bottom of
* the list, any new children added to the list will cause the list to
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom: PropTypes.bool,
/* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom.
* XXX: It's likely this is unnecessary and can be derived from
* stickyBottom, but I'm adding an extra parameter to ensure
* behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line.
*/
startAtBottom: PropTypes.bool,
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
* false) of the list.
*
* This should return a promise; no more calls will be made until the
* promise completes.
*
* The promise should resolve to true if there is more data to be
* retrieved in this direction (in which case onFillRequest may be
* called again immediately), or false if there is no more data in this
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest: PropTypes.func,
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
* without causing pagination to occur.
*
* This function should accept a boolean, which is true to indicate the back/top
* of the panel and false otherwise, and a scroll token, which refers to the
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest: PropTypes.func,
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll: PropTypes.func,
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll: PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: PropTypes.string,
/* style: styles to add to the top-level div
*/
style: PropTypes.object,
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier: PropTypes.object,
/* fixedChildren: allows for children to be passed which are rendered outside
* of the wrapper
*/
fixedChildren: PropTypes.node,
};
export default class ScrollPanel extends React.Component<IProps> {
static defaultProps = {
stickyBottom: true,
startAtBottom: true,
onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onFillRequest: function(backwards: boolean) { return Promise.resolve(false); },
onUnfillRequest: function(backwards: boolean, scrollToken: string) {},
onScroll: function() {},
};
constructor(props) {
super(props);
private readonly pendingFillRequests: Record<"b" | "f", boolean> = {
b: null,
f: null,
};
private readonly itemlist = createRef<HTMLOListElement>();
private unmounted = false;
private scrollTimeout: Timer;
private isFilling: boolean;
private fillRequestWhileRunning: boolean;
private scrollState: IScrollState;
private preventShrinkingState: IPreventShrinkingState;
private unfillDebouncer: NodeJS.Timeout;
private bottomGrowth: number;
private pages: number;
private heightUpdateInProgress: boolean;
private divScroll: HTMLDivElement;
this._pendingFillRequests = {b: null, f: null};
constructor(props, context) {
super(props, context);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
}
this.resetScrollState();
this._itemlist = createRef();
}
componentDidMount() {
@ -203,18 +230,18 @@ export default class ScrollPanel extends React.Component {
}
}
onScroll = ev => {
private onScroll = ev => {
// skip scroll events caused by resizing
if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
debuglog("onScroll", this._getScrollNode().scrollTop);
this._scrollTimeout.restart();
this._saveScrollState();
debuglog("onScroll", this.getScrollNode().scrollTop);
this.scrollTimeout.restart();
this.saveScrollState();
this.updatePreventShrinking();
this.props.onScroll(ev);
this.checkFillState();
};
onResize = () => {
private onResize = () => {
debuglog("onResize");
this.checkScroll();
// update preventShrinkingState if present
@ -225,11 +252,11 @@ export default class ScrollPanel extends React.Component {
// after an update to the contents of the panel, check that the scroll is
// where it ought to be, and set off pagination requests if necessary.
checkScroll = () => {
public checkScroll = () => {
if (this.unmounted) {
return;
}
this._restoreSavedScrollState();
this.restoreSavedScrollState();
this.checkFillState();
};
@ -238,8 +265,8 @@ export default class ScrollPanel extends React.Component {
// note that this is independent of the 'stuckAtBottom' state - it is simply
// about whether the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update.
isAtBottom = () => {
const sn = this._getScrollNode();
public isAtBottom = () => {
const sn = this.getScrollNode();
// fractional values (both too big and too small)
// for scrollTop happen on certain browsers/platforms
// when scrolled all the way down. E.g. Chrome 72 on debian.
@ -278,10 +305,10 @@ export default class ScrollPanel extends React.Component {
// |#########| - |
// |#########| |
// `---------' -
_getExcessHeight(backwards) {
const sn = this._getScrollNode();
const contentHeight = this._getMessagesHeight();
const listHeight = this._getListHeight();
private getExcessHeight(backwards: boolean): number {
const sn = this.getScrollNode();
const contentHeight = this.getMessagesHeight();
const listHeight = this.getListHeight();
const clippedHeight = contentHeight - listHeight;
const unclippedScrollTop = sn.scrollTop + clippedHeight;
@ -293,13 +320,13 @@ export default class ScrollPanel extends React.Component {
}
// check the scroll state and send out backfill requests if necessary.
checkFillState = async (depth=0) => {
public checkFillState = async (depth = 0): Promise<void> => {
if (this.unmounted) {
return;
}
const isFirstCall = depth === 0;
const sn = this._getScrollNode();
const sn = this.getScrollNode();
// if there is less than a screenful of messages above or below the
// viewport, try to get some more messages.
@ -330,17 +357,17 @@ export default class ScrollPanel extends React.Component {
// do make a note when a new request comes in while already running one,
// so we can trigger a new chain of calls once done.
if (isFirstCall) {
if (this._isFilling) {
debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request");
this._fillRequestWhileRunning = true;
if (this.isFilling) {
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
this.fillRequestWhileRunning = true;
return;
}
debuglog("_isFilling: setting");
this._isFilling = true;
debuglog("isFilling: setting");
this.isFilling = true;
}
const itemlist = this._itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild;
const itemlist = this.itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = [];
@ -348,13 +375,13 @@ export default class ScrollPanel extends React.Component {
// try backward filling
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
// need to back-fill
fillPromises.push(this._maybeFill(depth, true));
fillPromises.push(this.maybeFill(depth, true));
}
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
// try forward filling
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
// need to forward-fill
fillPromises.push(this._maybeFill(depth, false));
fillPromises.push(this.maybeFill(depth, false));
}
if (fillPromises.length) {
@ -365,26 +392,26 @@ export default class ScrollPanel extends React.Component {
}
}
if (isFirstCall) {
debuglog("_isFilling: clearing");
this._isFilling = false;
debuglog("isFilling: clearing");
this.isFilling = false;
}
if (this._fillRequestWhileRunning) {
this._fillRequestWhileRunning = false;
if (this.fillRequestWhileRunning) {
this.fillRequestWhileRunning = false;
this.checkFillState();
}
};
// check if unfilling is possible and send an unfill request if necessary
_checkUnfillState(backwards) {
let excessHeight = this._getExcessHeight(backwards);
private checkUnfillState(backwards: boolean): void {
let excessHeight = this.getExcessHeight(backwards);
if (excessHeight <= 0) {
return;
}
const origExcessHeight = excessHeight;
const tiles = this._itemlist.current.children;
const tiles = this.itemlist.current.children;
// The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null;
@ -413,11 +440,11 @@ export default class ScrollPanel extends React.Component {
if (markerScrollToken) {
// Use a debouncer to prevent multiple unfill calls in quick succession
// This is to make the unfilling process less aggressive
if (this._unfillDebouncer) {
clearTimeout(this._unfillDebouncer);
if (this.unfillDebouncer) {
clearTimeout(this.unfillDebouncer);
}
this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null;
this.unfillDebouncer = setTimeout(() => {
this.unfillDebouncer = null;
debuglog("unfilling now", backwards, origExcessHeight);
this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS);
@ -425,9 +452,9 @@ export default class ScrollPanel extends React.Component {
}
// check if there is already a pending fill request. If not, set one off.
_maybeFill(depth, backwards) {
private maybeFill(depth: number, backwards: boolean): Promise<void> {
const dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) {
if (this.pendingFillRequests[dir]) {
debuglog("Already a "+dir+" fill in progress - not starting another");
return;
}
@ -436,7 +463,7 @@ export default class ScrollPanel extends React.Component {
// onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call.
this._pendingFillRequests[dir] = true;
this.pendingFillRequests[dir] = true;
// wait 1ms before paginating, because otherwise
// this will block the scroll event handler for +700ms
@ -445,13 +472,13 @@ export default class ScrollPanel extends React.Component {
return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
return this.props.onFillRequest(backwards);
}).finally(() => {
this._pendingFillRequests[dir] = false;
this.pendingFillRequests[dir] = false;
}).then((hasMoreResults) => {
if (this.unmounted) {
return;
}
// Unpaginate once filling is complete
this._checkUnfillState(!backwards);
this.checkUnfillState(!backwards);
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) {
@ -477,7 +504,7 @@ export default class ScrollPanel extends React.Component {
* the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel.
*/
getScrollState = () => this.scrollState;
public getScrollState = (): IScrollState => this.scrollState;
/* reset the saved scroll state.
*
@ -491,35 +518,35 @@ export default class ScrollPanel extends React.Component {
* no use if no children exist yet, or if you are about to replace the
* child list.)
*/
resetScrollState = () => {
public resetScrollState = (): void => {
this.scrollState = {
stuckAtBottom: this.props.startAtBottom,
};
this._bottomGrowth = 0;
this._pages = 0;
this._scrollTimeout = new Timer(100);
this._heightUpdateInProgress = false;
this.bottomGrowth = 0;
this.pages = 0;
this.scrollTimeout = new Timer(100);
this.heightUpdateInProgress = false;
};
/**
* jump to the top of the content.
*/
scrollToTop = () => {
this._getScrollNode().scrollTop = 0;
this._saveScrollState();
public scrollToTop = (): void => {
this.getScrollNode().scrollTop = 0;
this.saveScrollState();
};
/**
* jump to the bottom of the content.
*/
scrollToBottom = () => {
public scrollToBottom = (): void => {
// the easiest way to make sure that the scroll state is correctly
// saved is to do the scroll, then save the updated state. (Calculating
// it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here).
const sn = this._getScrollNode();
const sn = this.getScrollNode();
sn.scrollTop = sn.scrollHeight;
this._saveScrollState();
this.saveScrollState();
};
/**
@ -527,18 +554,18 @@ export default class ScrollPanel extends React.Component {
*
* @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative = mult => {
const scrollNode = this._getScrollNode();
public scrollRelative = (mult: number): void => {
const scrollNode = this.getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta);
this._saveScrollState();
this.saveScrollState();
};
/**
* Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event
*/
handleScrollKey = ev => {
public handleScrollKey = (ev: KeyboardEvent) => {
let isScrolling = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
@ -575,17 +602,17 @@ export default class ScrollPanel extends React.Component {
* node (specifically, the bottom of it) will be positioned. If omitted, it
* defaults to 0.
*/
scrollToToken = (scrollToken, pixelOffset, offsetBase) => {
public scrollToToken = (scrollToken: string, pixelOffset: number, offsetBase: number): void => {
pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0;
// set the trackedScrollToken so we can get the node through _getTrackedNode
// set the trackedScrollToken so we can get the node through getTrackedNode
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: scrollToken,
};
const trackedNode = this._getTrackedNode();
const scrollNode = this._getScrollNode();
const trackedNode = this.getTrackedNode();
const scrollNode = this.getScrollNode();
if (trackedNode) {
// set the scrollTop to the position we want.
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
@ -595,34 +622,34 @@ export default class ScrollPanel extends React.Component {
// enough so it ends up in the top of the viewport.
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
this._saveScrollState();
this.saveScrollState();
}
};
_saveScrollState() {
private saveScrollState(): void {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
debuglog("saved stuckAtBottom state");
return;
}
const scrollNode = this._getScrollNode();
const scrollNode = this.getScrollNode();
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this._itemlist.current;
const itemlist = this.itemlist.current;
const messages = itemlist.children;
let node = null;
// TODO: do a binary search here, as items are sorted by offsetTop
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length-1; i >= 0; --i) {
if (!messages[i].dataset.scrollTokens) {
for (let i = messages.length - 1; i >= 0; --i) {
if (!(messages[i] as HTMLElement).dataset.scrollTokens) {
continue;
}
node = messages[i];
// break at the first message (coming from the bottom)
// that has it's offsetTop above the bottom of the viewport.
if (this._topFromBottom(node) > viewportBottom) {
if (this.topFromBottom(node) > viewportBottom) {
// Use this node as the scrollToken
break;
}
@ -634,7 +661,7 @@ export default class ScrollPanel extends React.Component {
}
const scrollToken = node.dataset.scrollTokens.split(',')[0];
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
const bottomOffset = this._topFromBottom(node);
const bottomOffset = this.topFromBottom(node);
this.scrollState = {
stuckAtBottom: false,
trackedNode: node,
@ -644,35 +671,35 @@ export default class ScrollPanel extends React.Component {
};
}
async _restoreSavedScrollState() {
private async restoreSavedScrollState(): Promise<void> {
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
const sn = this._getScrollNode();
const sn = this.getScrollNode();
if (sn.scrollTop !== sn.scrollHeight) {
sn.scrollTop = sn.scrollHeight;
}
} else if (scrollState.trackedScrollToken) {
const itemlist = this._itemlist.current;
const trackedNode = this._getTrackedNode();
const itemlist = this.itemlist.current;
const trackedNode = this.getTrackedNode();
if (trackedNode) {
const newBottomOffset = this._topFromBottom(trackedNode);
const newBottomOffset = this.topFromBottom(trackedNode);
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
this._bottomGrowth += bottomDiff;
this.bottomGrowth += bottomDiff;
scrollState.bottomOffset = newBottomOffset;
const newHeight = `${this._getListHeight()}px`;
const newHeight = `${this.getListHeight()}px`;
if (itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight;
}
debuglog("balancing height because messages below viewport grew by", bottomDiff);
}
}
if (!this._heightUpdateInProgress) {
this._heightUpdateInProgress = true;
if (!this.heightUpdateInProgress) {
this.heightUpdateInProgress = true;
try {
await this._updateHeight();
await this.updateHeight();
} finally {
this._heightUpdateInProgress = false;
this.heightUpdateInProgress = false;
}
} else {
debuglog("not updating height because request already in progress");
@ -680,11 +707,11 @@ export default class ScrollPanel extends React.Component {
}
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
async _updateHeight() {
private async updateHeight(): Promise<void> {
// wait until user has stopped scrolling
if (this._scrollTimeout.isRunning()) {
if (this.scrollTimeout.isRunning()) {
debuglog("updateHeight waiting for scrolling to end ... ");
await this._scrollTimeout.finished();
await this.scrollTimeout.finished();
} else {
debuglog("updateHeight getting straight to business, no scrolling going on.");
}
@ -694,14 +721,14 @@ export default class ScrollPanel extends React.Component {
return;
}
const sn = this._getScrollNode();
const itemlist = this._itemlist.current;
const contentHeight = this._getMessagesHeight();
const sn = this.getScrollNode();
const itemlist = this.itemlist.current;
const contentHeight = this.getMessagesHeight();
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
this._pages = Math.ceil(height / PAGE_SIZE);
this._bottomGrowth = 0;
const newHeight = `${this._getListHeight()}px`;
this.pages = Math.ceil(height / PAGE_SIZE);
this.bottomGrowth = 0;
const newHeight = `${this.getListHeight()}px`;
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
@ -713,7 +740,7 @@ export default class ScrollPanel extends React.Component {
}
debuglog("updateHeight to", newHeight);
} else if (scrollState.trackedScrollToken) {
const trackedNode = this._getTrackedNode();
const trackedNode = this.getTrackedNode();
// if the timeline has been reloaded
// this can be called before scrollToBottom or whatever has been called
// so don't do anything if the node has disappeared from
@ -735,17 +762,17 @@ export default class ScrollPanel extends React.Component {
}
}
_getTrackedNode() {
private getTrackedNode(): HTMLElement {
const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode;
if (!trackedNode || !trackedNode.parentElement) {
let node;
const messages = this._itemlist.current.children;
const messages = this.itemlist.current.children;
const scrollToken = scrollState.trackedScrollToken;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
const m = messages[i] as HTMLElement;
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
@ -768,45 +795,45 @@ export default class ScrollPanel extends React.Component {
return scrollState.trackedNode;
}
_getListHeight() {
return this._bottomGrowth + (this._pages * PAGE_SIZE);
private getListHeight(): number {
return this.bottomGrowth + (this.pages * PAGE_SIZE);
}
_getMessagesHeight() {
const itemlist = this._itemlist.current;
const lastNode = itemlist.lastElementChild;
private getMessagesHeight(): number {
const itemlist = this.itemlist.current;
const lastNode = itemlist.lastElementChild as HTMLElement;
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
const firstNodeTop = itemlist.firstElementChild ? (itemlist.firstElementChild as HTMLElement).offsetTop : 0;
// 18 is itemlist padding
return lastNodeBottom - firstNodeTop + (18 * 2);
}
_topFromBottom(node) {
private topFromBottom(node: HTMLElement): number {
// current capped height - distance from top = distance from bottom of container to top of tracked element
return this._itemlist.current.clientHeight - node.offsetTop;
return this.itemlist.current.clientHeight - node.offsetTop;
}
/* get the DOM node which has the scrollTop property we care about for our
* message panel.
*/
_getScrollNode() {
private getScrollNode(): HTMLDivElement {
if (this.unmounted) {
// this shouldn't happen, but when it does, turn the NPE into
// something more meaningful.
throw new Error("ScrollPanel._getScrollNode called when unmounted");
throw new Error("ScrollPanel.getScrollNode called when unmounted");
}
if (!this._divScroll) {
if (!this.divScroll) {
// Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected");
throw new Error("ScrollPanel.getScrollNode called before AutoHideScrollbar ref collected");
}
return this._divScroll;
return this.divScroll;
}
_collectScroll = divScroll => {
this._divScroll = divScroll;
private collectScroll = (divScroll: HTMLDivElement) => {
this.divScroll = divScroll;
};
/**
@ -814,15 +841,15 @@ export default class ScrollPanel extends React.Component {
anything below it changes, by calling updatePreventShrinking, to keep
the same minimum bottom offset, effectively preventing the timeline to shrink.
*/
preventShrinking = () => {
const messageList = this._itemlist.current;
public preventShrinking = (): void => {
const messageList = this.itemlist.current;
const tiles = messageList && messageList.children;
if (!messageList) {
return;
}
let lastTileNode;
for (let i = tiles.length - 1; i >= 0; i--) {
const node = tiles[i];
const node = tiles[i] as HTMLElement;
if (node.dataset.scrollTokens) {
lastTileNode = node;
break;
@ -841,8 +868,8 @@ export default class ScrollPanel extends React.Component {
};
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
clearPreventShrinking = () => {
const messageList = this._itemlist.current;
public clearPreventShrinking = (): void => {
const messageList = this.itemlist.current;
const balanceElement = messageList && messageList.parentElement;
if (balanceElement) balanceElement.style.paddingBottom = null;
this.preventShrinkingState = null;
@ -857,11 +884,11 @@ export default class ScrollPanel extends React.Component {
from the bottom of the marked tile grows larger than
what it was when marking.
*/
updatePreventShrinking = () => {
public updatePreventShrinking = (): void => {
if (this.preventShrinkingState) {
const sn = this._getScrollNode();
const sn = this.getScrollNode();
const scrollState = this.scrollState;
const messageList = this._itemlist.current;
const messageList = this.itemlist.current;
const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
// element used to set paddingBottom to balance the typing notifs disappearing
const balanceElement = messageList.parentElement;
@ -898,13 +925,15 @@ export default class ScrollPanel extends React.Component {
// list-style-type: none; is no longer a list
return (
<AutoHideScrollbar
wrappedRef={this._collectScroll}
wrappedRef={this.collectScroll}
onScroll={this.onScroll}
onWheel={this.props.onUserScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
className={`mx_ScrollPanel ${this.props.className}`}
style={this.props.style}
>
{ this.props.fixedChildren }
<div className="mx_RoomView_messageListWrapper">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
<ol ref={this.itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
{ this.props.children }
</ol>
</div>

View file

@ -14,34 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ReactNode, useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import React, { ReactNode, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import classNames from "classnames";
import {sortBy} from "lodash";
import { sortBy } from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import { _t } from "../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import RoomName from "../views/elements/RoomName";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {EnhancedMap} from "../../utils/maps";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import { EnhancedMap } from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar";
import {mediaFromMxc} from "../../customisations/Media";
import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import {linkifyElement} from "../../HtmlUtils";
import { linkifyElement } from "../../HtmlUtils";
interface IHierarchyProps {
space: Room;
@ -286,7 +286,7 @@ export const HierarchyLevel = ({
const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key);
return getChildOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;

View file

@ -55,7 +55,7 @@ export default class ViewSource extends React.Component {
viewSourceContent() {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted();
const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
const originalEventSource = mxEvent.event;
if (isEncrypted) {

View file

@ -17,16 +17,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useCallback, useContext, useEffect, useState} from 'react';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
import {ResizeMethod} from "../../../Avatar";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units";
import { _t } from '../../../languageHandler';
interface IProps {

View file

@ -15,10 +15,11 @@ limitations under the License.
*/
import React from 'react';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import BaseAvatar from './BaseAvatar';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
export interface IProps {
groupId?: string;

View file

@ -16,14 +16,14 @@ limitations under the License.
*/
import React from 'react';
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import { Action } from "../../../dispatcher/actions";
import BaseAvatar from "./BaseAvatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember;

View file

@ -13,17 +13,17 @@ 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, {ComponentProps} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is,

View file

@ -25,6 +25,7 @@ import TextWithTooltip from "../elements/TextWithTooltip";
import Modal from "../../../Modal";
import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag";
interface IProps {
title?: string;
@ -66,7 +67,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
const info = SettingsStore.getBetaInfo(featureId);
if (!info) return null; // Beta is invalid/disabled
const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info;
const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading, extraSettings } = info;
const value = SettingsStore.getValue(featureId);
let feedbackButton;
@ -82,26 +83,33 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
}
return <div className="mx_BetaCard">
<div>
<h3 className="mx_BetaCard_title">
{ titleOverride || _t(title) }
<BetaPill />
</h3>
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
<div className="mx_BetaCard_columns">
<div>
{ feedbackButton }
<AccessibleButton
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
kind={feedbackButton ? "primary_outline" : "primary"}
>
{ value ? _t("Leave the beta") : _t("Join the beta") }
</AccessibleButton>
<h3 className="mx_BetaCard_title">
{ titleOverride || _t(title) }
<BetaPill />
</h3>
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
<div className="mx_BetaCard_buttons">
{ feedbackButton }
<AccessibleButton
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
kind={feedbackButton ? "primary_outline" : "primary"}
>
{ value ? _t("Leave the beta") : _t("Join the beta") }
</AccessibleButton>
</div>
{ disclaimer && <div className="mx_BetaCard_disclaimer">
{ disclaimer(value) }
</div> }
</div>
{ disclaimer && <div className="mx_BetaCard_disclaimer">
{ disclaimer(value) }
</div> }
<img src={image} alt="" />
</div>
<img src={image} alt="" />
{ extraSettings && <div className="mx_BetaCard_relatedSettings">
{ extraSettings.map(key => (
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
)) }
</div> }
</div>;
};

View file

@ -18,6 +18,7 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
import Dialpad from '../voip/DialPad';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@ -44,13 +45,21 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
this.setState({value: this.state.value + digit});
}
onChange = (ev) => {
this.setState({value: ev.target.value});
}
render() {
return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header">
<div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div>
<Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</div>
<div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">

View file

@ -33,7 +33,6 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
export function canCancel(eventStatus) {
@ -180,7 +179,7 @@ export default class MessageContextMenu extends React.Component {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [
...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids,
...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []),
eventId,
],
});
@ -201,7 +200,7 @@ export default class MessageContextMenu extends React.Component {
};
onQuoteClick = () => {
dis.dispatch<ComposerInsertPayload>({
dis.dispatch({
action: Action.ComposerInsert,
event: this.props.mxEvent,
});

View file

@ -23,45 +23,70 @@ import TagOrderActions from '../../../actions/TagOrderActions';
import {MenuItem} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func.isRequired,
};
static contextType = MatrixClientContext;
constructor() {
super();
this._onViewCommunityClick = this._onViewCommunityClick.bind(this);
this._onRemoveClick = this._onRemoveClick.bind(this);
}
_onViewCommunityClick() {
_onViewCommunityClick = () => {
dis.dispatch({
action: 'view_group',
group_id: this.props.tag,
});
this.props.onFinished();
}
};
_onRemoveClick() {
_onRemoveClick = () => {
dis.dispatch(TagOrderActions.removeTag(this.context, this.props.tag));
this.props.onFinished();
}
};
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
this.props.onFinished();
};
_onMoveDown = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index + 1));
this.props.onFinished();
};
render() {
let moveUp;
let moveDown;
if (this.props.index > 0) {
moveUp = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveUp" onClick={this._onMoveUp}>
{ _t("Move up") }
</MenuItem>
);
}
if (this.props.index < (GroupFilterOrderStore.getOrderedTags() || []).length - 1) {
moveDown = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveDown" onClick={this._onMoveDown}>
{ _t("Move down") }
</MenuItem>
);
}
return <div>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') }
</MenuItem>
{ (moveUp || moveDown) ? <hr className="mx_TagTileContextMenu_separator" role="separator" /> : null }
{ moveUp }
{ moveDown }
<hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t('Hide') }
{ _t("Unpin") }
</MenuItem>
</div>;
}

View file

@ -39,6 +39,9 @@ import ProgressBar from "../elements/ProgressBar";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -204,6 +207,17 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
setSelectedToAdd(new Set(selectedToAdd));
} : null;
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
@ -216,16 +230,21 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
) : undefined }

View file

@ -24,7 +24,7 @@ import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import { addressTypes, getAddressType } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient';

View file

@ -44,7 +44,12 @@ const BetaFeedbackDialog: React.FC<IProps> = ({featureId, onFinished}) => {
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact);
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
o[k] = SettingsStore.getValue(k);
return o;
}, {});
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {

View file

@ -38,7 +38,7 @@ interface IProps {
// be the string entered.
askReason?: boolean;
danger?: boolean;
onFinished: (success: boolean, reason?: HTMLInputElement) => void;
onFinished: (success: boolean, reason?: string) => void;
}
/*
@ -59,11 +59,7 @@ export default class ConfirmUserActionDialog extends React.Component<IProps> {
};
public onOk = (): void => {
let reason;
if (this.reasonField) {
reason = this.reasonField.current;
}
this.props.onFinished(true, reason);
this.props.onFinished(true, this.reasonField.current?.value);
};
public onCancel = (): void => {

View file

@ -766,7 +766,7 @@ class VerificationExplorer extends React.PureComponent<IExplorerProps> {
render() {
const cli = this.context;
const room = this.props.room;
const inRoomChannel = cli.crypto._inRoomVerificationRequests;
const inRoomChannel = cli.crypto.inRoomVerificationRequests;
const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map();
return (<div>

View file

@ -14,31 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useMemo, useState, useEffect} from "react";
import React, { useMemo, useState, useEffect } from "react";
import classnames from "classnames";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import {_t} from "../../../languageHandler";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import {useSettingValue, useFeatureEnabled} from "../../../hooks/useSettings";
import {UIFeature} from "../../../settings/UIFeature";
import {Layout} from "../../../settings/Layout";
import {IDialogProps} from "./IDialogProps";
import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings";
import { UIFeature } from "../../../settings/UIFeature";
import { Layout } from "../../../settings/Layout";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import {avatarUrlForUser} from "../../../Avatar";
import { avatarUrlForUser } from "../../../Avatar";
import EventTile from "../rooms/EventTile";
import SearchBox from "../../structures/SearchBox";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import {Alignment} from '../elements/Tooltip';
import { Alignment } from '../elements/Tooltip';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "../rooms/NotificationBadge";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
const AVATAR_SIZE = 30;
@ -166,12 +170,12 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
userId,
getAvatarUrl: (..._) => {
return avatarUrlForUser(
{ avatarUrl: profileInfo.avatar_url },
{avatarUrl: profileInfo.avatar_url},
AVATAR_SIZE, AVATAR_SIZE, "crop",
);
},
getMxcAvatarUrl: () => profileInfo.avatar_url,
};
} as RoomMember;
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
@ -195,6 +199,17 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}).match(lcQuery);
}
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
return <BaseDialog
title={_t("Forward message")}
className="mx_ForwardDialog"
@ -227,15 +242,20 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
<AutoHideScrollbar className="mx_ForwardList_content">
{ rooms.length > 0 ? (
<div className="mx_ForwardList_results">
{ rooms.map(room =>
<Entry
key={room.roomId}
room={room}
event={event}
matrixClient={cli}
onFinished={onFinished}
/>,
) }
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
event={event}
matrixClient={cli}
onFinished={onFinished}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
) : <span className="mx_ForwardList_noResults">
{ _t("No results") }

View file

@ -17,37 +17,45 @@ limitations under the License.
import React, { createRef } from 'react';
import classNames from 'classnames';
import {_t, _td} from "../../../languageHandler";
import { _t, _td } from "../../../languageHandler";
import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { makeRoomPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks";
import DMRoomMap from "../../../utils/DMRoomMap";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import SdkConfig from "../../../SdkConfig";
import * as Email from "../../../email";
import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils";
import {abbreviateUrl} from "../../../utils/UrlUtils";
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils";
import { abbreviateUrl } from "../../../utils/UrlUtils";
import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import { humanizeTime } from "../../../utils/humanize";
import createRoom, {
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted,
canEncryptToAllUsers,
ensureDMExists,
findDMForUser,
privateShouldBeEncrypted,
} from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
import {DefaultTagID} from "../../../stores/room-list/models";
import {
IInviteResult,
inviteMultipleToRoom,
showAnyInviteErrors,
showCommunityInviteDialog,
} from "../../../RoomInvite";
import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions";
import { DefaultTagID } from "../../../stores/room-list/models";
import RoomListStore from "../../../stores/room-list/RoomListStore";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import { UIFeature } from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress";
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 { compare } from '../../../utils/strings';
@ -74,10 +82,10 @@ export const KIND_CALL_TRANSFER = "call_transfer";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
// This is the interface that is expected by various components in this file. It is a bit
// awkward because it also matches the RoomMember class from the js-sdk with some extra support
// This is the interface that is expected by various components in the Invite Dialog and RoomInvite.
// It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses.
abstract class Member {
export abstract class Member {
/**
* The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email).
@ -102,7 +110,8 @@ class DirectoryMember extends Member {
private readonly displayName: string;
private readonly avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
// eslint-disable-next-line camelcase
constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) {
super();
this._userId = userDirResult.user_id;
this.displayName = userDirResult.display_name;
@ -601,19 +610,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return members.map(m => ({userId: m.member.userId, user: m.member}));
}
private shouldAbortAfterInviteError(result): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error');
if (failedUsers.length > 0) {
console.log("Failed to invite users: ", result);
this.setState({
busy: false,
errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", {
csvUsers: failedUsers.join(", "),
}),
});
return true; // abort
}
return false;
private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
this.setState({ busy: false });
const userMap = new Map<string, Member>(this.state.targets.map(member => [member.userId, member]));
return !showAnyInviteErrors(result.states, room, result.inviter, userMap);
}
private convertFilter(): Member[] {
@ -731,7 +731,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this.shouldAbortAfterInviteError(result)) { // handles setting error message too
if (!this.shouldAbortAfterInviteError(result, room)) { // handles setting error message too
this.props.onFinished();
}

View file

@ -63,7 +63,8 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = {
disabledButtonIds: [],
disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter(b => b.disabled)
.map(b => b.id),
};
constructor(props) {

View file

@ -1,149 +0,0 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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, {PureComponent} from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import PropTypes from "prop-types";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SdkConfig from '../../../SdkConfig';
import Markdown from '../../../Markdown';
import {replaceableComponent} from "../../../utils/replaceableComponent";
/*
* A dialog for reporting an event.
*/
@replaceableComponent("views.dialogs.ReportEventDialog")
export default class ReportEventDialog extends PureComponent {
static propTypes = {
mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired,
onFinished: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
reason: "",
busy: false,
err: null,
};
}
_onReasonChange = ({target: {value: reason}}) => {
this.setState({ reason });
};
_onCancel = () => {
this.props.onFinished(false);
};
_onSubmit = async () => {
if (!this.state.reason || !this.state.reason.trim()) {
this.setState({
err: _t("Please fill why you're reporting."),
});
return;
}
this.setState({
busy: true,
err: null,
});
try {
const ev = this.props.mxEvent;
await MatrixClientPeg.get().reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim());
this.props.onFinished(true);
} catch (e) {
this.setState({
busy: false,
err: e.message,
});
}
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Loader = sdk.getComponent('elements.Spinner');
const Field = sdk.getComponent('elements.Field');
let error = null;
if (this.state.err) {
error = <div className="error">
{this.state.err}
</div>;
}
let progress = null;
if (this.state.busy) {
progress = (
<div className="progress">
<Loader />
</div>
);
}
const adminMessageMD =
SdkConfig.get().reportEvent &&
SdkConfig.get().reportEvent.adminMessageMD;
let adminMessage;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
return (
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.props.onFinished}
title={_t('Report Content to Your Homeserver Administrator')}
contentId='mx_ReportEventDialog'
>
<div className="mx_ReportEventDialog" id="mx_ReportEventDialog">
<p>
{
_t("Reporting this message will send its unique 'event ID' to the administrator of " +
"your homeserver. If messages in this room are encrypted, your homeserver " +
"administrator will not be able to read the message text or view any files or images.")
}
</p>
{adminMessage}
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("Reason")}
rows={5}
onChange={this._onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("Send report")}
onPrimaryButtonClick={this._onSubmit}
focus={true}
onCancel={this._onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,445 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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 * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { ensureDMExists } from "../../../createRoom";
import { IDialogProps } from "./IDialogProps";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import SdkConfig from '../../../SdkConfig';
import Markdown from '../../../Markdown';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
interface IProps extends IDialogProps {
mxEvent: MatrixEvent;
}
interface IState {
// A free-form text describing the abuse.
reason: string;
busy: boolean;
err?: string;
// If we know it, the nature of the abuse, as specified by MSC3215.
nature?: EXTENDED_NATURE;
}
const MODERATED_BY_STATE_EVENT_TYPE = [
"org.matrix.msc3215.room.moderation.moderated_by",
/**
* Unprefixed state event. Not ready for prime time.
*
* "m.room.moderation.moderated_by"
*/
];
const ABUSE_EVENT_TYPE = "org.matrix.msc3215.abuse.report";
// Standard abuse natures.
enum NATURE {
DISAGREEMENT = "org.matrix.msc3215.abuse.nature.disagreement",
TOXIC = "org.matrix.msc3215.abuse.nature.toxic",
ILLEGAL = "org.matrix.msc3215.abuse.nature.illegal",
SPAM = "org.matrix.msc3215.abuse.nature.spam",
OTHER = "org.matrix.msc3215.abuse.nature.other",
}
enum NON_STANDARD_NATURE {
// Non-standard abuse nature.
// It should never leave the client - we use it to fallback to
// server-wide abuse reporting.
ADMIN = "non-standard.abuse.nature.admin"
}
type EXTENDED_NATURE = NATURE | NON_STANDARD_NATURE;
type Moderation = {
// The id of the moderation room.
moderationRoomId: string;
// The id of the bot in charge of forwarding abuse reports to the moderation room.
moderationBotUserId: string;
}
/*
* A dialog for reporting an event.
*
* The actual content of the dialog will depend on two things:
*
* 1. Is `feature_report_to_moderators` enabled?
* 2. Does the room support moderation as per MSC3215, i.e. is there
* a well-formed state event `m.room.moderation.moderated_by`
* /`org.matrix.msc3215.room.moderation.moderated_by`?
*/
@replaceableComponent("views.dialogs.ReportEventDialog")
export default class ReportEventDialog extends React.Component<IProps, IState> {
// If the room supports moderation, the moderation information.
private moderation?: Moderation;
constructor(props: IProps) {
super(props);
let moderatedByRoomId = null;
let moderatedByUserId = null;
if (SettingsStore.getValue("feature_report_to_moderators")) {
// The client supports reporting to moderators.
// Does the room support it, too?
// Extract state events to determine whether we should display
const client = MatrixClientPeg.get();
const room = client.getRoom(props.mxEvent.getRoomId());
for (const stateEventType of MODERATED_BY_STATE_EVENT_TYPE) {
const stateEvent = room.currentState.getStateEvents(stateEventType, stateEventType);
if (!stateEvent) {
continue;
}
if (Array.isArray(stateEvent)) {
// Internal error.
throw new TypeError(`getStateEvents(${stateEventType}, ${stateEventType}) ` +
"should return at most one state event");
}
const event = stateEvent.event;
if (!("content" in event) || typeof event["content"] != "object") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug("Moderation error", "state event", stateEventType,
"should have an object field `content`, got", event);
continue;
}
const content = event["content"];
if (!("room_id" in content) || typeof content["room_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug("Moderation error", "state event", stateEventType,
"should have a string field `content.room_id`, got", event);
continue;
}
if (!("user_id" in content) || typeof content["user_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug("Moderation error", "state event", stateEventType,
"should have a string field `content.user_id`, got", event);
continue;
}
moderatedByRoomId = content["room_id"];
moderatedByUserId = content["user_id"];
}
if (moderatedByRoomId && moderatedByUserId) {
// The room supports moderation.
this.moderation = {
moderationRoomId: moderatedByRoomId,
moderationBotUserId: moderatedByUserId,
};
}
}
this.state = {
// A free-form text describing the abuse.
reason: "",
busy: false,
err: null,
// If specified, the nature of the abuse, as specified by MSC3215.
nature: null,
};
}
// The user has written down a freeform description of the abuse.
private onReasonChange = ({target: {value: reason}}): void => {
this.setState({ reason });
};
// The user has clicked on a nature.
private onNatureChosen = (e: React.FormEvent<HTMLInputElement>): void => {
this.setState({ nature: e.currentTarget.value as EXTENDED_NATURE});
};
// The user has clicked "cancel".
private onCancel = (): void => {
this.props.onFinished(false);
};
// The user has clicked "submit".
private onSubmit = async () => {
let reason = this.state.reason || "";
reason = reason.trim();
if (this.moderation) {
// This room supports moderation.
// We need a nature.
// If the nature is `NATURE.OTHER` or `NON_STANDARD_NATURE.ADMIN`, we also need a `reason`.
if (!this.state.nature ||
((this.state.nature == NATURE.OTHER || this.state.nature == NON_STANDARD_NATURE.ADMIN)
&& !reason)
) {
this.setState({
err: _t("Please fill why you're reporting."),
});
return;
}
} else {
// This room does not support moderation.
// We need a `reason`.
if (!reason) {
this.setState({
err: _t("Please fill why you're reporting."),
});
return;
}
}
this.setState({
busy: true,
err: null,
});
try {
const client = MatrixClientPeg.get();
const ev = this.props.mxEvent;
if (this.moderation && this.state.nature != NON_STANDARD_NATURE.ADMIN) {
const nature: NATURE = this.state.nature;
// Report to moderators through to the dedicated bot,
// as configured in the room's state events.
const dmRoomId = await ensureDMExists(client, this.moderation.moderationBotUserId);
await client.sendEvent(dmRoomId, ABUSE_EVENT_TYPE, {
event_id: ev.getId(),
room_id: ev.getRoomId(),
moderated_by_id: this.moderation.moderationRoomId,
nature,
reporter: client.getUserId(),
comment: this.state.reason.trim(),
});
} else {
// Report to homeserver admin through the dedicated Matrix API.
await client.reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim());
}
this.props.onFinished(true);
} catch (e) {
this.setState({
busy: false,
err: e.message,
});
}
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Loader = sdk.getComponent('elements.Spinner');
const Field = sdk.getComponent('elements.Field');
let error = null;
if (this.state.err) {
error = <div className="error">
{this.state.err}
</div>;
}
let progress = null;
if (this.state.busy) {
progress = (
<div className="progress">
<Loader />
</div>
);
}
const adminMessageMD =
SdkConfig.get().reportEvent &&
SdkConfig.get().reportEvent.adminMessageMD;
let adminMessage;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
if (this.moderation) {
// Display report-to-moderator dialog.
// We let the user pick a nature.
const client = MatrixClientPeg.get();
const homeServerName = SdkConfig.get()["validated_server_config"].hsName;
let subtitle;
switch (this.state.nature) {
case NATURE.DISAGREEMENT:
subtitle = _t("What this user is writing is wrong.\n" +
"This will be reported to the room moderators.");
break;
case NATURE.TOXIC:
subtitle = _t("This user is displaying toxic behaviour, " +
"for instance by insulting other users or sharing " +
" adult-only content in a family-friendly room " +
" or otherwise violating the rules of this room.\n" +
"This will be reported to the room moderators.");
break;
case NATURE.ILLEGAL:
subtitle = _t("This user is displaying illegal behaviour, " +
"for instance by doxing people or threatening violence.\n" +
"This will be reported to the room moderators who may escalate this to legal authorities.");
break;
case NATURE.SPAM:
subtitle = _t("This user is spamming the room with ads, links to ads or to propaganda.\n" +
"This will be reported to the room moderators.");
break;
case NON_STANDARD_NATURE.ADMIN:
if (client.isRoomEncrypted(this.props.mxEvent.getRoomId())) {
subtitle = _t("This room is dedicated to illegal or toxic content " +
"or the moderators fail to moderate illegal or toxic content.\n" +
"This will be reported to the administrators of %(homeserver)s. " +
"The administrators will NOT be able to read the encrypted content of this room.",
{ homeserver: homeServerName });
} else {
subtitle = _t("This room is dedicated to illegal or toxic content " +
"or the moderators fail to moderate illegal or toxic content.\n" +
" This will be reported to the administrators of %(homeserver)s.",
{ homeserver: homeServerName });
}
break;
case NATURE.OTHER:
subtitle = _t("Any other reason. Please describe the problem.\n" +
"This will be reported to the room moderators.");
break;
default:
subtitle = _t("Please pick a nature and describe what makes this message abusive.");
break;
}
return (
<BaseDialog
className="mx_ReportEventDialog"
onFinished={this.props.onFinished}
title={_t('Report Content')}
contentId='mx_ReportEventDialog'
>
<div>
<StyledRadioButton
name = "nature"
value = { NATURE.DISAGREEMENT }
checked = { this.state.nature == NATURE.DISAGREEMENT }
onChange = { this.onNatureChosen }
>
{_t('Disagree')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.TOXIC }
checked = { this.state.nature == NATURE.TOXIC }
onChange = { this.onNatureChosen }
>
{_t('Toxic Behaviour')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.ILLEGAL }
checked = { this.state.nature == NATURE.ILLEGAL }
onChange = { this.onNatureChosen }
>
{_t('Illegal Content')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.SPAM }
checked = { this.state.nature == NATURE.SPAM }
onChange = { this.onNatureChosen }
>
{_t('Spam or propaganda')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NON_STANDARD_NATURE.ADMIN }
checked = { this.state.nature == NON_STANDARD_NATURE.ADMIN }
onChange = { this.onNatureChosen }
>
{_t('Report the entire room')}
</StyledRadioButton>
<StyledRadioButton
name = "nature"
value = { NATURE.OTHER }
checked = { this.state.nature == NATURE.OTHER }
onChange = { this.onNatureChosen }
>
{_t('Other')}
</StyledRadioButton>
<p>
{subtitle}
</p>
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("Reason")}
rows={5}
onChange={this.onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("Send report")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
// Report to homeserver admin.
// Currently, the API does not support natures.
return (
<BaseDialog
className="mx_ReportEventDialog"
onFinished={this.props.onFinished}
title={_t('Report Content to Your Homeserver Administrator')}
contentId='mx_ReportEventDialog'
>
<div className="mx_ReportEventDialog" id="mx_ReportEventDialog">
<p>
{
_t("Reporting this message will send its unique 'event ID' to the administrator of " +
"your homeserver. If messages in this room are encrypted, your homeserver " +
"administrator will not be able to read the message text or view any files " +
"or images.")
}
</p>
{adminMessage}
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("Reason")}
rows={5}
onChange={this.onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("Send report")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -108,7 +108,10 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
ROOM_ADVANCED_TAB,
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
<AdvancedRoomSettingsTab
roomId={this.props.roomId}
closeSettingsFn={() => this.props.onFinished(true)}
/>,
));
}

View file

@ -14,24 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from 'react';
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType} from "matrix-js-sdk/src/@types/event";
import React, { useMemo } from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {_t} from '../../../languageHandler';
import {IDialogProps} from "./IDialogProps";
import { _t, _td } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DevtoolsDialog from "./DevtoolsDialog";
import SpaceBasicSettings from '../spaces/SpaceBasicSettings';
import {getTopic} from "../elements/RoomTopic";
import {avatarUrlForRoom} from "../../../Avatar";
import ToggleSwitch from "../elements/ToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {useDispatcher} from "../../../hooks/useDispatcher";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import { useDispatcher } from "../../../hooks/useDispatcher";
import TabbedView, { Tab } from "../../structures/TabbedView";
import SpaceSettingsGeneralTab from '../spaces/SpaceSettingsGeneralTab';
import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
export enum SpaceSettingsTab {
General = "SPACE_GENERAL_TAB",
Visibility = "SPACE_VISIBILITY_TAB",
Advanced = "SPACE_ADVANCED_TAB",
}
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -45,63 +48,30 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
}
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const userId = cli.getUserId();
const [newAvatar, setNewAvatar] = useState<File>(null); // undefined means to remove avatar
const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId);
const avatarChanged = newAvatar !== null;
const [name, setName] = useState<string>(space.name);
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
const nameChanged = name !== space.name;
const currentTopic = getTopic(space);
const [topic, setTopic] = useState<string>(currentTopic);
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
const topicChanged = topic !== currentTopic;
const currentJoinRule = space.getJoinRule();
const [joinRule, setJoinRule] = useState(currentJoinRule);
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
const joinRuleChanged = joinRule !== currentJoinRule;
const onSave = async () => {
setBusy(true);
const promises = [];
if (avatarChanged) {
if (newAvatar) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
}
if (nameChanged) {
promises.push(cli.setRoomName(space.roomId, name));
}
if (topicChanged) {
promises.push(cli.setRoomTopic(space.roomId, topic));
}
if (joinRuleChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, ""));
}
const results = await Promise.allSettled(promises);
setBusy(false);
const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) {
console.error("Failed to save space settings: ", failures);
setError(_t("Failed to save space settings."));
}
};
const tabs = useMemo(() => {
return [
new Tab(
SpaceSettingsTab.General,
_td("General"),
"mx_SpaceSettingsDialog_generalIcon",
<SpaceSettingsGeneralTab matrixClient={cli} space={space} onFinished={onFinished} />,
),
new Tab(
SpaceSettingsTab.Visibility,
_td("Visibility"),
"mx_SpaceSettingsDialog_visibilityIcon",
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} />,
),
SettingsStore.getValue(UIFeature.AdvancedSettings)
? new Tab(
SpaceSettingsTab.Advanced,
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={space.roomId} closeSettingsFn={onFinished} />,
)
: null,
].filter(Boolean);
}, [cli, space, onFinished]);
return <BaseDialog
title={_t("Space settings")}
@ -110,61 +80,14 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
onFinished={onFinished}
fixedWidth={false}
>
<div className="mx_SpaceSettingsDialog_content" id="mx_SpaceSettingsDialog">
<div>{ _t("Edit settings relating to your space.") }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
avatarDisabled={busy || !canSetAvatar}
setAvatar={setNewAvatar}
name={name}
nameDisabled={busy || !canSetName}
setName={setName}
topic={topic}
topicDisabled={busy || !canSetTopic}
setTopic={setTopic}
/>
<div>
{ _t("Make this space private") }
<ToggleSwitch
checked={joinRule !== "public"}
onChange={checked => setJoinRule(checked ? "invite" : "public")}
disabled={!canSetJoinRule}
aria-label={_t("Make this space private")}
/>
</div>
<AccessibleButton
kind="danger"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave Space") }
</AccessibleButton>
<div className="mx_SpaceSettingsDialog_buttons">
<AccessibleButton onClick={() => Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}>
{ _t("View dev tools") }
</AccessibleButton>
<AccessibleButton onClick={onFinished} disabled={busy} kind="link">
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton onClick={onSave} disabled={busy} kind="primary">
{ busy ? _t("Saving...") : _t("Save Changes") }
</AccessibleButton>
</div>
<div
className="mx_SpaceSettingsDialog_content"
id="mx_SpaceSettingsDialog"
title={_t("Settings - %(spaceName)s", { spaceName: space.name })}
>
<TabbedView tabs={tabs} />
</div>
</BaseDialog>;
};
export default SpaceSettingsDialog;

View file

@ -62,6 +62,8 @@ export default function AccessibleButton({
disabled,
inputRef,
className,
onKeyDown,
onKeyUp,
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
@ -83,6 +85,8 @@ export default function AccessibleButton({
if (e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyDown?.(e);
}
};
newProps.onKeyUp = (e) => {
@ -94,6 +98,8 @@ export default function AccessibleButton({
if (e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyUp?.(e);
}
};
}

View file

@ -20,9 +20,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress.js';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import { UserAddressType } from '../../../UserAddress';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
@replaceableComponent("views.elements.AddressTile")
export default class AddressTile extends React.Component {

View file

@ -18,7 +18,6 @@ limitations under the License.
import TagTile from './TagTile';
import React from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu";
import * as sdk from '../../../index';
@ -31,32 +30,17 @@ export default function DNDTagTile(props) {
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
contextMenu = (
<ContextMenu {...toRightOf(elementRect)} onFinished={closeMenu}>
<TagTileContextMenu tag={props.tag} onFinished={closeMenu} />
<TagTileContextMenu tag={props.tag} onFinished={closeMenu} index={props.index} />
</ContextMenu>
);
}
return <div>
<Draggable
key={props.tag}
draggableId={props.tag}
index={props.index}
type="draggable-TagTile"
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<TagTile
{...props}
contextMenuButtonRef={handle}
menuDisplayed={menuDisplayed}
openMenu={openMenu}
/>
</div>
)}
</Draggable>
return <>
<TagTile
{...props}
contextMenuButtonRef={handle}
menuDisplayed={menuDisplayed}
openMenu={openMenu}
/>
{contextMenu}
</div>;
</>;
}

View file

@ -14,10 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import EventIndexPeg from "../../../indexing/EventIndexPeg";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import React from "react";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserSettingsDialog";
export enum WarningKind {
Files,
@ -33,6 +37,22 @@ export default function DesktopBuildsNotice({isRoomEncrypted, kind}: IProps) {
if (!isRoomEncrypted) return null;
if (EventIndexPeg.get()) return null;
if (EventIndexPeg.error) {
return <>
{_t("Message search initialisation failed, check <a>your settings</a> for more information", {}, {
a: sub => (<a onClick={(evt) => {
evt.preventDefault();
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
});
}}>
{sub}
</a>),
})}
</>;
}
const {desktopBuilds, brand} = SdkConfig.get();
let text = null;

View file

@ -1,5 +1,5 @@
/*
Copyright 2017, 2019 New Vector Ltd.
Copyright 2017-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.
@ -14,48 +14,48 @@ 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';
import React from "react";
import { _t } from '../../../languageHandler';
import Field from "./Field";
import AccessibleButton from "./AccessibleButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export class EditableItem extends React.Component {
static propTypes = {
index: PropTypes.number,
value: PropTypes.string,
onRemove: PropTypes.func,
interface IItemProps {
index?: number;
value?: string;
onRemove?(index: number): void;
}
interface IItemState {
verifyRemove: boolean;
}
export class EditableItem extends React.Component<IItemProps, IItemState> {
public state = {
verifyRemove: false,
};
constructor() {
super();
this.state = {
verifyRemove: false,
};
}
_onRemove = (e) => {
private onRemove = (e) => {
e.stopPropagation();
e.preventDefault();
this.setState({verifyRemove: true});
this.setState({ verifyRemove: true });
};
_onDontRemove = (e) => {
private onDontRemove = (e) => {
e.stopPropagation();
e.preventDefault();
this.setState({verifyRemove: false});
this.setState({ verifyRemove: false });
};
_onActuallyRemove = (e) => {
private onActuallyRemove = (e) => {
e.stopPropagation();
e.preventDefault();
if (this.props.onRemove) this.props.onRemove(this.props.index);
this.setState({verifyRemove: false});
this.setState({ verifyRemove: false });
};
render() {
@ -66,14 +66,14 @@ export class EditableItem extends React.Component {
{_t("Are you sure?")}
</span>
<AccessibleButton
onClick={this._onActuallyRemove}
onClick={this.onActuallyRemove}
kind="primary_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("Yes")}
</AccessibleButton>
<AccessibleButton
onClick={this._onDontRemove}
onClick={this.onDontRemove}
kind="danger_sm"
className="mx_EditableItem_confirmBtn"
>
@ -85,59 +85,68 @@ export class EditableItem extends React.Component {
return (
<div className="mx_EditableItem">
<div onClick={this._onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" />
<div onClick={this.onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" />
<span className="mx_EditableItem_item">{this.props.value}</span>
</div>
);
}
}
interface IProps {
id: string;
items: string[];
itemsLabel?: string;
noItemsLabel?: string;
placeholder?: string;
newItem?: string;
canEdit?: boolean;
canRemove?: boolean;
suggestionsListId?: string;
onItemAdded?(item: string): void;
onItemRemoved?(index: number): void;
onNewItemChanged?(item: string): void;
}
@replaceableComponent("views.elements.EditableItemList")
export default class EditableItemList extends React.Component {
static propTypes = {
id: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.string).isRequired,
itemsLabel: PropTypes.string,
noItemsLabel: PropTypes.string,
placeholder: PropTypes.string,
newItem: PropTypes.string,
onItemAdded: PropTypes.func,
onItemRemoved: PropTypes.func,
onNewItemChanged: PropTypes.func,
canEdit: PropTypes.bool,
canRemove: PropTypes.bool,
};
_onItemAdded = (e) => {
export default class EditableItemList<P = {}> extends React.PureComponent<IProps & P> {
protected onItemAdded = (e) => {
e.stopPropagation();
e.preventDefault();
if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem);
};
_onItemRemoved = (index) => {
protected onItemRemoved = (index) => {
if (this.props.onItemRemoved) this.props.onItemRemoved(index);
};
_onNewItemChanged = (e) => {
protected onNewItemChanged = (e) => {
if (this.props.onNewItemChanged) this.props.onNewItemChanged(e.target.value);
};
_renderNewItemField() {
protected renderNewItemField() {
return (
<form
onSubmit={this._onItemAdded}
onSubmit={this.onItemAdded}
autoComplete="off"
noValidate={true}
className="mx_EditableItemList_newItem"
>
<Field label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
{_t("Add")}
<Field
label={this.props.placeholder}
type="text"
autoComplete="off"
value={this.props.newItem || ""}
onChange={this.onNewItemChanged}
list={this.props.suggestionsListId}
/>
<AccessibleButton
onClick={this.onItemAdded}
kind="primary"
type="submit"
disabled={!this.props.newItem}
>
{ _t("Add") }
</AccessibleButton>
</form>
);
@ -153,19 +162,21 @@ export default class EditableItemList extends React.Component {
key={item}
index={index}
value={item}
onRemove={this._onItemRemoved}
onRemove={this.onItemRemoved}
/>;
});
const editableItemsSection = this.props.canRemove ? editableItems : <ul>{editableItems}</ul>;
const label = this.props.items.length > 0 ? this.props.itemsLabel : this.props.noItemsLabel;
return (<div className="mx_EditableItemList">
<div className="mx_EditableItemList_label">
{ label }
return (
<div className="mx_EditableItemList">
<div className="mx_EditableItemList_label">
{ label }
</div>
{ editableItemsSection }
{ this.props.canEdit ? this.renderNewItemField() : <div /> }
</div>
{ editableItemsSection }
{ this.props.canEdit ? this._renderNewItemField() : <div /> }
</div>);
);
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
@ -14,21 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import React, { ErrorInfo } from 'react';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg';
import Modal from '../../../Modal';
import SdkConfig from "../../../SdkConfig";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import BugReportDialog from '../dialogs/BugReportDialog';
import AccessibleButton from './AccessibleButton';
interface IState {
error: Error;
}
/**
* This error boundary component can be used to wrap large content areas and
* catch exceptions during rendering in the component tree below them.
*/
@replaceableComponent("views.elements.ErrorBoundary")
export default class ErrorBoundary extends React.PureComponent {
export default class ErrorBoundary extends React.PureComponent<{}, IState> {
constructor(props) {
super(props);
@ -37,13 +43,13 @@ export default class ErrorBoundary extends React.PureComponent {
};
}
static getDerivedStateFromError(error) {
static getDerivedStateFromError(error: Error): Partial<IState> {
// Side effects are not permitted here, so we only update the state so
// that the next render shows an error message.
return { error };
}
componentDidCatch(error, { componentStack }) {
componentDidCatch(error: Error, { componentStack }: ErrorInfo): void {
// Browser consoles are better at formatting output when native errors are passed
// in their own `console.error` invocation.
console.error(error);
@ -53,7 +59,7 @@ export default class ErrorBoundary extends React.PureComponent {
);
}
_onClearCacheAndReload = () => {
private onClearCacheAndReload = (): void => {
if (!PlatformPeg.get()) return;
MatrixClientPeg.get().stopClient();
@ -62,11 +68,7 @@ export default class ErrorBoundary extends React.PureComponent {
});
};
_onBugReport = () => {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
if (!BugReportDialog) {
return;
}
private onBugReport = (): void => {
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
label: 'react-soft-crash',
});
@ -74,7 +76,6 @@ export default class ErrorBoundary extends React.PureComponent {
render() {
if (this.state.error) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new";
let bugReportSection;
@ -95,7 +96,7 @@ export default class ErrorBoundary extends React.PureComponent {
"the rooms or groups you have visited and the usernames of " +
"other users. They do not contain messages.",
)}</p>
<AccessibleButton onClick={this._onBugReport} kind='primary'>
<AccessibleButton onClick={this.onBugReport} kind='primary'>
{_t("Submit debug logs")}
</AccessibleButton>
</React.Fragment>;
@ -105,7 +106,7 @@ export default class ErrorBoundary extends React.PureComponent {
<div className="mx_ErrorBoundary_body">
<h1>{_t("Something went wrong!")}</h1>
{ bugReportSection }
<AccessibleButton onClick={this._onClearCacheAndReload} kind='danger'>
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{_t("Clear cache and reload")}
</AccessibleButton>
</div>

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ReactChildren, useEffect} from 'react';
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import React, { ReactNode, useEffect } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import {useStateToggle} from "../../../hooks/useStateToggle";
import { useStateToggle } from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton";
interface IProps {
@ -31,11 +31,11 @@ interface IProps {
// Whether or not to begin with state.expanded=true
startExpanded?: boolean,
// The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[],
summaryMembers?: RoomMember[];
// The text to show as the summary of this event list
summaryText?: string,
summaryText?: string;
// An array of EventTiles to render when expanded
children: ReactChildren,
children: ReactNode[];
// Called when the event list expansion is toggled
onToggle?(): void;
}

View file

@ -17,13 +17,14 @@ limitations under the License.
import React from 'react';
import classnames from 'classnames';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import * as Avatar from '../../../Avatar';
import EventTile from '../rooms/EventTile';
import SettingsStore from "../../../settings/SettingsStore";
import {Layout} from "../../../settings/Layout";
import {UIFeature} from "../../../settings/UIFeature";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Layout } from "../../../settings/Layout";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
/**
@ -101,16 +102,17 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
// Fake it more
event.sender = {
name: this.props.displayName,
name: this.props.displayName || this.props.userId,
rawDisplayName: this.props.displayName,
userId: this.props.userId,
getAvatarUrl: (..._) => {
return Avatar.avatarUrlForUser(
{ avatarUrl: this.props.avatarUrl },
{avatarUrl: this.props.avatarUrl},
AVATAR_SIZE, AVATAR_SIZE, "crop",
);
},
getMxcAvatarUrl: () => this.props.avatarUrl,
};
} as RoomMember;
return event;
}

View file

@ -29,6 +29,11 @@ function getId() {
return `${BASE_ID}_${count++}`;
}
export interface IValidateOpts {
focused?: boolean;
allowEmpty?: boolean;
}
interface IProps {
// The field's ID, which binds the input and label together. Immutable.
id?: string;
@ -180,7 +185,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
}
};
public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) {
public async validate({ focused, allowEmpty = true }: IValidateOpts) {
if (!this.props.onValidate) {
return;
}

View file

@ -1,28 +0,0 @@
/*
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 AccessibleButton from "./AccessibleButton";
export default function FormButton(props) {
const {className, label, kind, ...restProps} = props;
const newClassName = (className || "") + " mx_FormButton";
const allProps = Object.assign({}, restProps,
{className: newClassName, kind: kind || "primary", children: [label]});
return React.createElement(AccessibleButton, allProps);
}
FormButton.propTypes = AccessibleButton.propTypes;

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.
@ -14,38 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from "prop-types";
import React from "react";
import ToggleSwitch from "./ToggleSwitch";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
// The value for the toggle switch
value: boolean;
// The translated label for the switch
label: string;
// Whether or not to disable the toggle switch
disabled?: boolean;
// True to put the toggle in front of the label
// Default false.
toggleInFront?: boolean;
// Additional class names to append to the switch. Optional.
className?: string;
// The function to call when the value changes
onChange(checked: boolean): void;
}
@replaceableComponent("views.elements.LabelledToggleSwitch")
export default class LabelledToggleSwitch extends React.Component {
static propTypes = {
// The value for the toggle switch
value: PropTypes.bool.isRequired,
// The function to call when the value changes
onChange: PropTypes.func.isRequired,
// The translated label for the switch
label: PropTypes.string.isRequired,
// Whether or not to disable the toggle switch
disabled: PropTypes.bool,
// True to put the toggle in front of the label
// Default false.
toggleInFront: PropTypes.bool,
// Additional class names to append to the switch. Optional.
className: PropTypes.string,
};
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
render() {
// This is a minimal version of a SettingsFlag
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
let firstPart = <span className="mx_SettingsFlag_label">{ this.props.label }</span>;
let secondPart = <ToggleSwitch
checked={this.props.value}
disabled={this.props.disabled}

View file

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactChildren } from 'react';
import React, { ComponentProps } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@ -26,21 +26,11 @@ import { isValid3pidInvite } from "../../../RoomInvite";
import EventListSummary from "./EventListSummary";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
// An array of member events to summarise
events: MatrixEvent[];
interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength?: number;
// The maximum number of avatars to display in the summary
avatarsMaxLength?: number;
// The minimum number of events needed to trigger summarisation
threshold?: number,
// Whether or not to begin with state.expanded=true
startExpanded?: boolean,
// An array of EventTiles to render when expanded
children: ReactChildren;
// Called when the MELS expansion is toggled
onToggle?(): void,
}
interface IUserEvents {

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.
@ -13,67 +13,78 @@ 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, { createRef } from "react";
import { _t } from '../../../languageHandler';
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import withValidation from './Validation';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field, { IValidateOpts } from "./Field";
interface IProps {
domain: string;
value: string;
label?: string;
placeholder?: string;
onChange?(value: string): void;
}
interface IState {
isValid: boolean;
}
// Controlled form component wrapping Field for inputting a room alias scoped to a given domain
@replaceableComponent("views.elements.RoomAliasField")
export default class RoomAliasField extends React.PureComponent {
static propTypes = {
domain: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string.isRequired,
};
export default class RoomAliasField extends React.PureComponent<IProps, IState> {
private fieldRef = createRef<Field>();
constructor(props) {
super(props);
this.state = {isValid: true};
constructor(props, context) {
super(props, context);
this.state = {
isValid: true,
};
}
_asFullAlias(localpart) {
private asFullAlias(localpart: string): string {
return `#${localpart}:${this.props.domain}`;
}
render() {
const Field = sdk.getComponent('views.elements.Field');
const poundSign = (<span>#</span>);
const aliasPostfix = ":" + this.props.domain;
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
return (
<Field
label={_t("Room address")}
label={this.props.label || _t("Room address")}
className="mx_RoomAliasField"
prefixComponent={poundSign}
postfixComponent={domain}
ref={ref => this._fieldRef = ref}
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
ref={this.fieldRef}
onValidate={this.onValidate}
placeholder={this.props.placeholder || _t("e.g. my-room")}
onChange={this.onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength}
/>
);
}
_onChange = (ev) => {
private onChange = (ev) => {
if (this.props.onChange) {
this.props.onChange(this._asFullAlias(ev.target.value));
this.props.onChange(this.asFullAlias(ev.target.value));
}
};
_onValidate = async (fieldState) => {
const result = await this._validationRules(fieldState);
private onValidate = async (fieldState) => {
const result = await this.validationRules(fieldState);
this.setState({isValid: result.valid});
return result;
};
_validationRules = withValidation({
private validationRules = withValidation({
rules: [
{
key: "safeLocalpart",
@ -81,7 +92,7 @@ export default class RoomAliasField extends React.PureComponent {
if (!value) {
return true;
}
const fullAlias = this._asFullAlias(value);
const fullAlias = this.asFullAlias(value);
// XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668
return !value.includes("#") && !value.includes(":") && !value.includes(",") &&
encodeURI(fullAlias) === fullAlias;
@ -90,7 +101,7 @@ export default class RoomAliasField extends React.PureComponent {
}, {
key: "required",
test: async ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Please provide a room address"),
invalid: () => _t("Please provide an address"),
}, {
key: "taken",
final: true,
@ -100,7 +111,7 @@ export default class RoomAliasField extends React.PureComponent {
}
const client = MatrixClientPeg.get();
try {
await client.getRoomIdForAlias(this._asFullAlias(value));
await client.getRoomIdForAlias(this.asFullAlias(value));
// we got a room id, so the alias is taken
return false;
} catch (err) {
@ -116,15 +127,15 @@ export default class RoomAliasField extends React.PureComponent {
],
});
get isValid() {
public get isValid() {
return this.state.isValid;
}
validate(options) {
return this._fieldRef.validate(options);
public validate(options: IValidateOpts) {
return this.fieldRef.current?.validate(options);
}
focus() {
this._fieldRef.focus();
public focus() {
this.fieldRef.current?.focus();
}
}

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {useEffect, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import React, { useEffect, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
interface IProps {
room: Room;
@ -34,7 +34,7 @@ const RoomName = ({ room, children }: IProps): JSX.Element => {
}, [room]);
if (children) return children(name);
return name || "";
return <>{ name || "" }</>;
};
export default RoomName;

View file

@ -77,9 +77,10 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
public render() {
const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level);
let label = this.props.label;
if (!label) label = SettingsStore.getDisplayName(this.props.name, this.props.level);
else label = _t(label);
const label = this.props.label
? _t(this.props.label)
: SettingsStore.getDisplayName(this.props.name, this.props.level);
const description = SettingsStore.getDescription(this.props.name);
if (this.props.useCheckbox) {
return <StyledCheckbox
@ -99,6 +100,9 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
disabled={this.props.disabled || !canChange}
aria-label={label}
/>
{ description && <div className="mx_SettingsFlag_microcopy">
{ description }
</div> }
</div>
);
}

View file

@ -34,10 +34,19 @@ interface IProps<T extends string> {
definitions: IDefinition<T>[];
value?: T; // if not provided no options will be selected
outlined?: boolean;
disabled?: boolean;
onChange(newValue: T): void;
}
function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) {
function StyledRadioGroup<T extends string>({
name,
definitions,
value,
className,
outlined,
disabled,
onChange,
}: IProps<T>) {
const _onChange = e => {
onChange(e.target.value);
};
@ -50,12 +59,12 @@ function StyledRadioGroup<T extends string>({name, definitions, value, className
checked={d.checked !== undefined ? d.checked : d.value === value}
name={name}
value={d.value}
disabled={d.disabled}
disabled={disabled || d.disabled}
outlined={outlined}
>
{d.label}
{ d.label }
</StyledRadioButton>
{d.description}
{ d.description ? <span>{ d.description }</span> : null }
</React.Fragment>)}
</React.Fragment>;
}

View file

@ -16,31 +16,29 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.TruncatedList")
export default class TruncatedList extends React.Component {
static propTypes = {
// The number of elements to show before truncating. If negative, no truncation is done.
truncateAt: PropTypes.number,
// The className to apply to the wrapping div
className: PropTypes.string,
// A function that returns the children to be rendered into the element.
// function getChildren(start: number, end: number): Array<React.Node>
// The start element is included, the end is not (as in `slice`).
// If omitted, the React child elements will be used. This parameter can be used
// to avoid creating unnecessary React elements.
getChildren: PropTypes.func,
// A function that should return the total number of child element available.
// Required if getChildren is supplied.
getChildCount: PropTypes.func,
// A function which will be invoked when an overflow element is required.
// This will be inserted after the children.
createOverflowElement: PropTypes.func,
};
interface IProps {
// The number of elements to show before truncating. If negative, no truncation is done.
truncateAt?: number;
// The className to apply to the wrapping div
className?: string;
// A function that returns the children to be rendered into the element.
// The start element is included, the end is not (as in `slice`).
// If omitted, the React child elements will be used. This parameter can be used
// to avoid creating unnecessary React elements.
getChildren?: (start: number, end: number) => Array<React.ReactNode>;
// A function that should return the total number of child element available.
// Required if getChildren is supplied.
getChildCount?: () => number;
// A function which will be invoked when an overflow element is required.
// This will be inserted after the children.
createOverflowElement?: (overflowCount: number, totalCount: number) => React.ReactNode;
}
@replaceableComponent("views.elements.TruncatedList")
export default class TruncatedList extends React.Component<IProps> {
static defaultProps ={
truncateAt: 2,
createOverflowElement(overflowCount, totalCount) {
@ -50,7 +48,7 @@ export default class TruncatedList extends React.Component {
},
};
_getChildren(start, end) {
private getChildren(start: number, end: number): Array<React.ReactNode> {
if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildren(start, end);
} else {
@ -63,7 +61,7 @@ export default class TruncatedList extends React.Component {
}
}
_getChildCount() {
private getChildCount(): number {
if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildCount();
} else {
@ -73,10 +71,10 @@ export default class TruncatedList extends React.Component {
}
}
render() {
public render() {
let overflowNode = null;
const totalChildren = this._getChildCount();
const totalChildren = this.getChildCount();
let upperBound = totalChildren;
if (this.props.truncateAt >= 0) {
const overflowCount = totalChildren - this.props.truncateAt;
@ -87,7 +85,7 @@ export default class TruncatedList extends React.Component {
upperBound = this.props.truncateAt;
}
}
const childNodes = this._getChildren(0, upperBound);
const childNodes = this.getChildren(0, upperBound);
return (
<div className={this.props.className}>

View file

@ -66,9 +66,7 @@ export default class GroupPublicityToggle extends React.Component {
render() {
const GroupTile = sdk.getComponent('groups.GroupTile');
return <div className="mx_GroupPublicity_toggle">
<GroupTile groupId={this.props.groupId} showDescription={false}
avatarHeight={40} draggable={false}
/>
<GroupTile groupId={this.props.groupId} showDescription={false} avatarHeight={40} />
<ToggleSwitch checked={this.state.isGroupPublicised}
disabled={!this.state.ready || this.state.busy}
onChange={this._onPublicityToggle} />

View file

@ -16,15 +16,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { Draggable, Droppable } from 'react-beautiful-dnd';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import FlairStore from '../../../stores/FlairStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
function nop() {}
import { _t } from "../../../languageHandler";
import TagOrderActions from "../../../actions/TagOrderActions";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
@replaceableComponent("views.groups.GroupTile")
class GroupTile extends React.Component {
@ -34,7 +34,6 @@ class GroupTile extends React.Component {
showDescription: PropTypes.bool,
// Height of the group avatar in pixels
avatarHeight: PropTypes.number,
draggable: PropTypes.bool,
};
static contextType = MatrixClientContext;
@ -42,7 +41,6 @@ class GroupTile extends React.Component {
static defaultProps = {
showDescription: true,
avatarHeight: 50,
draggable: true,
};
state = {
@ -57,7 +55,7 @@ class GroupTile extends React.Component {
});
}
onMouseDown = e => {
onClick = e => {
e.preventDefault();
dis.dispatch({
action: 'view_group',
@ -65,6 +63,18 @@ class GroupTile extends React.Component {
});
};
onPinClick = e => {
e.preventDefault();
e.stopPropagation();
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.groupId, 0));
};
onUnpinClick = e => {
e.preventDefault();
e.stopPropagation();
dis.dispatch(TagOrderActions.removeTag(this.context, this.props.groupId));
};
render() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
@ -78,7 +88,7 @@ class GroupTile extends React.Component {
? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarHeight)
: null;
let avatarElement = (
const avatarElement = (
<div className="mx_GroupTile_avatar">
<BaseAvatar
name={name}
@ -88,46 +98,21 @@ class GroupTile extends React.Component {
height={avatarHeight} />
</div>
);
if (this.props.draggable) {
const avatarClone = avatarElement;
avatarElement = (
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => (
<div ref={droppableProvided.innerRef}>
<Draggable
key={"GroupTile " + this.props.groupId}
draggableId={"GroupTile " + this.props.groupId}
index={this.props.groupId}
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
{avatarClone}
</div>
{ /* Instead of a blank placeholder, use a copy of the avatar itself. */ }
{ provided.placeholder ? avatarClone : <div /> }
</div>
) }
</Draggable>
</div>
) }
</Droppable>
);
}
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6156
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown} onClick={nop}>
return <AccessibleButton className="mx_GroupTile" onClick={this.onClick}>
{ avatarElement }
<div className="mx_GroupTile_profile">
<div className="mx_GroupTile_name">{ name }</div>
{ descElement }
<div className="mx_GroupTile_groupId">{ this.props.groupId }</div>
{ !(GroupFilterOrderStore.getOrderedTags() || []).includes(this.props.groupId)
? <AccessibleButton kind="link" onClick={this.onPinClick}>
{ _t("Pin") }
</AccessibleButton>
: <AccessibleButton kind="link" onClick={this.onUnpinClick}>
{ _t("Unpin") }
</AccessibleButton>
}
</div>
</AccessibleButton>;
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
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,12 +16,12 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {formatFullDateNoTime} from '../../../DateUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent";
function getdaysArray() {
import { _t } from '../../../languageHandler';
import { formatFullDateNoTime } from '../../../DateUtils';
import { replaceableComponent } from "../../../utils/replaceableComponent";
function getDaysArray(): string[] {
return [
_t('Sunday'),
_t('Monday'),
@ -33,17 +33,17 @@ function getdaysArray() {
];
}
@replaceableComponent("views.messages.DateSeparator")
export default class DateSeparator extends React.Component {
static propTypes = {
ts: PropTypes.number.isRequired,
};
interface IProps {
ts: number;
}
getLabel() {
@replaceableComponent("views.messages.DateSeparator")
export default class DateSeparator extends React.Component<IProps> {
private getLabel() {
const date = new Date(this.props.ts);
const today = new Date();
const yesterday = new Date();
const days = getdaysArray();
const days = getDaysArray();
yesterday.setDate(today.getDate() - 1);
if (date.toDateString() === today.toDateString()) {

View file

@ -15,41 +15,40 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk/src';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {getNameForEventRoom, userLabelForEventRoom}
import { getNameForEventRoom, userLabelForEventRoom }
from '../../../utils/KeyVerificationStateObserver';
import dis from "../../../dispatcher/dispatcher";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { Action } from "../../../dispatcher/actions";
import EventTileBubble from "./EventTileBubble";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
mxEvent: MatrixEvent
}
@replaceableComponent("views.messages.MKeyVerificationRequest")
export default class MKeyVerificationRequest extends React.Component {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
export default class MKeyVerificationRequest extends React.Component<IProps> {
public componentDidMount() {
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.on("change", this._onRequestChanged);
request.on("change", this.onRequestChanged);
}
}
componentWillUnmount() {
public componentWillUnmount() {
const request = this.props.mxEvent.verificationRequest;
if (request) {
request.off("change", this._onRequestChanged);
request.off("change", this.onRequestChanged);
}
}
_openRequest = () => {
const {verificationRequest} = this.props.mxEvent;
private openRequest = () => {
const { verificationRequest } = this.props.mxEvent;
const member = MatrixClientPeg.get().getUser(verificationRequest.otherUserId);
dis.dispatch({
action: Action.SetRightPanelPhase,
@ -58,15 +57,15 @@ export default class MKeyVerificationRequest extends React.Component {
});
};
_onRequestChanged = () => {
private onRequestChanged = () => {
this.forceUpdate();
};
_onAcceptClicked = async () => {
private onAcceptClicked = async () => {
const request = this.props.mxEvent.verificationRequest;
if (request) {
try {
this._openRequest();
this.openRequest();
await request.accept();
} catch (err) {
console.error(err.message);
@ -74,7 +73,7 @@ export default class MKeyVerificationRequest extends React.Component {
}
};
_onRejectClicked = async () => {
private onRejectClicked = async () => {
const request = this.props.mxEvent.verificationRequest;
if (request) {
try {
@ -85,7 +84,7 @@ export default class MKeyVerificationRequest extends React.Component {
}
};
_acceptedLabel(userId) {
private acceptedLabel(userId: string) {
const client = MatrixClientPeg.get();
const myUserId = client.getUserId();
if (userId === myUserId) {
@ -95,7 +94,7 @@ export default class MKeyVerificationRequest extends React.Component {
}
}
_cancelledLabel(userId) {
private cancelledLabel(userId: string) {
const client = MatrixClientPeg.get();
const myUserId = client.getUserId();
const {cancellationCode} = this.props.mxEvent.verificationRequest;
@ -115,9 +114,8 @@ export default class MKeyVerificationRequest extends React.Component {
}
}
render() {
public render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const FormButton = sdk.getComponent("elements.FormButton");
const {mxEvent} = this.props;
const request = mxEvent.verificationRequest;
@ -134,11 +132,11 @@ export default class MKeyVerificationRequest extends React.Component {
let stateLabel;
const accepted = request.ready || request.started || request.done;
if (accepted) {
stateLabel = (<AccessibleButton onClick={this._openRequest}>
{this._acceptedLabel(request.receivingUserId)}
stateLabel = (<AccessibleButton onClick={this.openRequest}>
{this.acceptedLabel(request.receivingUserId)}
</AccessibleButton>);
} else if (request.cancelled) {
stateLabel = this._cancelledLabel(request.cancellingUserId);
stateLabel = this.cancelledLabel(request.cancellingUserId);
} else if (request.accepting) {
stateLabel = _t("Accepting …");
} else if (request.declining) {
@ -153,8 +151,12 @@ export default class MKeyVerificationRequest extends React.Component {
subtitle = userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId());
if (request.canAccept) {
stateNode = (<div className="mx_cryptoEvent_buttons">
<FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
<FormButton onClick={this._onAcceptClicked} label={_t("Accept")} />
<AccessibleButton kind="danger" onClick={this.onRejectClicked}>
{_t("Decline")}
</AccessibleButton>
<AccessibleButton onClick={this.onAcceptClicked}>
{_t("Accept")}
</AccessibleButton>
</div>);
}
} else { // request sent by us
@ -174,8 +176,3 @@ export default class MKeyVerificationRequest extends React.Component {
return null;
}
}
MKeyVerificationRequest.propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};

View file

@ -28,7 +28,9 @@ interface IProps {
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
public render() {
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'];
// 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'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
if (isVoiceMessage && voiceMessagesEnabled) {
return <MVoiceMessageBody {...this.props} />;

View file

@ -15,7 +15,7 @@
*/
import React from 'react';
import Flair from '../elements/Flair.js';
import Flair from '../elements/Flair';
import FlairStore from '../../../stores/FlairStore';
import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext";

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.
@ -16,14 +16,24 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import SdkConfig from "../../../SdkConfig";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import BugReportDialog from '../dialogs/BugReportDialog';
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
error: Error;
}
@replaceableComponent("views.messages.TileErrorBoundary")
export default class TileErrorBoundary extends React.Component {
export default class TileErrorBoundary extends React.Component<IProps, IState> {
constructor(props) {
super(props);
@ -32,17 +42,13 @@ export default class TileErrorBoundary extends React.Component {
};
}
static getDerivedStateFromError(error) {
static getDerivedStateFromError(error: Error): Partial<IState> {
// Side effects are not permitted here, so we only update the state so
// that the next render shows an error message.
return { error };
}
_onBugReport = () => {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
if (!BugReportDialog) {
return;
}
private onBugReport = (): void => {
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
label: 'react-soft-crash-tile',
});
@ -60,7 +66,7 @@ export default class TileErrorBoundary extends React.Component {
let submitLogsButton;
if (SdkConfig.get().bug_report_endpoint_url) {
submitLogsButton = <a onClick={this._onBugReport} href="#">
submitLogsButton = <a onClick={this.onBugReport} href="#">
{_t("Submit logs")}
</a>;
}

View file

@ -14,28 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useCallback, useEffect, useState} from "react";
import React, { useCallback, useEffect, useState } from "react";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { User } from "matrix-js-sdk/src/models/user";
import { PHASE_REQUESTED, PHASE_UNSENT } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import EncryptionInfo from "./EncryptionInfo";
import VerificationPanel from "./VerificationPanel";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {ensureDMExists} from "../../../createRoom";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { ensureDMExists } from "../../../createRoom";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import Modal from "../../../Modal";
import {PHASE_REQUESTED, PHASE_UNSENT} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import * as sdk from "../../../index";
import {_t} from "../../../languageHandler";
import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
// cancellation codes which constitute a key mismatch
const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
interface IProps {
member: RoomMember;
member: RoomMember | User;
onClose: () => void;
verificationRequest: VerificationRequest;
verificationRequestPromise: Promise<VerificationRequest>;

View file

@ -16,7 +16,6 @@ limitations under the License.
import React, {useCallback, useContext, useEffect, useState} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from 'matrix-js-sdk/src/@types/event';
@ -28,6 +27,7 @@ import { useEventEmitter } from "../../../hooks/useEventEmitter";
import PinningUtils from "../../../utils/PinningUtils";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import PinnedEventTile from "../rooms/PinnedEventTile";
import { useRoomState } from "../../../hooks/useRoomState";
interface IProps {
room: Room;
@ -75,24 +75,6 @@ export const useReadPinnedEvents = (room: Room): Set<string> => {
return readPinnedEvents;
};
const useRoomState = <T extends any>(room: Room, mapper: (state: RoomState) => T): T => {
const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined);
const update = useCallback(() => {
if (!room) return;
setValue(mapper(room.currentState));
}, [room, mapper]);
useEventEmitter(room?.currentState, "RoomState.events", update);
useEffect(() => {
update();
return () => {
setValue(undefined);
};
}, [update]);
return value;
};
const PinnedMessagesCard = ({ room, onClose }: IProps) => {
const cli = useContext(MatrixClientContext);
const canUnpin = useRoomState(room, state => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli));

View file

@ -503,19 +503,15 @@ const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) =>
return member.powerLevel < levelToSend;
};
const getPowerLevels = room => room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({});
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>(getPowerLevels(room));
const update = useCallback((ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
const event = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
if (event) {
setPowerLevels(event.getContent());
} else {
setPowerLevels({});
}
setPowerLevels(getPowerLevels(room));
}, [room]);
useEventEmitter(cli, "RoomState.events", update);
@ -1594,7 +1590,7 @@ const UserInfo: React.FC<IProps> = ({
content = (
<BasicUserInfo
room={room}
member={member}
member={member as User}
groupId={groupId as string}
devices={devices}
isRoomEncrypted={isRoomEncrypted} />
@ -1605,7 +1601,7 @@ const UserInfo: React.FC<IProps> = ({
content = (
<EncryptionPanel
{...props as React.ComponentProps<typeof EncryptionPanel>}
member={member}
member={member as User | RoomMember}
onClose={onEncryptionPanelClose}
isRoomEncrypted={isRoomEncrypted}
/>

View file

@ -195,14 +195,7 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
private renderQRReciprocatePhase() {
const {member, request} = this.props;
let Button;
// a bit of a hack, but the FormButton should only be used in the right panel
// they should probably just be the same component with a css class applied to it?
if (this.props.inDialog) {
Button = sdk.getComponent("elements.AccessibleButton");
} else {
Button = sdk.getComponent("elements.FormButton");
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const description = request.isSelfVerification ?
_t("Almost there! Is your other session showing the same shield?") :
_t("Almost there! Is %(displayName)s showing the same shield?", {
@ -211,21 +204,24 @@ export default class VerificationPanel extends React.PureComponent<IProps, IStat
let body: JSX.Element;
if (this.state.reciprocateQREvent) {
// Element Web doesn't support scanning yet, so assume here we're the client being scanned.
//
// we're passing both a label and a child string to Button as
// FormButton and AccessibleButton expect this differently
body = <React.Fragment>
<p>{description}</p>
<E2EIcon isUser={true} status="verified" size={128} hideTooltip={true} />
<div className="mx_VerificationPanel_reciprocateButtons">
<Button
label={_t("No")} kind="danger"
<AccessibleButton
kind="danger"
disabled={this.state.reciprocateButtonClicked}
onClick={this.onReciprocateNoClick}>{_t("No")}</Button>
<Button
label={_t("Yes")} kind="primary"
onClick={this.onReciprocateNoClick}
>
{ _t("No") }
</AccessibleButton>
<AccessibleButton
kind="primary"
disabled={this.state.reciprocateButtonClicked}
onClick={this.onReciprocateYesClick}>{_t("Yes")}</Button>
onClick={this.onReciprocateYesClick}
>
{ _t("Yes") }
</AccessibleButton>
</div>
</React.Fragment>;
} else {

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2018, 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.
@ -15,59 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, createRef } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import EditableItemList from "../elements/EditableItemList";
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import ErrorDialog from "../dialogs/ErrorDialog";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import RoomPublishSetting from "./RoomPublishSetting";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomAliasField from "../elements/RoomAliasField";
class EditableAliasesList extends EditableItemList {
constructor(props) {
super(props);
interface IEditableAliasesListProps {
domain?: string;
}
this._aliasField = createRef();
}
class EditableAliasesList extends EditableItemList<IEditableAliasesListProps> {
private aliasField = createRef<RoomAliasField>();
_onAliasAdded = async () => {
await this._aliasField.current.validate({ allowEmpty: false });
private onAliasAdded = async () => {
await this.aliasField.current.validate({ allowEmpty: false });
if (this._aliasField.current.isValid) {
if (this.aliasField.current.isValid) {
if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem);
return;
}
this._aliasField.current.focus();
this._aliasField.current.validate({ allowEmpty: false, focused: true });
this.aliasField.current.focus();
this.aliasField.current.validate({ allowEmpty: false, focused: true });
};
_renderNewItemField() {
protected renderNewItemField() {
// if we don't need the RoomAliasField,
// we don't need to overriden version of _renderNewItemField
// we don't need to overriden version of renderNewItemField
if (!this.props.domain) {
return super._renderNewItemField();
return super.renderNewItemField();
}
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
const onChange = (alias) => this._onNewItemChanged({target: {value: alias}});
const onChange = (alias) => this.onNewItemChanged({target: {value: alias}});
return (
<form
onSubmit={this._onAliasAdded}
onSubmit={this.onAliasAdded}
autoComplete="off"
noValidate={true}
className="mx_EditableItemList_newItem"
>
<RoomAliasField
ref={this._aliasField}
ref={this.aliasField}
onChange={onChange}
value={this.props.newItem || ""}
domain={this.props.domain} />
<AccessibleButton onClick={this._onAliasAdded} kind="primary">
<AccessibleButton onClick={this.onAliasAdded} kind="primary">
{ _t("Add") }
</AccessibleButton>
</form>
@ -75,19 +75,30 @@ class EditableAliasesList extends EditableItemList {
}
}
@replaceableComponent("views.room_settings.AliasSettings")
export default class AliasSettings extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
canSetCanonicalAlias: PropTypes.bool.isRequired,
canSetAliases: PropTypes.bool.isRequired,
canonicalAliasEvent: PropTypes.object, // MatrixEvent
};
interface IProps {
roomId: string;
canSetCanonicalAlias: boolean;
canSetAliases: boolean;
canonicalAliasEvent?: MatrixEvent;
hidePublishSetting?: boolean;
}
interface IState {
altAliases: string[];
localAliases: string[];
canonicalAlias?: string;
updatingCanonicalAlias: boolean;
localAliasesLoading: boolean;
detailsOpen: boolean;
newAlias?: string;
newAltAlias?: string;
}
@replaceableComponent("views.room_settings.AliasSettings")
export default class AliasSettings extends React.Component<IProps, IState> {
static defaultProps = {
canSetAliases: false,
canSetCanonicalAlias: false,
aliasEvents: [],
};
constructor(props) {
@ -122,7 +133,7 @@ export default class AliasSettings extends React.Component {
}
}
async loadLocalAliases() {
private async loadLocalAliases() {
this.setState({ localAliasesLoading: true });
try {
const cli = MatrixClientPeg.get();
@ -134,12 +145,16 @@ export default class AliasSettings extends React.Component {
}
}
this.setState({ localAliases });
if (localAliases.length === 0) {
this.setState({ detailsOpen: true });
}
} finally {
this.setState({ localAliasesLoading: false });
}
}
changeCanonicalAlias(alias) {
private changeCanonicalAlias(alias: string) {
if (!this.props.canSetCanonicalAlias) return;
const oldAlias = this.state.canonicalAlias;
@ -170,7 +185,7 @@ export default class AliasSettings extends React.Component {
});
}
changeAltAliases(altAliases) {
private changeAltAliases(altAliases: string[]) {
if (!this.props.canSetCanonicalAlias) return;
this.setState({
@ -181,7 +196,7 @@ export default class AliasSettings extends React.Component {
const eventContent = {};
if (this.state.canonicalAlias) {
eventContent.alias = this.state.canonicalAlias;
eventContent["alias"] = this.state.canonicalAlias;
}
if (altAliases) {
eventContent["alt_aliases"] = altAliases;
@ -202,11 +217,11 @@ export default class AliasSettings extends React.Component {
});
}
onNewAliasChanged = (value) => {
this.setState({newAlias: value});
private onNewAliasChanged = (value: string) => {
this.setState({ newAlias: value });
};
onLocalAliasAdded = (alias) => {
private onLocalAliasAdded = (alias: string) => {
if (!alias || alias.length === 0) return; // ignore attempts to create blank aliases
const localDomain = MatrixClientPeg.get().getDomain();
@ -232,7 +247,7 @@ export default class AliasSettings extends React.Component {
});
};
onLocalAliasDeleted = (index) => {
private onLocalAliasDeleted = (index: number) => {
const alias = this.state.localAliases[index];
// TODO: In future, we should probably be making sure that the alias actually belongs
// to this room. See https://github.com/vector-im/element-web/issues/7353
@ -261,7 +276,7 @@ export default class AliasSettings extends React.Component {
});
};
onLocalAliasesToggled = (event) => {
private onLocalAliasesToggled = (event: ChangeEvent<HTMLDetailsElement>) => {
// expanded
if (event.target.open) {
// if local aliases haven't been preloaded yet at component mount
@ -269,43 +284,45 @@ export default class AliasSettings extends React.Component {
this.loadLocalAliases();
}
}
this.setState({detailsOpen: event.target.open});
this.setState({ detailsOpen: event.currentTarget.open });
};
onCanonicalAliasChange = (event) => {
private onCanonicalAliasChange = (event: ChangeEvent<HTMLSelectElement>) => {
this.changeCanonicalAlias(event.target.value);
};
onNewAltAliasChanged = (value) => {
this.setState({newAltAlias: value});
private onNewAltAliasChanged = (value: string) => {
this.setState({ newAltAlias: value });
}
onAltAliasAdded = (alias) => {
private onAltAliasAdded = (alias: string) => {
const altAliases = this.state.altAliases.slice();
if (!altAliases.some(a => a.trim() === alias.trim())) {
altAliases.push(alias.trim());
this.changeAltAliases(altAliases);
this.setState({newAltAlias: ""});
this.setState({ newAltAlias: "" });
}
}
onAltAliasDeleted = (index) => {
private onAltAliasDeleted = (index: number) => {
const altAliases = this.state.altAliases.slice();
altAliases.splice(index, 1);
this.changeAltAliases(altAliases);
}
_getAliases() {
return this.state.altAliases.concat(this._getLocalNonAltAliases());
private getAliases() {
return this.state.altAliases.concat(this.getLocalNonAltAliases());
}
_getLocalNonAltAliases() {
private getLocalNonAltAliases() {
const {altAliases} = this.state;
return this.state.localAliases.filter(alias => !altAliases.includes(alias));
}
render() {
const localDomain = MatrixClientPeg.get().getDomain();
const cli = MatrixClientPeg.get();
const localDomain = cli.getDomain();
const isSpaceRoom = cli.getRoom(this.props.roomId)?.isSpaceRoom();
let found = false;
const canonicalValue = this.state.canonicalAlias || "";
@ -320,7 +337,7 @@ export default class AliasSettings extends React.Component {
>
<option value="" key="unset">{ _t('not specified') }</option>
{
this._getAliases().map((alias, i) => {
this.getAliases().map((alias, i) => {
if (alias === this.state.canonicalAlias) found = true;
return (
<option value={alias} key={i}>
@ -340,12 +357,10 @@ export default class AliasSettings extends React.Component {
let localAliasesList;
if (this.state.localAliasesLoading) {
const Spinner = sdk.getComponent("elements.Spinner");
localAliasesList = <Spinner />;
} else {
localAliasesList = (<EditableAliasesList
id="roomAliases"
className={"mx_RoomSettings_localAliases"}
items={this.state.localAliases}
newItem={this.state.newAlias}
onNewItemChanged={this.onNewAliasChanged}
@ -353,7 +368,9 @@ export default class AliasSettings extends React.Component {
canEdit={this.props.canSetAliases}
onItemAdded={this.onLocalAliasAdded}
onItemRemoved={this.onLocalAliasDeleted}
noItemsLabel={_t('This room has no local addresses')}
noItemsLabel={isSpaceRoom
? _t("This space has no local addresses")
: _t("This room has no local addresses")}
placeholder={_t('Local address')}
domain={localDomain}
/>);
@ -362,18 +379,27 @@ export default class AliasSettings extends React.Component {
return (
<div className='mx_AliasSettings'>
<span className='mx_SettingsTab_subheading'>{_t("Published Addresses")}</span>
<p>{_t("Published addresses can be used by anyone on any server to join your room. " +
"To publish an address, it needs to be set as a local address first.")}</p>
{canonicalAliasSection}
<RoomPublishSetting roomId={this.props.roomId} canSetCanonicalAlias={this.props.canSetCanonicalAlias} />
<p>
{ isSpaceRoom
? _t("Published addresses can be used by anyone on any server to join your space.")
: _t("Published addresses can be used by anyone on any server to join your room.")}
&nbsp;
{ _t("To publish an address, it needs to be set as a local address first.") }
</p>
{ canonicalAliasSection }
{ this.props.hidePublishSetting
? null
: <RoomPublishSetting
roomId={this.props.roomId}
canSetCanonicalAlias={this.props.canSetCanonicalAlias}
/> }
<datalist id="mx_AliasSettings_altRecommendations">
{this._getLocalNonAltAliases().map(alias => {
{this.getLocalNonAltAliases().map(alias => {
return <option value={alias} key={alias} />;
})};
</datalist>
<EditableAliasesList
id="roomAltAliases"
className={"mx_RoomSettings_altAliases"}
items={this.state.altAliases}
newItem={this.state.newAltAlias}
onNewItemChanged={this.onNewAltAliasChanged}
@ -386,11 +412,19 @@ export default class AliasSettings extends React.Component {
noItemsLabel={_t('No other published addresses yet, add one below')}
placeholder={_t('New published address (e.g. #alias:server)')}
/>
<span className='mx_SettingsTab_subheading mx_AliasSettings_localAliasHeader'>{_t("Local Addresses")}</span>
<p>{_t("Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", {localDomain})}</p>
<details onToggle={this.onLocalAliasesToggled}>
<span className='mx_SettingsTab_subheading mx_AliasSettings_localAliasHeader'>
{ _t("Local Addresses") }
</span>
<p>
{ isSpaceRoom
? _t("Set addresses for this space so users can find this space " +
"through your homeserver (%(localDomain)s)", { localDomain })
: _t("Set addresses for this room so users can find this room " +
"through your homeserver (%(localDomain)s)", { localDomain }) }
</p>
<details onToggle={this.onLocalAliasesToggled} open={this.state.detailsOpen}>
<summary>{ this.state.detailsOpen ? _t('Show less') : _t("Show more")}</summary>
{localAliasesList}
{ localAliasesList }
</details>
</div>
);

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.
@ -14,20 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import {_t} from "../../../languageHandler";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
roomId: string;
label?: string;
canSetCanonicalAlias?: boolean;
}
interface IState {
isRoomPublished: boolean;
}
@replaceableComponent("views.room_settings.RoomPublishSetting")
export default class RoomPublishSetting extends React.PureComponent {
constructor(props) {
super(props);
this.state = {isRoomPublished: false};
export default class RoomPublishSetting extends React.PureComponent<IProps, IState> {
constructor(props, context) {
super(props, context);
this.state = {
isRoomPublished: false,
};
}
onRoomPublishChange = (e) => {
private onRoomPublishChange = (e) => {
const valueBefore = this.state.isRoomPublished;
const newValue = !valueBefore;
this.setState({isRoomPublished: newValue});
@ -52,11 +66,14 @@ export default class RoomPublishSetting extends React.PureComponent {
render() {
const client = MatrixClientPeg.get();
return (<LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange}
disabled={!this.props.canSetCanonicalAlias}
label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(),
})} />);
return (
<LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange}
disabled={!this.props.canSetCanonicalAlias}
label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(),
})}
/>
);
}
}

View file

@ -15,15 +15,17 @@ limitations under the License.
*/
import React from 'react';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Room } from 'matrix-js-sdk/src/models/room'
import AppsDrawer from './AppsDrawer';
import classNames from 'classnames';
import { lexicographicCompare } from 'matrix-js-sdk/src/utils';
import { Room } from 'matrix-js-sdk/src/models/room'
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AppsDrawer from './AppsDrawer';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { UIFeature } from "../../../settings/UIFeature";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom';
import { objectHasDiff } from "../../../utils/objects";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -106,9 +108,7 @@ export default class AuxPanel extends React.Component<IProps, IState> {
if (this.props.room && SettingsStore.getValue("feature_state_counters")) {
const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter');
stateEvs.sort((a, b) => {
return a.getStateKey() < b.getStateKey();
});
stateEvs.sort((a, b) => lexicographicCompare(a.getStateKey(), b.getStateKey()));
for (const ev of stateEvs) {
const title = ev.getContent().title;

View file

@ -15,9 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { createRef } from 'react';
import classNames from "classnames";
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";
@ -29,24 +28,24 @@ import { hasText } from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {Layout} from "../../../settings/Layout";
import {formatTime} from "../../../DateUtils";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import { Layout } from "../../../settings/Layout";
import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { ALL_RULE_TYPES } from "../../../mjolnir/BanList";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
import {toRem} from "../../../utils/units";
import {WidgetType} from "../../../widgets/WidgetType";
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 { WIDGET_LAYOUT_EVENT_TYPE } from "../../../stores/widgets/WidgetLayoutStore";
import { objectHasDiff } from "../../../utils/objects";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { EditorStateTransfer } from "../../../utils/EditorStateTransfer";
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
import {ComposerInsertPayload} from "../../../dispatcher/payloads/ComposerInsertPayload";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from '../../../dispatcher/actions';
const eventTileTypes = {
@ -177,12 +176,19 @@ const MAX_READ_AVATARS = 5;
// | '--------------------------------------' |
// '----------------------------------------------------------'
interface IReadReceiptProps {
export interface IReadReceiptProps {
userId: string;
roomMember: RoomMember;
ts: number;
}
export enum TileShape {
Notif = "notif",
FileGrid = "file_grid",
Reply = "reply",
ReplyPreview = "reply_preview",
}
interface IProps {
// the MatrixEvent to show
mxEvent: MatrixEvent;
@ -249,7 +255,7 @@ interface IProps {
// It could also be done by subclassing EventTile, but that'd be quite
// boiilerplatey. So just make the necessary render decisions conditional
// for now.
tileShape?: 'notif' | 'file_grid' | 'reply' | 'reply_preview';
tileShape?: TileShape;
// show twelve hour timestamps
isTwelveHour?: boolean;
@ -307,10 +313,11 @@ interface IState {
export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private ref: React.RefObject<unknown>;
private tile = React.createRef();
private replyThread = React.createRef();
public readonly ref = createRef<HTMLElement>();
static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
onHeightChanged: function() {},
@ -346,8 +353,6 @@ export default class EventTile extends React.Component<IProps, IState> {
// 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;
this.ref = React.createRef();
}
/**

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,17 +21,28 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher/dispatcher';
import {isValid3pidInvite} from "../../../RoomInvite";
import rate_limited_func from "../../../ratelimitedfunc";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import { isValid3pidInvite } from "../../../RoomInvite";
import rateLimitedFunction from "../../../ratelimitedfunc";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import { User } from "matrix-js-sdk/src/models/user";
import TruncatedList from '../elements/TruncatedList';
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import AccessibleButton from '../elements/AccessibleButton';
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar';
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -40,41 +52,59 @@ const SHOW_MORE_INCREMENT = 100;
// matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
interface IProps {
roomId: string;
onClose(): void;
}
interface IState {
loading: boolean;
members: Array<RoomMember>;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
truncateAtJoined: number;
truncateAtInvited: number;
searchQuery: string;
}
@replaceableComponent("views.rooms.MemberList")
export default class MemberList extends React.Component {
export default class MemberList extends React.Component<IProps, IState> {
private showPresence = true;
private mounted = false;
private collator: Intl.Collator;
private sortNames = new Map<RoomMember, string>(); // RoomMember -> sortName
constructor(props) {
super(props);
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
// show an empty list
this.state = this._getMembersState([]);
this.state = this.getMembersState([]);
} else {
this.state = this._getMembersState(this.roomMembers());
this.state = this.getMembersState(this.roomMembers());
}
cli.on("Room", this.onRoom); // invites & joining after peek
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
const hsUrl = MatrixClientPeg.get().baseUrl;
this._showPresence = true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
this._showPresence = enablePresenceByHsUrl[hsUrl];
}
this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true;
}
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
const cli = MatrixClientPeg.get();
this._mounted = true;
this.mounted = true;
if (cli.hasLazyLoadMembersEnabled()) {
this._showMembersAccordingToMembershipWithLL();
this.showMembersAccordingToMembershipWithLL();
cli.on("Room.myMembership", this.onMyMembership);
} else {
this._listenForMembersChanges();
this.listenForMembersChanges();
}
}
_listenForMembersChanges() {
private listenForMembersChanges(): void {
const cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
cli.on("RoomMember.name", this.onRoomMemberName);
@ -89,7 +119,7 @@ export default class MemberList extends React.Component {
}
componentWillUnmount() {
this._mounted = false;
this.mounted = false;
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.members", this.onRoomStateMember);
@ -103,7 +133,7 @@ export default class MemberList extends React.Component {
}
// cancel any pending calls to the rate_limited_funcs
this._updateList.cancelPendingCall();
this.updateList.cancelPendingCall();
}
/**
@ -111,7 +141,7 @@ export default class MemberList extends React.Component {
* show a spinner and load the members if the user is joined,
* or show the members available so far if the user is invited
*/
async _showMembersAccordingToMembershipWithLL() {
private async showMembersAccordingToMembershipWithLL(): Promise<void> {
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
const cli = MatrixClientPeg.get();
@ -122,31 +152,31 @@ export default class MemberList extends React.Component {
try {
await room.loadMembersIfNeeded();
} catch (ex) {/* already logged in RoomView */}
if (this._mounted) {
this.setState(this._getMembersState(this.roomMembers()));
this._listenForMembersChanges();
if (this.mounted) {
this.setState(this.getMembersState(this.roomMembers()));
this.listenForMembersChanges();
}
} else {
// show the members we already have loaded
this.setState(this._getMembersState(this.roomMembers()));
this.setState(this.getMembersState(this.roomMembers()));
}
}
}
get canInvite() {
private get canInvite(): boolean {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
return room && room.canInvite(cli.getUserId());
}
_getMembersState(members) {
// set the state after determining _showPresence to make sure it's
// taken into account while rerendering
private getMembersState(members: Array<RoomMember>): IState {
// 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'),
filteredInvitedMembers: this.filterMembers(members, 'invite'),
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
@ -157,72 +187,72 @@ export default class MemberList extends React.Component {
};
}
onUserPresenceChange = (event, user) => {
private onUserPresenceChange = (event: MatrixEvent, user: User): void => {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener.
const tile = this.refs[user.userId];
// console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`);
if (tile) {
this._updateList(); // reorder the membership list
this.updateList(); // reorder the membership list
}
};
onRoom = room => {
private onRoom = (room: Room): void => {
if (room.roomId !== this.props.roomId) {
return;
}
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this._showMembersAccordingToMembershipWithLL();
this.showMembersAccordingToMembershipWithLL();
};
onMyMembership = (room, membership, oldMembership) => {
private onMyMembership = (room: Room, membership: string, oldMembership: string): void => {
if (room.roomId === this.props.roomId && membership === "join") {
this._showMembersAccordingToMembershipWithLL();
this.showMembersAccordingToMembershipWithLL();
}
};
onRoomStateMember = (ev, state, member) => {
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this._updateList();
this.updateList();
};
onRoomMemberName = (ev, member) => {
private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this._updateList();
this.updateList();
};
onRoomStateEvent = (event, state) => {
private onRoomStateEvent = (event: MatrixEvent, state: RoomState): void => {
if (event.getRoomId() === this.props.roomId &&
event.getType() === "m.room.third_party_invite") {
this._updateList();
this.updateList();
}
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
};
_updateList = rate_limited_func(() => {
this._updateListNow();
private updateList = rateLimitedFunction(() => {
this.updateListNow();
}, 500);
_updateListNow() {
// console.log("Updating memberlist");
const newState = {
private updateListNow(): void {
const members = this.roomMembers()
this.setState({
loading: false,
members: this.roomMembers(),
};
newState.filteredJoinedMembers = this._filterMembers(newState.members, 'join', this.state.searchQuery);
newState.filteredInvitedMembers = this._filterMembers(newState.members, 'invite', this.state.searchQuery);
this.setState(newState);
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join', this.state.searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', this.state.searchQuery),
});
}
getMembersWithUser() {
private getMembersWithUser(): Array<RoomMember> {
if (!this.props.roomId) return [];
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
@ -230,15 +260,18 @@ export default class MemberList extends React.Component {
const allMembers = Object.values(room.currentState.members);
allMembers.forEach(function(member) {
allMembers.forEach((member) => {
// work around a race where you might have a room member object
// before the user object exists. This may or may not cause
// before the user object exists. This may or may not cause
// https://github.com/vector-im/vector-web/issues/186
if (member.user === null) {
if (!member.user) {
member.user = cli.getUser(member.userId);
}
member.sortName = (member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, "");
this.sortNames.set(
member,
(member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, ""),
);
// XXX: this user may have no lastPresenceTs value!
// the right solution here is to fix the race rather than leave it as 0
@ -247,7 +280,7 @@ export default class MemberList extends React.Component {
return allMembers;
}
roomMembers() {
private roomMembers(): Array<RoomMember> {
const allMembers = this.getMembersWithUser();
const filteredAndSortedMembers = allMembers.filter((m) => {
return (
@ -255,23 +288,21 @@ export default class MemberList extends React.Component {
);
});
const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: 'base', usePunctuation: true });
this.collator = new Intl.Collator(language, { sensitivity: 'base', ignorePunctuation: false });
filteredAndSortedMembers.sort(this.memberSort);
return filteredAndSortedMembers;
}
_createOverflowTileJoined = (overflowCount, totalCount) => {
return this._createOverflowTile(overflowCount, totalCount, this._showMoreJoinedMemberList);
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
};
_createOverflowTileInvited = (overflowCount, totalCount) => {
return this._createOverflowTile(overflowCount, totalCount, this._showMoreInvitedMemberList);
private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
};
_createOverflowTile = (overflowCount, totalCount, onClick) => {
private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element=> {
// For now we'll pretend this is any entity. It should probably be a separate tile.
const EntityTile = sdk.getComponent("rooms.EntityTile");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
@ -281,31 +312,48 @@ export default class MemberList extends React.Component {
);
};
_showMoreJoinedMemberList = () => {
private showMoreJoinedMemberList = (): void => {
this.setState({
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
});
};
_showMoreInvitedMemberList = () => {
private showMoreInvitedMemberList = (): void => {
this.setState({
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
});
};
memberString(member) {
/**
* SHOULD ONLY BE USED BY TESTS
*/
public memberString(member: RoomMember): string {
if (!member) {
return "(null)";
} else {
const u = member.user;
return "(" + member.name + ", " + member.powerLevel + ", " + (u ? u.lastActiveAgo : "<null>") + ", " + (u ? u.getLastActiveTs() : "<null>") + ", " + (u ? u.currentlyActive : "<null>") + ", " + (u ? u.presence : "<null>") + ")";
return (
"(" +
member.name +
", " +
member.powerLevel +
", " +
(u ? u.lastActiveAgo : "<null>") +
", " +
(u ? u.getLastActiveTs() : "<null>") +
", " +
(u ? u.currentlyActive : "<null>") +
", " +
(u ? u.presence : "<null>") +
")"
);
}
}
// returns negative if a comes before b,
// returns 0 if a and b are equivalent in ordering
// returns positive if a comes after b.
memberSort = (memberA, memberB) => {
private memberSort = (memberA: RoomMember, memberB: RoomMember): number => {
// order by presence, with "active now" first.
// ...and then by power level
// ...and then by last active
@ -325,7 +373,7 @@ export default class MemberList extends React.Component {
if (!userA && userB) return 1;
// First by presence
if (this._showPresence) {
if (this.showPresence) {
const convertPresence = (p) => p === 'unavailable' ? 'online' : p;
const presenceIndex = p => {
const order = ['active', 'online', 'offline'];
@ -349,31 +397,31 @@ export default class MemberList extends React.Component {
}
// Third by last active
if (this._showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
if (this.showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
// console.log("Comparing on last active timestamp - returning");
return userB.getLastActiveTs() - userA.getLastActiveTs();
}
// Fourth by name (alphabetical)
return this.collator.compare(memberA.sortName, memberB.sortName);
return this.collator.compare(this.sortNames.get(memberA), this.sortNames.get(memberB));
};
onSearchQueryChanged = searchQuery => {
private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({
searchQuery,
filteredJoinedMembers: this._filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', searchQuery),
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(this.state.members, 'invite', searchQuery),
});
};
_onPending3pidInviteClick = inviteEvent => {
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
dis.dispatch({
action: 'view_3pid_invite',
event: inviteEvent,
});
};
_filterMembers(members, membership, query) {
private filterMembers(members: Array<RoomMember>, membership: string, query?: string): Array<RoomMember> {
return members.filter((m) => {
if (query) {
query = query.toLowerCase();
@ -389,7 +437,7 @@ export default class MemberList extends React.Component {
});
}
_getPending3PidInvites() {
private getPending3PidInvites(): Array<MatrixEvent> {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
@ -409,42 +457,40 @@ export default class MemberList extends React.Component {
}
}
_makeMemberTiles(members) {
const MemberTile = sdk.getComponent("rooms.MemberTile");
const EntityTile = sdk.getComponent("rooms.EntityTile");
private makeMemberTiles(members: Array<RoomMember | MatrixEvent>) {
return members.map((m) => {
if (m.userId) {
if (m instanceof RoomMember) {
// Is a Matrix invite
return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this._showPresence} />;
return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this.showPresence} />;
} else {
// Is a 3pid invite
return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
onClick={() => this._onPending3pidInviteClick(m)} />;
onClick={() => this.onPending3pidInviteClick(m)} />;
}
});
}
_getChildrenJoined = (start, end) => this._makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
_getChildCountJoined = () => this.state.filteredJoinedMembers.length;
_getChildrenInvited = (start, end) => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this._getPending3PidInvites());
}
return this._makeMemberTiles(targets.slice(start, end));
private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end))
};
_getChildCountInvited = () => {
return this.state.filteredInvitedMembers.length + (this._getPending3PidInvites() || []).length;
private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this.getPending3PidInvites());
}
return this.makeMemberTiles(targets.slice(start, end));
};
private getChildCountInvited = (): number => {
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
}
render() {
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <BaseCard
className="mx_MemberList"
onClose={this.props.onClose}
@ -454,9 +500,6 @@ export default class MemberList extends React.Component {
</BaseCard>;
}
const SearchBox = sdk.getComponent('structures.SearchBox');
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
let inviteButton;
@ -470,22 +513,30 @@ export default class MemberList extends React.Component {
inviteButtonText = _t("Invite to this space");
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!this.state.canInvite}>
inviteButton = (
<AccessibleButton
className="mx_MemberList_invite"
onClick={this.onInviteButtonClick}
disabled={!this.state.canInvite}
>
<span>{ inviteButtonText }</span>
</AccessibleButton>;
</AccessibleButton>
);
}
let invitedHeader;
let invitedSection;
if (this._getChildCountInvited() > 0) {
if (this.getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>;
invitedSection = (
<TruncatedList
className="mx_MemberList_section mx_MemberList_invited"
truncateAt={this.state.truncateAtInvited}
createOverflowElement={this.createOverflowTileInvited}
getChildren={this.getChildrenInvited}
getChildCount={this.getChildCountInvited}
/>
);
}
const footer = (
@ -517,17 +568,19 @@ export default class MemberList extends React.Component {
previousPhase={previousPhase}
>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined} />
<TruncatedList
className="mx_MemberList_section mx_MemberList_joined"
truncateAt={this.state.truncateAtJoined}
createOverflowElement={this.createOverflowTileJoined}
getChildren={this.getChildrenJoined}
getChildCount={this.getChildCountJoined} />
{ invitedHeader }
{ invitedSection }
</div>
</BaseCard>;
}
onInviteButtonClick = () => {
onInviteButtonClick = (): void => {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;

View file

@ -14,30 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext} from "react";
import {EventType} from "matrix-js-sdk/src/@types/event";
import React, { useContext } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { User } from "matrix-js-sdk/src/models/user";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import RoomContext from "../../../contexts/RoomContext";
import DMRoomMap from "../../../utils/DMRoomMap";
import {_t} from "../../../languageHandler";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import MiniAvatarUploader, {AVATAR_SIZE} from "../elements/MiniAvatarUploader";
import MiniAvatarUploader, { AVATAR_SIZE } from "../elements/MiniAvatarUploader";
import RoomAvatar from "../avatars/RoomAvatar";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {ViewUserPayload} from "../../../dispatcher/payloads/ViewUserPayload";
import {Action} from "../../../dispatcher/actions";
import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
import SpaceStore from "../../../stores/SpaceStore";
import {showSpaceInvite} from "../../../utils/space";
import { showSpaceInvite } from "../../../utils/space";
import { privateShouldBeEncrypted } from "../../../createRoom";
import EventTileBubble from "../messages/EventTileBubble";
import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog";
function hasExpectedEncryptionSettings(room): boolean {
const isEncrypted: boolean = room._client?.isRoomEncrypted(room.roomId);
function hasExpectedEncryptionSettings(matrixClient: MatrixClient, room: Room): boolean {
const isEncrypted: boolean = matrixClient.isRoomEncrypted(room.roomId);
const isPublic: boolean = room.getJoinRule() === "public";
return isPublic || !privateShouldBeEncrypted() || isEncrypted;
}
@ -61,7 +62,7 @@ const NewRoomIntro = () => {
defaultDispatcher.dispatch<ViewUserPayload>({
action: Action.ViewUser,
// XXX: We should be using a real member object and not assuming what the receiver wants.
member: member || {userId: dmPartner},
member: member || { userId: dmPartner } as User,
});
}} />
@ -194,7 +195,7 @@ const NewRoomIntro = () => {
return <div className="mx_NewRoomIntro">
{ !hasExpectedEncryptionSettings(room) && (
{ !hasExpectedEncryptionSettings(cli, room) && (
<EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon_warning"
title={_t("End-to-end encryption isn't enabled")}

View file

@ -22,7 +22,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t, _td } from "../../../languageHandler";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import RoomViewStore from "../../../stores/RoomViewStore";
import { ITagMap } from "../../../stores/room-list/algorithms/models";
@ -466,6 +466,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
}
private renderCommunityInvites(): ReactComponentElement<typeof ExtraTile>[] {
if (SettingsStore.getValue("feature_spaces")) return [];
// 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 => {

View file

@ -45,7 +45,7 @@ import { ActionPayload } from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";

View file

@ -119,7 +119,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
};
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (!room?.roomId === this.props.room.roomId) return;
if (room?.roomId !== this.props.room.roomId) return;
this.setState({hasUnsentEvents: this.countUnsentEvents() > 0});
};
@ -316,7 +316,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
0,
));
} else {
console.warn(`Unexpected tag ${tagId} applied to ${this.props.room.room_id}`);
console.warn(`Unexpected tag ${tagId} applied to ${this.props.room.roomId}`);
}
if ((ev as React.KeyboardEvent).key === Key.ENTER) {

View file

@ -1,99 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 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, {createRef} from 'react';
import AccessibleButton from "../elements/AccessibleButton";
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);
this._search_term = createRef();
this.state = {
scope: 'Room',
};
}
onThisRoomClick = () => {
this.setState({ scope: 'Room' }, () => this._searchIfQuery());
};
onAllRoomsClick = () => {
this.setState({ scope: 'All' }, () => this._searchIfQuery());
};
onSearchChange = (e) => {
switch (e.key) {
case Key.ENTER:
this.onSearch();
break;
case Key.ESCAPE:
this.props.onCancelClick();
break;
}
};
_searchIfQuery() {
if (this._search_term.current.value) {
this.onSearch();
}
}
onSearch = () => {
this.props.onSearch(this._search_term.current.value, this.state.scope);
};
render() {
const searchButtonClasses = classNames("mx_SearchBar_searchButton", {
mx_SearchBar_searching: this.props.searchInProgress,
});
const thisRoomClasses = classNames("mx_SearchBar_button", {
mx_SearchBar_unselected: this.state.scope !== 'Room',
});
const allRoomsClasses = classNames("mx_SearchBar_button", {
mx_SearchBar_unselected: this.state.scope !== 'All',
});
return (
<>
<div className="mx_SearchBar">
<div className="mx_SearchBar_buttons" role="radiogroup">
<AccessibleButton className={ thisRoomClasses } onClick={this.onThisRoomClick} aria-checked={this.state.scope === 'Room'} role="radio">
{_t("This Room")}
</AccessibleButton>
<AccessibleButton className={ allRoomsClasses } onClick={this.onAllRoomsClick} aria-checked={this.state.scope === 'All'} role="radio">
{_t("All Rooms")}
</AccessibleButton>
</div>
<div className="mx_SearchBar_input mx_textinput">
<input ref={this._search_term} type="text" autoFocus={true} placeholder={_t("Search…")} onKeyDown={this.onSearchChange} />
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch} />
</div>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick} />
</div>
<DesktopBuildsNotice isRoomEncrypted={this.props.isRoomEncrypted} kind={WarningKind.Search} />
</>
);
}
}

View file

@ -0,0 +1,130 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 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, { createRef, RefObject } from 'react';
import AccessibleButton from "../elements/AccessibleButton";
import classNames from "classnames";
import { _t } from '../../../languageHandler';
import { Key } from "../../../Keyboard";
import DesktopBuildsNotice, { WarningKind } from "../elements/DesktopBuildsNotice";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
onCancelClick: () => void;
onSearch: (query: string, scope: string) => void;
searchInProgress?: boolean;
isRoomEncrypted?: boolean;
}
interface IState {
scope: SearchScope;
}
export enum SearchScope {
Room = "Room",
All = "All",
}
@replaceableComponent("views.rooms.SearchBar")
export default class SearchBar extends React.Component<IProps, IState> {
private searchTerm: RefObject<HTMLInputElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
scope: SearchScope.Room,
};
}
private onThisRoomClick = () => {
this.setState({ scope: SearchScope.Room }, () => this.searchIfQuery());
};
private onAllRoomsClick = () => {
this.setState({ scope: SearchScope.All }, () => this.searchIfQuery());
};
private onSearchChange = (e: React.KeyboardEvent) => {
switch (e.key) {
case Key.ENTER:
this.onSearch();
break;
case Key.ESCAPE:
this.props.onCancelClick();
break;
}
};
private searchIfQuery(): void {
if (this.searchTerm.current.value) {
this.onSearch();
}
}
private onSearch = (): void => {
this.props.onSearch(this.searchTerm.current.value, this.state.scope);
};
public render() {
const searchButtonClasses = classNames("mx_SearchBar_searchButton", {
mx_SearchBar_searching: this.props.searchInProgress,
});
const thisRoomClasses = classNames("mx_SearchBar_button", {
mx_SearchBar_unselected: this.state.scope !== SearchScope.Room,
});
const allRoomsClasses = classNames("mx_SearchBar_button", {
mx_SearchBar_unselected: this.state.scope !== SearchScope.All,
});
return (
<>
<div className="mx_SearchBar">
<div className="mx_SearchBar_buttons" role="radiogroup">
<AccessibleButton
className={thisRoomClasses}
onClick={this.onThisRoomClick}
aria-checked={this.state.scope === SearchScope.Room}
role="radio"
>
{_t("This Room")}
</AccessibleButton>
<AccessibleButton
className={allRoomsClasses}
onClick={this.onAllRoomsClick}
aria-checked={this.state.scope === SearchScope.All}
role="radio"
>
{_t("All Rooms")}
</AccessibleButton>
</div>
<div className="mx_SearchBar_input mx_textinput">
<input
ref={this.searchTerm}
type="text"
autoFocus={true}
placeholder={_t("Search…")}
onKeyDown={this.onSearchChange}
/>
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch} />
</div>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick} />
</div>
<DesktopBuildsNotice isRoomEncrypted={this.props.isRoomEncrypted} kind={WarningKind.Search} />
</>
);
}
}

View file

@ -30,7 +30,7 @@ import RecordingPlayback from "../voice_messages/RecordingPlayback";
import {MsgType} from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import CallMediaHandler from "../../../CallMediaHandler";
import MediaDeviceHandler from "../../../MediaDeviceHandler";
interface IProps {
room: Room;
@ -77,7 +77,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
size: this.state.recorder.contentLength,
},
// MSC1767 experiment
// MSC1767 + Ideals of MSC2516 as MSC3245
// https://github.com/matrix-org/matrix-doc/pull/3245
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: mxc,
@ -88,14 +89,10 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
"org.matrix.msc1767.audio": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// Events can't have floats, so we try to maintain resolution by using 1024
// as a maximum value. The waveform contains values between zero and 1, so this
// should come out largely sane.
//
// We're expecting about one data point per second of audio.
// https://github.com/matrix-org/matrix-doc/pull/3246
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
},
"org.matrix.msc2516.voice": {}, // No content, this is a rendering hint
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
});
await this.disposeRecording();
}
@ -132,8 +129,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
// Do a sanity test to ensure we're about to grab a valid microphone reference. Things might
// change between this and recording, but at least we will have tried.
try {
const devices = await CallMediaHandler.getDevices();
if (!devices?.['audioinput']?.length) {
const devices = await MediaDeviceHandler.getDevices();
if (!devices?.['audioInput']?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"),
description: <>

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";

View file

@ -79,8 +79,8 @@ export default class CrossSigningPanel extends React.PureComponent {
async _getUpdatedStatus() {
const cli = MatrixClientPeg.get();
const pkCache = cli.getCrossSigningCacheCallbacks();
const crossSigning = cli.crypto._crossSigningInfo;
const secretStorage = cli.crypto._secretStorage;
const crossSigning = cli.crypto.crossSigningInfo;
const secretStorage = cli.crypto.secretStorage;
const crossSigningPublicKeysOnDevice = crossSigning.getId();
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
const masterPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("master"));

View file

@ -131,7 +131,7 @@ export default class SecureBackupPanel extends React.PureComponent {
async _getUpdatedDiagnostics() {
const cli = MatrixClientPeg.get();
const secretStorage = cli.crypto._secretStorage;
const secretStorage = cli.crypto.secretStorage;
const backupKeyStored = !!(await cli.isKeyBackupKeyStored());
const backupKeyFromCache = await cli.crypto.getSessionBackupPrivateKey();

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.
@ -15,68 +15,76 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../..";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from "../../../../../languageHandler";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import RoomUpgradeDialog from "../../../dialogs/RoomUpgradeDialog";
import DevtoolsDialog from "../../../dialogs/DevtoolsDialog";
import Modal from "../../../../../Modal";
import dis from "../../../../../dispatcher/dispatcher";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
interface IProps {
roomId: string;
closeSettingsFn(): void;
}
interface IRecommendedVersion {
version: string;
needsUpgrade: boolean;
urgent: boolean;
}
interface IState {
upgradeRecommendation?: IRecommendedVersion;
oldRoomId?: string;
oldEventId?: string;
upgraded?: boolean;
}
@replaceableComponent("views.settings.tabs.room.AdvancedRoomSettingsTab")
export default class AdvancedRoomSettingsTab extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
closeSettingsFn: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
export default class AdvancedRoomSettingsTab extends React.Component<IProps, IState> {
constructor(props, context) {
super(props, context);
this.state = {
// This is eventually set to the value of room.getRecommendedVersion()
upgradeRecommendation: null,
};
}
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
// we handle lack of this object gracefully later, so don't worry about it failing here.
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
room.getRecommendedVersion().then((v) => {
const tombstone = room.currentState.getStateEvents("m.room.tombstone", "");
const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, "");
const additionalStateChanges = {};
const createEvent = room.currentState.getStateEvents("m.room.create", "");
const additionalStateChanges: Partial<IState> = {};
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
const predecessor = createEvent ? createEvent.getContent().predecessor : null;
if (predecessor && predecessor.room_id) {
additionalStateChanges['oldRoomId'] = predecessor.room_id;
additionalStateChanges['oldEventId'] = predecessor.event_id;
additionalStateChanges['hasPreviousRoom'] = true;
additionalStateChanges.oldRoomId = predecessor.room_id;
additionalStateChanges.oldEventId = predecessor.event_id;
}
this.setState({
upgraded: tombstone && tombstone.getContent().replacement_room,
upgraded: !!tombstone?.getContent().replacement_room,
upgradeRecommendation: v,
...additionalStateChanges,
});
});
}
_upgradeRoom = (e) => {
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
private upgradeRoom = (e) => {
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, {room: room});
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room });
};
_openDevtools = (e) => {
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
private openDevtools = (e) => {
Modal.createDialog(DevtoolsDialog, {roomId: this.props.roomId});
};
_onOldRoomClicked = (e) => {
private onOldRoomClicked = (e) => {
e.preventDefault();
e.stopPropagation();
@ -93,9 +101,9 @@ export default class AdvancedRoomSettingsTab extends React.Component {
const room = client.getRoom(this.props.roomId);
let unfederatableSection;
const createEvent = room.currentState.getStateEvents('m.room.create', '');
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, '');
if (createEvent && createEvent.getContent()['m.federate'] === false) {
unfederatableSection = <div>{_t('This room is not accessible by remote Matrix servers')}</div>;
unfederatableSection = <div>{ _t('This room is not accessible by remote Matrix servers') }</div>;
}
let roomUpgradeButton;
@ -103,7 +111,7 @@ export default class AdvancedRoomSettingsTab extends React.Component {
roomUpgradeButton = (
<div>
<p className='mx_SettingsTab_warningText'>
{_t(
{ _t(
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members " +
"to the new version of the room.</i> We'll post a link to the new room in the old " +
"version of the room - room members will have to click this link to join the new room.",
@ -111,51 +119,53 @@ export default class AdvancedRoomSettingsTab extends React.Component {
"b": (sub) => <b>{sub}</b>,
"i": (sub) => <i>{sub}</i>,
},
)}
) }
</p>
<AccessibleButton onClick={this._upgradeRoom} kind='primary'>
{_t("Upgrade this room to the recommended room version")}
<AccessibleButton onClick={this.upgradeRoom} kind='primary'>
{ _t("Upgrade this room to the recommended room version") }
</AccessibleButton>
</div>
);
}
let oldRoomLink;
if (this.state.hasPreviousRoom) {
if (this.state.oldRoomId) {
let name = _t("this room");
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (room && room.name) name = room.name;
oldRoomLink = (
<AccessibleButton element='a' onClick={this._onOldRoomClicked}>
{_t("View older messages in %(roomName)s.", {roomName: name})}
<AccessibleButton element='a' onClick={this.onOldRoomClicked}>
{ _t("View older messages in %(roomName)s.", { roomName: name }) }
</AccessibleButton>
);
}
return (
<div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{_t("Advanced")}</div>
<div className="mx_SettingsTab_heading">{ _t("Advanced") }</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t("Room information")}</span>
<span className='mx_SettingsTab_subheading'>
{ room?.isSpaceRoom() ? _t("Space information") : _t("Room information") }
</span>
<div>
<span>{_t("Internal room ID:")}</span>&nbsp;
{this.props.roomId}
<span>{ _t("Internal room ID:") }</span>&nbsp;
{ this.props.roomId }
</div>
{unfederatableSection}
{ unfederatableSection }
</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t("Room version")}</span>
<span className='mx_SettingsTab_subheading'>{ _t("Room version") }</span>
<div>
<span>{_t("Room version:")}</span>&nbsp;
{room.getVersion()}
<span>{ _t("Room version:") }</span>&nbsp;
{ room.getVersion() }
</div>
{oldRoomLink}
{roomUpgradeButton}
{ oldRoomLink }
{ roomUpgradeButton }
</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t("Developer options")}</span>
<AccessibleButton onClick={this._openDevtools} kind='primary'>
{_t("Open Devtools")}
<span className='mx_SettingsTab_subheading'>{ _t("Developer options") }</span>
<AccessibleButton onClick={this.openDevtools} kind='primary'>
{ _t("Open Devtools") }
</AccessibleButton>
</div>
</div>

View file

@ -60,7 +60,6 @@ export default class GeneralRoomSettingsTab extends React.Component {
const canSetAliases = true; // Previously, we arbitrarily only allowed admins to do this
const canSetCanonical = room.currentState.mayClientSendStateEvent("m.room.canonical_alias", client);
const canonicalAliasEv = room.currentState.getStateEvents("m.room.canonical_alias", '');
const aliasEvents = room.currentState.getStateEvents("m.room.aliases");
const canChangeGroups = room.currentState.mayClientSendStateEvent("m.room.related_groups", client);
const groupsEvent = room.currentState.getStateEvents("m.room.related_groups", "");
@ -100,7 +99,7 @@ export default class GeneralRoomSettingsTab extends React.Component {
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<AliasSettings roomId={this.props.roomId}
canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv} aliasEvents={aliasEvents} />
canonicalAliasEvent={canonicalAliasEv} />
</div>
<div className="mx_SettingsTab_heading">{_t("Other")}</div>
{ flairSection }

View file

@ -29,19 +29,19 @@ import {UIFeature} from "../../../../../settings/UIFeature";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
// Knock and private are reserved keywords which are not yet implemented.
enum JoinRule {
export enum JoinRule {
Public = "public",
Knock = "knock",
Invite = "invite",
Private = "private",
}
enum GuestAccess {
export enum GuestAccess {
CanJoin = "can_join",
Forbidden = "forbidden",
}
enum HistoryVisibility {
export enum HistoryVisibility {
Invited = "invited",
Joined = "joined",
Shared = "shared",
@ -121,7 +121,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
if (refreshWhenTypes.includes(e.getType())) this.forceUpdate();
};
private onEncryptionChange = (e: React.ChangeEvent) => {
private onEncryptionChange = () => {
Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, {
title: _t('Enable encryption?'),
description: _t(

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import {_t} from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import CallMediaHandler from "../../../../../CallMediaHandler";
import MediaDeviceHandler from "../../../../../MediaDeviceHandler";
import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
@ -41,7 +41,7 @@ export default class VoiceUserSettingsTab extends React.Component {
}
async componentDidMount() {
const canSeeDeviceLabels = await CallMediaHandler.hasAnyLabeledDevices();
const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
if (canSeeDeviceLabels) {
this._refreshMediaDevices();
}
@ -49,10 +49,10 @@ export default class VoiceUserSettingsTab extends React.Component {
_refreshMediaDevices = async (stream) => {
this.setState({
mediaDevices: await CallMediaHandler.getDevices(),
activeAudioOutput: CallMediaHandler.getAudioOutput(),
activeAudioInput: CallMediaHandler.getAudioInput(),
activeVideoInput: CallMediaHandler.getVideoInput(),
mediaDevices: await MediaDeviceHandler.getDevices(),
activeAudioOutput: MediaDeviceHandler.getAudioOutput(),
activeAudioInput: MediaDeviceHandler.getAudioInput(),
activeVideoInput: MediaDeviceHandler.getVideoInput(),
});
if (stream) {
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
@ -100,21 +100,21 @@ export default class VoiceUserSettingsTab extends React.Component {
};
_setAudioOutput = (e) => {
CallMediaHandler.setAudioOutput(e.target.value);
MediaDeviceHandler.instance.setAudioOutput(e.target.value);
this.setState({
activeAudioOutput: e.target.value,
});
};
_setAudioInput = (e) => {
CallMediaHandler.setAudioInput(e.target.value);
MediaDeviceHandler.instance.setAudioInput(e.target.value);
this.setState({
activeAudioInput: e.target.value,
});
};
_setVideoInput = (e) => {
CallMediaHandler.setVideoInput(e.target.value);
MediaDeviceHandler.instance.setVideoInput(e.target.value);
this.setState({
activeVideoInput: e.target.value,
});
@ -171,7 +171,7 @@ export default class VoiceUserSettingsTab extends React.Component {
}
};
const audioOutputs = this.state.mediaDevices.audiooutput.slice(0);
const audioOutputs = this.state.mediaDevices.audioOutput.slice(0);
if (audioOutputs.length > 0) {
const defaultDevice = getDefaultDevice(audioOutputs);
speakerDropdown = (
@ -183,7 +183,7 @@ export default class VoiceUserSettingsTab extends React.Component {
);
}
const audioInputs = this.state.mediaDevices.audioinput.slice(0);
const audioInputs = this.state.mediaDevices.audioInput.slice(0);
if (audioInputs.length > 0) {
const defaultDevice = getDefaultDevice(audioInputs);
microphoneDropdown = (
@ -195,7 +195,7 @@ export default class VoiceUserSettingsTab extends React.Component {
);
}
const videoInputs = this.state.mediaDevices.videoinput.slice(0);
const videoInputs = this.state.mediaDevices.videoInput.slice(0);
if (videoInputs.length > 0) {
const defaultDevice = getDefaultDevice(videoInputs);
webcamDropdown = (

View file

@ -35,6 +35,7 @@ import withValidation from "../elements/Validation";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import { Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests";
import RoomAliasField from "../elements/RoomAliasField";
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return (
@ -60,6 +61,11 @@ const spaceNameValidator = withValidation({
],
});
const nameToAlias = (name: string, domain: string): string => {
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
return `#${localpart}:${domain}`;
};
const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null);
@ -67,6 +73,8 @@ const SpaceCreateMenu = ({ onFinished }) => {
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("");
const spaceAliasField = useRef<RoomAliasField>();
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
@ -82,6 +90,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (visibility === Visibility.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
const initialState: ICreateRoomStateEvent[] = [
{
@ -99,12 +114,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
content: { url },
});
}
if (topic) {
initialState.push({
type: EventType.RoomTopic,
content: { topic },
});
}
try {
await createRoom({
@ -112,7 +121,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
name,
creation_content: {
// Based on MSC1840
[RoomCreateTypeField]: RoomType.Space,
},
initial_state: initialState,
@ -121,6 +129,8 @@ const SpaceCreateMenu = ({ onFinished }) => {
events_default: 100,
...Visibility.Public ? { invite: 0 } : {},
},
room_alias_name: alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
topic,
},
spinner: false,
encryption: false,
@ -159,6 +169,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
<SpaceFeedbackPrompt onClick={onFinished} />
</React.Fragment>;
} else {
const domain = cli.getDomain();
body = <React.Fragment>
<AccessibleTooltipButton
className="mx_SpaceCreateMenu_back"
@ -187,12 +198,30 @@ const SpaceCreateMenu = ({ onFinished }) => {
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => setName(ev.target.value)}
onChange={ev => {
const newName = ev.target.value;
if (!alias || alias === nameToAlias(name, domain)) {
setAlias(nameToAlias(newName, domain));
}
setName(newName);
}}
ref={spaceNameField}
onValidate={spaceNameValidator}
disabled={busy}
/>
{ visibility === Visibility.Public
? <RoomAliasField
ref={spaceAliasField}
onChange={setAlias}
domain={domain}
value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
label={_t("Address")}
/>
: null
}
<Field
name="spaceTopic"
element="textarea"

View file

@ -14,18 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useState } from "react";
import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import {_t} from "../../../languageHandler";
import { _t } from "../../../languageHandler";
import RoomAvatar from "../avatars/RoomAvatar";
import {useContextMenu} from "../../structures/ContextMenu";
import { useContextMenu } from "../../structures/ContextMenu";
import SpaceCreateMenu from "./SpaceCreateMenu";
import {SpaceItem} from "./SpaceTreeLevel";
import { SpaceItem } from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore, {
HOME_SPACE,
UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES,
@ -37,9 +39,10 @@ import {
RovingAccessibleTooltipButton,
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import {Key} from "../../../Keyboard";
import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore";
import {NotificationState} from "../../../stores/notifications/NotificationState";
import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import SettingsStore from "../../../settings/SettingsStore";
interface IButtonProps {
space?: Room;
@ -120,11 +123,65 @@ const useSpaces = (): [Room[], Room[], Room | null] => {
return [invites, spaces, activeSpace];
};
interface IInnerSpacePanelProps {
children?: ReactNode;
isPanelCollapsed: boolean;
setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
}
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : [];
const homeNotificationState = SettingsStore.getValue("feature_spaces.all_rooms")
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return <div className="mx_SpaceTreeLevel">
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={SettingsStore.getValue("feature_spaces.all_rooms") ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState}
isNarrow={isPanelCollapsed}
/>
{ invites.map(s => (
<SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
)) }
{ spaces.map((s, i) => (
<Draggable key={s.roomId} draggableId={s.roomId} index={i}>
{(provided, snapshot) => (
<SpaceItem
{...provided.draggableProps}
{...provided.dragHandleProps}
key={s.roomId}
innerRef={provided.innerRef}
className={snapshot.isDragging
? "mx_SpaceItem_dragging"
: undefined}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
)}
</Draggable>
)) }
{ children }
</div>;
});
const SpacePanel = () => {
// We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
const [invites, spaces, activeSpace] = useSpaces();
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
useEffect(() => {
@ -133,10 +190,6 @@ const SpacePanel = () => {
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
const newClasses = classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
});
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
@ -203,59 +256,61 @@ const SpacePanel = () => {
}
};
const activeSpaces = activeSpace ? [activeSpace] : [];
const expandCollapseButtonTitle = isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel");
// TODO drag and drop for re-arranging order
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => (
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
>
<AutoHideScrollbar className="mx_SpacePanel_spaceTreeWrapper">
<div className="mx_SpaceTreeLevel">
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={_t("All rooms")}
notificationState={RoomNotificationStateStore.instance.globalState}
isNarrow={isPanelCollapsed}
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return (
<DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
}}>
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => (
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
>
<Droppable droppableId="top-level-spaces">
{(provided, snapshot) => (
<AutoHideScrollbar
{...provided.droppableProps}
wrappedRef={provided.innerRef}
className="mx_SpacePanel_spaceTreeWrapper"
style={snapshot.isDraggingOver ? {
pointerEvents: "none",
} : undefined}
>
<InnerSpacePanel
isPanelCollapsed={isPanelCollapsed}
setPanelCollapsed={setPanelCollapsed}
>
{ provided.placeholder }
</InnerSpacePanel>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar>
)}
</Droppable>
<AccessibleTooltipButton
className={classNames("mx_SpacePanel_toggleCollapse", { expanded: !isPanelCollapsed })}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
/>
{ invites.map(s => <SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>) }
{ spaces.map(s => <SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>) }
</div>
<SpaceButton
className={newClasses}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
}}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar>
<AccessibleTooltipButton
className={classNames("mx_SpacePanel_toggleCollapse", {expanded: !isPanelCollapsed})}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={expandCollapseButtonTitle}
/>
{ contextMenu }
</ul>
)}
</RovingTabIndexProvider>
{ contextMenu }
</ul>
)}
</RovingTabIndexProvider>
</DragDropContext>
);
};
export default SpacePanel;

View file

@ -0,0 +1,143 @@
/*
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, { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import SpaceBasicSettings from "./SpaceBasicSettings";
import { avatarUrlForRoom } from "../../../Avatar";
import { IDialogProps } from "../dialogs/IDialogProps";
import { getTopic } from "../elements/RoomTopic";
import { defaultDispatcher } from "../../../dispatcher/dispatcher";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
space: Room;
}
const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProps) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const userId = cli.getUserId();
const [newAvatar, setNewAvatar] = useState<File>(null); // undefined means to remove avatar
const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId);
const avatarChanged = newAvatar !== null;
const [name, setName] = useState<string>(space.name);
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
const nameChanged = name !== space.name;
const currentTopic = getTopic(space);
const [topic, setTopic] = useState<string>(currentTopic);
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
const topicChanged = topic !== currentTopic;
const onCancel = () => {
setNewAvatar(null);
setName(space.name);
setTopic(currentTopic);
};
const onSave = async () => {
setBusy(true);
const promises = [];
if (avatarChanged) {
if (newAvatar) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
}
if (nameChanged) {
promises.push(cli.setRoomName(space.roomId, name));
}
if (topicChanged) {
promises.push(cli.setRoomTopic(space.roomId, topic));
}
const results = await Promise.allSettled(promises);
setBusy(false);
const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) {
console.error("Failed to save space settings: ", failures);
setError(_t("Failed to save space settings."));
}
};
return <div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{ _t("General") }</div>
<div>{ _t("Edit settings relating to your space.") }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
<div className="mx_SettingsTab_section">
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
avatarDisabled={busy || !canSetAvatar}
setAvatar={setNewAvatar}
name={name}
nameDisabled={busy || !canSetName}
setName={setName}
topic={topic}
topicDisabled={busy || !canSetTopic}
setTopic={setTopic}
/>
<AccessibleButton
onClick={onCancel}
disabled={busy || !(avatarChanged || nameChanged || topicChanged)}
kind="link"
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton onClick={onSave} disabled={busy} kind="primary">
{ busy ? _t("Saving...") : _t("Save Changes") }
</AccessibleButton>
</div>
<span className="mx_SettingsTab_subheading">{_t("Leave Space")}</span>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
<AccessibleButton
kind="danger"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave Space") }
</AccessibleButton>
</div>
</div>;
};
export default SpaceSettingsGeneralTab;

View file

@ -0,0 +1,187 @@
/*
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, { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import AliasSettings from "../room_settings/AliasSettings";
import { useStateToggle } from "../../../hooks/useStateToggle";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { GuestAccess, HistoryVisibility, JoinRule } from "../settings/tabs/room/SecurityRoomSettingsTab";
import StyledRadioGroup from "../elements/StyledRadioGroup";
interface IProps {
matrixClient: MatrixClient;
space: Room;
}
enum SpaceVisibility {
Unlisted = "unlisted",
Private = "private",
}
const useLocalEcho = <T extends any>(
currentFactory: () => T,
setterFn: (value: T) => Promise<void>,
errorFn: (error: Error) => void,
): [value: T, handler: (value: T) => void] => {
const [value, setValue] = useState(currentFactory);
const handler = async (value: T) => {
setValue(value);
try {
await setterFn(value);
} catch (e) {
setValue(currentFactory());
errorFn(e);
}
};
return [value, handler];
};
const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
const [error, setError] = useState("");
const userId = cli.getUserId();
const [visibility, setVisibility] = useLocalEcho<SpaceVisibility>(
() => space.getJoinRule() === JoinRule.Private ? SpaceVisibility.Private : SpaceVisibility.Unlisted,
visibility => cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, {
join_rule: visibility === SpaceVisibility.Unlisted ? JoinRule.Public : JoinRule.Private,
}, ""),
() => setError(_t("Failed to update the visibility of this space")),
);
const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho<boolean>(
() => space.currentState.getStateEvents(EventType.RoomGuestAccess, "")
?.getContent()?.guest_access === GuestAccess.CanJoin,
guestAccessEnabled => cli.sendStateEvent(space.roomId, EventType.RoomGuestAccess, {
guest_access: guestAccessEnabled ? GuestAccess.CanJoin : GuestAccess.Forbidden,
}, ""),
() => setError(_t("Failed to update the guest access of this space")),
);
const [historyVisibility, setHistoryVisibility] = useLocalEcho<HistoryVisibility>(
() => space.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")
?.getContent()?.history_visibility || HistoryVisibility.Shared,
historyVisibility => cli.sendStateEvent(space.roomId, EventType.RoomHistoryVisibility, {
history_visibility: historyVisibility,
}, ""),
() => setError(_t("Failed to update the history visibility of this space")),
);
const [showAdvancedSection, toggleAdvancedSection] = useStateToggle();
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
const canSetGuestAccess = space.currentState.maySendStateEvent(EventType.RoomGuestAccess, userId);
const canSetHistoryVisibility = space.currentState.maySendStateEvent(EventType.RoomHistoryVisibility, userId);
const canSetCanonical = space.currentState.mayClientSendStateEvent(EventType.RoomCanonicalAlias, cli);
const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
let advancedSection;
if (showAdvancedSection) {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Hide advanced") }
</AccessibleButton>
<LabelledToggleSwitch
value={guestAccessEnabled}
onChange={setGuestAccessEnabled}
disabled={!canSetGuestAccess}
label={_t("Enable guest access")}
/>
<p>
{ _t("Guests can join a space without having an account.") }
<br />
{ _t("This may be useful for public spaces.") }
</p>
</>;
} else {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Show advanced") }
</AccessibleButton>
</>;
}
let addressesSection;
if (visibility !== SpaceVisibility.Private) {
addressesSection = <>
<span className="mx_SettingsTab_subheading">{_t("Address")}</span>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
<AliasSettings
roomId={space.roomId}
canSetCanonicalAlias={canSetCanonical}
canSetAliases={true}
canonicalAliasEvent={canonicalAliasEv}
hidePublishSetting={true}
/>
</div>
</>;
}
return <div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Visibility") }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<div className="mx_SettingsTab_section">
<div className="mx_SettingsTab_section_caption">
{ _t("Decide who can view and join %(spaceName)s.", { spaceName: space.name }) }
</div>
<div>
<StyledRadioGroup
name="spaceVisibility"
value={visibility}
onChange={setVisibility}
disabled={!canSetJoinRule}
definitions={[
{
value: SpaceVisibility.Unlisted,
label: _t("Public"),
description: _t("anyone with the link can view and join"),
}, {
value: SpaceVisibility.Private,
label: _t("Invite only"),
description: _t("only invited people can view and join"),
},
]}
/>
</div>
{ advancedSection }
<LabelledToggleSwitch
value={historyVisibility === HistoryVisibility.WorldReadable}
onChange={(checked: boolean) => {
setHistoryVisibility(checked ? HistoryVisibility.WorldReadable : HistoryVisibility.Shared);
}}
disabled={!canSetHistoryVisibility}
label={_t("Preview Space")}
/>
<div>{ _t("Allow people to preview your space before they join.") }</div>
<b>{ _t("Recommended for public spaces.") }</b>
</div>
{ addressesSection }
</div>;
};
export default SpaceSettingsVisibilityTab;

View file

@ -14,23 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { createRef, InputHTMLAttributes, LegacyRef } from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import RoomAvatar from "../avatars/RoomAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
import NotificationBadge from "../rooms/NotificationBadge";
import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton";
import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import {_t} from "../../../languageHandler";
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import {toRightOf} from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { toRightOf } from "../../structures/ContextMenu";
import {
shouldShowSpaceSettings,
showAddExistingRooms,
@ -39,33 +38,38 @@ import {
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton, {ButtonEvent} from "../elements/AccessibleButton";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import { Action } from "../../../dispatcher/actions";
import RoomViewStore from "../../../stores/RoomViewStore";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import {NotificationColor} from "../../../stores/notifications/NotificationColor";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
interface IItemProps {
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
space?: Room;
activeSpaces: Room[];
isNested?: boolean;
isPanelCollapsed?: boolean;
onExpand?: Function;
parents?: Set<string>;
innerRef?: LegacyRef<HTMLLIElement>;
}
interface IItemState {
collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
childSpaces: Room[];
}
export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
static contextType = MatrixClientContext;
private buttonRef = createRef<HTMLDivElement>();
constructor(props) {
super(props);
@ -78,14 +82,36 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
this.state = {
collapsed: collapsed,
contextMenuPosition: null,
childSpaces: this.childSpaces,
};
SpaceStore.instance.on(this.props.space.roomId, this.onSpaceUpdate);
}
private toggleCollapse(evt) {
if (this.props.onExpand && this.state.collapsed) {
componentWillUnmount() {
SpaceStore.instance.off(this.props.space.roomId, this.onSpaceUpdate);
}
private onSpaceUpdate = () => {
this.setState({
childSpaces: this.childSpaces,
});
};
private get childSpaces() {
return SpaceStore.instance.getChildSpaces(this.props.space.roomId)
.filter(s => !this.props.parents?.has(s.roomId));
}
private get isCollapsed() {
return this.state.collapsed || this.props.isPanelCollapsed;
}
private toggleCollapse = evt => {
if (this.props.onExpand && this.isCollapsed) {
this.props.onExpand();
}
const newCollapsedState = !this.state.collapsed;
const newCollapsedState = !this.isCollapsed;
SpaceTreeLevelLayoutStore.instance.setSpaceCollapsedState(
this.props.space.roomId,
@ -96,7 +122,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
// don't bubble up so encapsulating button for space
// doesn't get triggered
evt.stopPropagation();
}
};
private onContextMenu = (ev: React.MouseEvent) => {
if (this.props.space.getMyMembership() !== "join") return;
@ -111,6 +137,43 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
});
}
private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true;
const action = getKeyBindingsManager().getRoomListAction(ev);
const hasChildren = this.state.childSpaces?.length;
switch (action) {
case RoomListAction.CollapseSection:
if (hasChildren && !this.isCollapsed) {
this.toggleCollapse(ev);
} else {
const parentItem = this.buttonRef?.current?.parentElement?.parentElement;
const parentButton = parentItem?.previousElementSibling as HTMLElement;
parentButton?.focus();
}
break;
case RoomListAction.ExpandSection:
if (hasChildren) {
if (this.isCollapsed) {
this.toggleCollapse(ev);
} else {
const childLevel = this.buttonRef?.current?.nextElementSibling;
const firstSpaceItemChild = childLevel?.querySelector<HTMLLIElement>(".mx_SpaceItem");
firstSpaceItemChild?.querySelector<HTMLDivElement>(".mx_SpaceButton")?.focus();
}
}
break;
default:
handled = false;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -300,27 +363,25 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
}
render() {
const {space, activeSpaces, isNested} = this.props;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
...otherProps } = this.props;
const forceCollapsed = this.props.isPanelCollapsed;
const isNarrow = this.props.isPanelCollapsed;
const collapsed = this.state.collapsed || forceCollapsed;
const collapsed = this.isCollapsed;
const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId)
.filter(s => !this.props.parents?.has(s.roomId));
const isActive = activeSpaces.includes(space);
const itemClasses = classNames({
const itemClasses = classNames(this.props.className, {
"mx_SpaceItem": true,
"mx_SpaceItem_narrow": isNarrow,
"mx_SpaceItem_narrow": isPanelCollapsed,
"collapsed": collapsed,
"hasSubSpaces": childSpaces && childSpaces.length,
"hasSubSpaces": this.state.childSpaces?.length,
});
const isInvite = space.getMyMembership() === "invite";
const classes = classNames("mx_SpaceButton", {
mx_SpaceButton_active: isActive,
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isNarrow,
mx_SpaceButton_narrow: isPanelCollapsed,
mx_SpaceButton_invite: isInvite,
});
const notificationState = isInvite
@ -328,12 +389,12 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
: SpaceStore.instance.getNotificationState(space.roomId);
let childItems;
if (childSpaces && !collapsed) {
if (this.state.childSpaces?.length && !collapsed) {
childItems = <SpaceTreeLevel
spaces={childSpaces}
spaces={this.state.childSpaces}
activeSpaces={activeSpaces}
isNested={true}
parents={new Set(this.props.parents).add(this.props.space.roomId)}
parents={new Set(parents).add(space.roomId)}
/>;
}
@ -346,53 +407,36 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
const avatarSize = isNested ? 24 : 32;
const toggleCollapseButton = childSpaces && childSpaces.length ?
const toggleCollapseButton = this.state.childSpaces?.length ?
<AccessibleButton
className="mx_SpaceButton_toggleCollapse"
onClick={evt => this.toggleCollapse(evt)}
onClick={this.toggleCollapse}
tabIndex={-1}
aria-label={collapsed ? _t("Expand") : _t("Collapse")}
/> : null;
let button;
if (isNarrow) {
button = (
return (
<li {...otherProps} className={itemClasses} ref={innerRef}>
<RovingAccessibleTooltipButton
className={classes}
title={space.name}
onClick={this.onClick}
onContextMenu={this.onContextMenu}
forceHide={!!this.state.contextMenuPosition}
forceHide={!isPanelCollapsed || !!this.state.contextMenuPosition}
role="treeitem"
aria-expanded={!collapsed}
inputRef={this.buttonRef}
onKeyDown={this.onKeyDown}
>
{ toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ !isPanelCollapsed && <span className="mx_SpaceButton_name">{ space.name }</span> }
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton
className={classes}
onClick={this.onClick}
onContextMenu={this.onContextMenu}
role="treeitem"
>
{ toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
<span className="mx_SpaceButton_name">{ space.name }</span>
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleButton>
);
}
return (
<li className={itemClasses}>
{ button }
{ childItems }
</li>
);

View file

@ -15,8 +15,8 @@ limitations under the License.
*/
import React, {ReactNode} from "react";
import AccessibleButton from "../elements/AccessibleButton";
import FormButton from "../elements/FormButton";
import {XOR} from "../../../@types/common";
export interface IProps {
@ -50,8 +50,12 @@ const GenericToast: React.FC<XOR<IPropsExtended, IProps>> = ({
{detailContent}
</div>
<div className="mx_Toast_buttons" aria-live="off">
{onReject && rejectLabel && <FormButton label={rejectLabel} kind="danger" onClick={onReject} /> }
<FormButton label={acceptLabel} onClick={onAccept} />
{onReject && rejectLabel && <AccessibleButton kind="danger" onClick={onReject}>
{ rejectLabel }
</AccessibleButton> }
<AccessibleButton onClick={onAccept} kind="primary">
{ acceptLabel }
</AccessibleButton>
</div>
</div>;
};

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, {createRef} from 'react';
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import CallMediaHandler from "../../../CallMediaHandler";
import MediaDeviceHandler, { MediaDeviceHandlerEvent } from "../../../MediaDeviceHandler";
interface IProps {
feed: CallFeed,
@ -27,19 +27,25 @@ export default class AudioFeed extends React.Component<IProps> {
private element = createRef<HTMLAudioElement>();
componentDidMount() {
MediaDeviceHandler.instance.addListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
this.onAudioOutputChanged,
);
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.playMedia();
}
componentWillUnmount() {
MediaDeviceHandler.instance.removeListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
this.onAudioOutputChanged,
);
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.stopMedia();
}
private playMedia() {
private onAudioOutputChanged = (audioOutput: string) => {
const element = this.element.current;
const audioOutput = CallMediaHandler.getAudioOutput();
if (audioOutput) {
try {
// This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where
@ -52,7 +58,11 @@ export default class AudioFeed extends React.Component<IProps> {
logger.warn("Couldn't set requested audio output device: using default", e);
}
}
}
private playMedia() {
const element = this.element.current;
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
element.muted = false;
element.srcObject = this.props.feed.stream;
element.autoplay = true;

View file

@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler, { AudioID } from '../../../CallHandler';
import RoomAvatar from '../avatars/RoomAvatar';
import FormButton from '../elements/FormButton';
import AccessibleButton from '../elements/AccessibleButton';
import { CallState } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
@ -143,21 +143,22 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
/>
</div>
<div className="mx_IncomingCallBox_buttons">
<FormButton
<AccessibleButton
className={"mx_IncomingCallBox_decline"}
onClick={this.onRejectClick}
kind="danger"
label={_t("Decline")}
/>
>
{ _t("Decline") }
</AccessibleButton>
<div className="mx_IncomingCallBox_spacer" />
<FormButton
<AccessibleButton
className={"mx_IncomingCallBox_accept"}
onClick={this.onAnswerClick}
kind="primary"
label={_t("Accept")}
/>
>
{ _t("Accept") }
</AccessibleButton>
</div>
</div>;
}
}