1
0
mirror of https://github.com/matrix-org/matrix-react-sdk.git synced 2025-11-14 19:02:33 +03:00

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

This commit is contained in:
Michael Telatynski
2020-05-22 11:18:19 +01:00
74 changed files with 3752 additions and 369 deletions

View File

@@ -26,6 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
import Analytics from "../../Analytics";
import RoomList2 from "../views/rooms/RoomList2";
const LeftPanel = createReactClass({
@@ -273,6 +274,29 @@ const LeftPanel = createReactClass({
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
}
let roomList = null;
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
roomList = <RoomList2
onKeyDown={this._onKeyDown}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ref={this.collectRoomList}
onFocus={this._onFocus}
onBlur={this._onBlur}
/>;
} else {
roomList = <RoomList
onKeyDown={this._onKeyDown}
onFocus={this._onFocus}
onBlur={this._onBlur}
ref={this.collectRoomList}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />;
}
return (
<div className={containerClasses}>
{ tagPanelContainer }
@@ -284,15 +308,7 @@ const LeftPanel = createReactClass({
{ exploreButton }
{ searchBox }
</div>
<RoomList
onKeyDown={this._onKeyDown}
onFocus={this._onFocus}
onBlur={this._onBlur}
ref={this.collectRoomList}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />
{roomList}
</aside>
</div>
);

View File

@@ -31,7 +31,6 @@ import dis from '../../dispatcher/dispatcher';
import sessionStore from '../../stores/SessionStore';
import {MatrixClientPeg, MatrixClientCreds} from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
@@ -42,6 +41,8 @@ import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
import HomePage from "./HomePage";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg";
import { RoomListStoreTempProxy } from "../../stores/room-list/RoomListStoreTempProxy";
import { DefaultTagID } from "../../stores/room-list/models";
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general.
@@ -297,18 +298,18 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
};
onRoomStateEvents = (ev, state) => {
const roomLists = RoomListStore.getRoomLists();
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
const roomLists = RoomListStoreTempProxy.getRoomLists();
if (roomLists[DefaultTagID.ServerNotice] && roomLists[DefaultTagID.ServerNotice].some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
}
};
_updateServerNoticeEvents = async () => {
const roomLists = RoomListStore.getRoomLists();
if (!roomLists['m.server_notice']) return [];
const roomLists = RoomListStoreTempProxy.getRoomLists();
if (!roomLists[DefaultTagID.ServerNotice]) return [];
const pinnedEvents = [];
for (const room of roomLists['m.server_notice']) {
for (const room of roomLists[DefaultTagID.ServerNotice]) {
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;

View File

@@ -60,6 +60,7 @@ import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDisco
import DMRoomMap from '../../utils/DMRoomMap';
import { countRoomsWithNotif } from '../../RoomNotifs';
import { ThemeWatcher } from "../../theme";
import { FontWatcher } from '../../FontWatcher';
import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer, IDeferred } from "../../utils/promise";
import ToastStore from "../../stores/ToastStore";
@@ -216,6 +217,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private readonly loggedInView: React.RefObject<LoggedInViewType>;
private readonly dispatcherRef: any;
private readonly themeWatcher: ThemeWatcher;
private readonly fontWatcher: FontWatcher;
constructor(props, context) {
super(props, context);
@@ -283,8 +285,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.accountPasswordTimer = null;
this.dispatcherRef = dis.register(this.onAction);
this.themeWatcher = new ThemeWatcher();
this.fontWatcher = new FontWatcher();
this.themeWatcher.start();
this.fontWatcher.start();
this.focusComposer = false;
@@ -367,6 +372,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
this.themeWatcher.stop();
this.fontWatcher.stop();
window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);

View File

@@ -29,6 +29,7 @@ import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@@ -109,14 +110,16 @@ export default class MessagePanel extends React.Component {
showReactions: PropTypes.bool,
};
constructor() {
super();
// Force props to be loaded for useIRCLayout
constructor(props) {
super(props);
this.state = {
// previous positions the read marker has been in, so we can
// display 'ghost' read markers that are animating away
ghostReadMarkers: [],
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
useIRCLayout: this.useIRCLayout(SettingsStore.getValue("feature_irc_ui")),
};
// opaque readreceipt info for each userId; used by ReadReceiptMarker
@@ -169,6 +172,8 @@ export default class MessagePanel extends React.Component {
this._showTypingNotificationsWatcherRef =
SettingsStore.watchSetting("showTypingNotifications", null, this.onShowTypingNotificationsChange);
this._layoutWatcherRef = SettingsStore.watchSetting("feature_irc_ui", null, this.onLayoutChange);
}
componentDidMount() {
@@ -178,6 +183,7 @@ export default class MessagePanel extends React.Component {
componentWillUnmount() {
this._isMounted = false;
SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
SettingsStore.unwatchSetting(this._layoutWatcherRef);
}
componentDidUpdate(prevProps, prevState) {
@@ -196,6 +202,17 @@ export default class MessagePanel extends React.Component {
});
};
onLayoutChange = () => {
this.setState({
useIRCLayout: this.useIRCLayout(SettingsStore.getValue("feature_irc_ui")),
});
}
useIRCLayout(ircLayoutSelected) {
// if room is null we are not in a normal room list
return ircLayoutSelected && this.props.room;
}
/* get the DOM node representing the given event */
getNodeForEventId(eventId) {
if (!this.eventNodes) {
@@ -597,6 +614,7 @@ export default class MessagePanel extends React.Component {
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
useIRCLayout={this.state.useIRCLayout}
/>
</TileErrorBoundary>
</li>,
@@ -779,6 +797,8 @@ export default class MessagePanel extends React.Component {
this.props.className,
{
"mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps,
"mx_IRCLayout": this.state.useIRCLayout,
"mx_GroupLayout": !this.state.useIRCLayout,
},
);
@@ -792,6 +812,15 @@ export default class MessagePanel extends React.Component {
);
}
let ircResizer = null;
if (this.state.useIRCLayout) {
ircResizer = <IRCTimelineProfileResizer
minWidth={20}
maxWidth={600}
roomId={this.props.room ? this.props.roomroomId : null}
/>;
}
return (
<ErrorBoundary>
<ScrollPanel
@@ -804,6 +833,7 @@ export default class MessagePanel extends React.Component {
style={style}
stickyBottom={this.props.stickyBottom}
resizeNotifier={this.props.resizeNotifier}
fixedChildren={ircResizer}
>
{ topSpinner }
{ this._getEventTiles() }

View File

@@ -32,7 +32,7 @@ import RoomTile from "../views/rooms/RoomTile";
import LazyRenderList from "../views/elements/LazyRenderList";
import {_t} from "../../languageHandler";
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
import toRem from "../../utils/rem";
import {toPx} from "../../utils/units";
// turn this on for drop & drag console debugging galore
const debug = false;
@@ -420,7 +420,7 @@ export default class RoomSubList extends React.PureComponent {
setHeight = (height) => {
if (this._subList.current) {
this._subList.current.style.height = toRem(height);
this._subList.current.style.height = toPx(height);
}
this._updateLazyRenderHeight(height);
};

View File

@@ -144,6 +144,11 @@ export default createReactClass({
/* 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,
},
getDefaultProps: function() {
@@ -881,6 +886,7 @@ export default createReactClass({
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll}
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">
{ this.props.children }

View File

@@ -24,7 +24,7 @@ import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import toRem from "../../../utils/rem";
import {toPx} from "../../../utils/units";
export default createReactClass({
displayName: 'BaseAvatar',
@@ -166,9 +166,9 @@ export default createReactClass({
const textNode = (
<span className="mx_BaseAvatar_initial" aria-hidden="true"
style={{
fontSize: toRem(width * 0.65),
width: toRem(width),
lineHeight: toRem(height),
fontSize: toPx(width * 0.65),
width: toPx(width),
lineHeight: toPx(height),
}}
>
{ initialLetter }
@@ -179,8 +179,8 @@ export default createReactClass({
alt="" title={title} onError={this.onError}
aria-hidden="true"
style={{
width: toRem(width),
height: toRem(height)
width: toPx(width),
height: toPx(height)
}} />
);
if (onClick != null) {
@@ -210,8 +210,8 @@ export default createReactClass({
onClick={onClick}
onError={this.onError}
style={{
width: toRem(width),
height: toRem(height),
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
inputRef={inputRef}
@@ -224,8 +224,8 @@ export default createReactClass({
src={imageUrl}
onError={this.onError}
style={{
width: toRem(width),
height: toRem(height),
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
ref={inputRef}

View File

@@ -34,9 +34,10 @@ import {humanizeTime} from "../../../utils/humanize";
import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
import {inviteMultipleToRoom} from "../../../RoomInvite";
import SettingsStore from '../../../settings/SettingsStore';
import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
import {DefaultTagID} from "../../../stores/room-list/models";
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
@@ -344,10 +345,10 @@ export default class InviteDialog extends React.PureComponent {
_buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as TAG_DM so we don't miss anything. Sometimes the
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
const taggedRooms = RoomListStore.getRoomLists();
const dmTaggedRooms = taggedRooms[TAG_DM];
const taggedRooms = RoomListStoreTempProxy.getRoomLists();
const dmTaggedRooms = taggedRooms[DefaultTagID.DM];
const myUserId = MatrixClientPeg.get().getUserId();
for (const dmRoom of dmTaggedRooms) {
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);

View File

@@ -22,6 +22,7 @@ import {_t, _td} from "../../../languageHandler";
import GeneralUserSettingsTab from "../settings/tabs/user/GeneralUserSettingsTab";
import SettingsStore from "../../../settings/SettingsStore";
import LabsUserSettingsTab from "../settings/tabs/user/LabsUserSettingsTab";
import AppearanceUserSettingsTab from "../settings/tabs/user/AppearanceUserSettingsTab";
import SecurityUserSettingsTab from "../settings/tabs/user/SecurityUserSettingsTab";
import NotificationUserSettingsTab from "../settings/tabs/user/NotificationUserSettingsTab";
import PreferencesUserSettingsTab from "../settings/tabs/user/PreferencesUserSettingsTab";
@@ -66,6 +67,11 @@ export default class UserSettingsDialog extends React.Component {
"mx_UserSettingsDialog_settingsIcon",
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
tabs.push(new Tab(
_td("Appearance"),
"mx_UserSettingsDialog_appearanceIcon",
<AppearanceUserSettingsTab />,
));
tabs.push(new Tab(
_td("Flair"),
"mx_UserSettingsDialog_flairIcon",

View File

@@ -201,7 +201,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
// `accessSecretStorage` may prompt for storage access as needed.
const recoverInfo = await accessSecretStorage(async () => {
return MatrixClientPeg.get().restoreKeyBackupWithSecretStorage(
this.state.backupInfo,
this.state.backupInfo, undefined, undefined,
{ progressCallback: this._progressCallback },
);
});

View File

@@ -0,0 +1,84 @@
/*
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 from 'react';
interface IProps {
className: string,
dragFunc: (currentLocation: ILocationState, event: MouseEvent) => ILocationState,
onMouseUp: (event: MouseEvent) => void,
}
interface IState {
onMouseMove: (event: MouseEvent) => void,
onMouseUp: (event: MouseEvent) => void,
location: ILocationState,
}
export interface ILocationState {
currentX: number,
currentY: number,
}
export default class Draggable extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
onMouseMove: this.onMouseMove.bind(this),
onMouseUp: this.onMouseUp.bind(this),
location: {
currentX: 0,
currentY: 0,
},
};
}
private onMouseDown = (event: MouseEvent): void => {
this.setState({
location: {
currentX: event.clientX,
currentY: event.clientY,
},
});
document.addEventListener("mousemove", this.state.onMouseMove);
document.addEventListener("mouseup", this.state.onMouseUp);
console.log("Mouse down")
}
private onMouseUp = (event: MouseEvent): void => {
document.removeEventListener("mousemove", this.state.onMouseMove);
document.removeEventListener("mouseup", this.state.onMouseUp);
this.props.onMouseUp(event);
console.log("Mouse up")
}
private onMouseMove(event: MouseEvent): void {
console.log("Mouse Move")
const newLocation = this.props.dragFunc(this.state.location, event);
this.setState({
location: newLocation,
});
}
render() {
return <div className={this.props.className} onMouseDown={this.onMouseDown.bind(this)} />
}
}

View File

@@ -0,0 +1,88 @@
/*
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 from 'react';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Draggable, {ILocationState} from './Draggable';
interface IProps {
// Current room
roomId: string,
minWidth: number,
maxWidth: number,
};
interface IState {
width: number,
IRCLayoutRoot: HTMLElement,
};
export default class IRCTimelineProfileResizer extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
width: SettingsStore.getValue("ircDisplayNameWidth", this.props.roomId),
IRCLayoutRoot: null,
}
};
componentDidMount() {
this.setState({
IRCLayoutRoot: document.querySelector(".mx_IRCLayout") as HTMLElement,
}, () => this.updateCSSWidth(this.state.width))
}
private dragFunc = (location: ILocationState, event: React.MouseEvent<Element, MouseEvent>): ILocationState => {
const offset = event.clientX - location.currentX;
const newWidth = this.state.width + offset;
console.log({offset})
// If we're trying to go smaller than min width, don't.
if (newWidth < this.props.minWidth) {
return location;
}
if (newWidth > this.props.maxWidth) {
return location;
}
this.setState({
width: newWidth,
});
this.updateCSSWidth.bind(this)(newWidth);
return {
currentX: event.clientX,
currentY: location.currentY,
}
}
private updateCSSWidth(newWidth: number) {
this.state.IRCLayoutRoot.style.setProperty("--name-width", newWidth + "px");
}
private onMoueUp(event: MouseEvent) {
if (this.props.roomId) {
SettingsStore.setValue("ircDisplayNameWidth", this.props.roomId, SettingLevel.ROOM_DEVICE, this.state.width);
}
}
render() {
return <Draggable className="mx_ProfileResizer" dragFunc={this.dragFunc.bind(this)} onMouseUp={this.onMoueUp.bind(this)}/>
}
};

View File

@@ -156,16 +156,70 @@ export default class PersistedElement extends React.Component {
child.style.display = visible ? 'block' : 'none';
}
/*
* Clip element bounding rectangle to that of the parent elements.
* This is not a full visibility check, but prevents the persisted
* element from overflowing parent containers when inside a scrolled
* area.
*/
_getClippedBoundingClientRect(element) {
let parentElement = element.parentElement;
let rect = element.getBoundingClientRect();
rect = new DOMRect(rect.left, rect.top, rect.width, rect.height);
while (parentElement) {
const parentRect = parentElement.getBoundingClientRect();
if (parentRect.left > rect.left) {
rect.width = rect.width - (parentRect.left - rect.left);
rect.x = parentRect.x;
}
if (parentRect.top > rect.top) {
rect.height = rect.height - (parentRect.top - rect.top);
rect.y = parentRect.y;
}
if (parentRect.right < rect.right) {
rect.width = rect.width - (rect.right - parentRect.right);
}
if (parentRect.bottom < rect.bottom) {
rect.height = rect.height - (rect.bottom - parentRect.bottom);
}
parentElement = parentElement.parentElement;
}
if (rect.width < 0) rect.width = 0;
if (rect.height < 0) rect.height = 0;
return rect;
}
updateChildPosition(child, parent) {
if (!child || !parent) return;
const parentRect = parent.getBoundingClientRect();
const clipRect = this._getClippedBoundingClientRect(parent);
Object.assign(child.parentElement.style, {
position: 'absolute',
top: clipRect.top + 'px',
left: clipRect.left + 'px',
width: clipRect.width + 'px',
height: clipRect.height + 'px',
overflow: "hidden",
});
Object.assign(child.style, {
position: 'absolute',
top: parentRect.top + 'px',
left: parentRect.left + 'px',
top: (parentRect.top - clipRect.top) + 'px',
left: (parentRect.left - clipRect.left) + 'px',
width: parentRect.width + 'px',
height: parentRect.height + 'px',
overflow: "hidden",
});
}

View File

@@ -37,6 +37,8 @@ export default class ReplyThread extends React.Component {
// called when the ReplyThread contents has changed, including EventTiles thereof
onHeightChanged: PropTypes.func.isRequired,
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use.
useIRCLayout: PropTypes.bool,
};
static contextType = MatrixClientContext;
@@ -176,12 +178,17 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref) {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, useIRCLayout) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div />;
return <div className="mx_ReplyThread_wrapper_empty" />;
}
return <ReplyThread parentEv={parentEv} onHeightChanged={onHeightChanged}
ref={ref} permalinkCreator={permalinkCreator} />;
return <ReplyThread
parentEv={parentEv}
onHeightChanged={onHeightChanged}
ref={ref}
permalinkCreator={permalinkCreator}
useIRCLayout={useIRCLayout}
/>;
}
componentDidMount() {
@@ -331,11 +338,13 @@ export default class ReplyThread extends React.Component {
onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator}
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
useIRCLayout={this.props.useIRCLayout}
/>
</blockquote>;
});
return <div>
return <div className="mx_ReplyThread_wrapper">
<div>{ header }</div>
<div>{ evTiles }</div>
</div>;

View File

@@ -0,0 +1,146 @@
/*
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 * as React from 'react';
interface IProps {
// A callback for the selected value
onSelectionChange: (value: number) => void;
// The current value of the slider
value: number;
// The range and values of the slider
// Currently only supports an ascending, constant interval range
values: number[];
// A function for formatting the the values
displayFunc: (value: number) => string;
// Whether the slider is disabled
disabled: boolean;
}
export default class Slider extends React.Component<IProps> {
// offset is a terrible inverse approximation.
// if the values represents some function f(x) = y where x is the
// index of the array and y = values[x] then offset(f, y) = x
// s.t f(x) = y.
// it assumes a monotonic function and interpolates linearly between
// y values.
// Offset is used for finding the location of a value on a
// non linear slider.
private offset(values: number[], value: number): number {
// the index of the first number greater than value.
let closest = values.reduce((prev, curr) => {
return (value > curr ? prev + 1 : prev);
}, 0);
// Off the left
if (closest === 0) {
return 0;
}
// Off the right
if (closest === values.length) {
return 100;
}
// Now
const closestLessValue = values[closest - 1];
const closestGreaterValue = values[closest];
const intervalWidth = 1 / (values.length - 1);
const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue)
return 100 * (closest - 1 + linearInterpolation) * intervalWidth
}
render(): React.ReactNode {
const dots = this.props.values.map(v =>
<Dot active={v <= this.props.value}
label={this.props.displayFunc(v)}
onClick={this.props.disabled ? () => {} : () => this.props.onSelectionChange(v)}
key={v}
disabled={this.props.disabled}
/>);
let selection = null;
if (!this.props.disabled) {
const offset = this.offset(this.props.values, this.props.value);
selection = <div className="mx_Slider_selection">
<div className="mx_Slider_selectionDot" style={{left: "calc(-0.55em + " + offset + "%)"}} />
<hr style={{width: offset + "%"}} />
</div>
}
return <div className="mx_Slider">
<div>
<div className="mx_Slider_bar">
<hr onClick={this.props.disabled ? () => {} : this.onClick.bind(this)}/>
{ selection }
</div>
<div className="mx_Slider_dotContainer">
{dots}
</div>
</div>
</div>;
}
onClick(event: React.MouseEvent) {
const width = (event.target as HTMLElement).clientWidth;
// nativeEvent is safe to use because https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/offsetX
// is supported by all modern browsers
const relativeClick = (event.nativeEvent.offsetX / width);
const nearestValue = this.props.values[Math.round(relativeClick * (this.props.values.length - 1))];
this.props.onSelectionChange(nearestValue);
}
}
interface IDotProps {
// Callback for behavior onclick
onClick: () => void,
// Whether the dot should appear active
active: boolean,
// The label on the dot
label: string,
// Whether the slider is disabled
disabled: boolean;
}
class Dot extends React.PureComponent<IDotProps> {
render(): React.ReactNode {
let className = "mx_Slider_dot"
if (!this.props.disabled && this.props.active) {
className += " mx_Slider_dotActive";
}
return <span onClick={this.props.onClick} className="mx_Slider_dotValue">
<div className={className} />
<div className="mx_Slider_labelContainer">
<div className="mx_Slider_label">
{this.props.label}
</div>
</div>
</span>;
}
}

View File

@@ -131,7 +131,9 @@ export default createReactClass({
return (
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}>
{ content }
<div className="mx_SenderProfile_hover">
{ content }
</div>
</div>
);
},

View File

@@ -141,6 +141,15 @@ export default createReactClass({
return counters;
},
_onScroll: function(rect) {
if (this.props.onResize) {
this.props.onResize();
}
/* Force refresh of PersistedElements which may be partially hidden */
window.dispatchEvent(new Event('resize'));
},
render: function() {
const CallView = sdk.getComponent("voip.CallView");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
@@ -265,7 +274,7 @@ export default createReactClass({
}
return (
<AutoHideScrollbar className={classes} style={style} >
<AutoHideScrollbar className={classes} style={style} onScroll={this._onScroll}>
{ stateViews }
{ appsDrawer }
{ fileDropTarget }

View File

@@ -34,7 +34,7 @@ import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
import toRem from "../../../utils/rem";
import {toRem} from "../../../utils/units";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
@@ -206,6 +206,9 @@ export default createReactClass({
// whether to show reactions for this event
showReactions: PropTypes.bool,
// whether to use the irc layout
useIRCLayout: PropTypes.bool,
},
getDefaultProps: function() {
@@ -695,6 +698,9 @@ export default createReactClass({
// joins/parts/etc
avatarSize = 14;
needsSenderProfile = false;
} else if (this.props.useIRCLayout) {
avatarSize = 14;
needsSenderProfile = true;
} else if (this.props.continuation && this.props.tileShape !== "file_grid") {
// no avatar or sender profile for continuation messages
avatarSize = 0;
@@ -786,6 +792,17 @@ export default createReactClass({
/>;
}
const linkedTimestamp = <a
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>;
const groupTimestamp = !this.props.useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = this.props.useIRCLayout ? linkedTimestamp : null;
switch (this.props.tileShape) {
case 'notif': {
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
@@ -853,12 +870,11 @@ export default createReactClass({
}
return (
<div className={classes}>
{ ircTimestamp }
{ avatar }
{ sender }
<div className="mx_EventTile_reply">
<a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ groupTimestamp }
{ !isBubbleMessage && this._renderE2EPadlock() }
{ thread }
<EventTileType ref={this._tile}
@@ -877,22 +893,19 @@ export default createReactClass({
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.props.useIRCLayout,
);
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
<div className={classes} tabIndex={-1}>
{ ircTimestamp }
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ sender }
<div className="mx_EventTile_line">
<a
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>
{ groupTimestamp }
{ !isBubbleMessage && this._renderE2EPadlock() }
{ thread }
<EventTileType ref={this._tile}

View File

@@ -381,7 +381,7 @@ export default class MessageComposer extends React.Component {
}
return (
<div className="mx_MessageComposer">
<div className="mx_MessageComposer mx_GroupLayout">
<div className="mx_MessageComposer_wrapper">
<div className="mx_MessageComposer_row">
{ controls }

View File

@@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor";
import * as sdk from "../../../index";
import toRem from "../../../utils/rem";
import {toRem} from "../../../utils/units";
let bounce = false;
try {

View File

@@ -29,7 +29,6 @@ import rate_limited_func from "../../../ratelimitedfunc";
import * as Rooms from '../../../Rooms';
import DMRoomMap from '../../../utils/DMRoomMap';
import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore, {TAG_DM} from '../../../stores/RoomListStore';
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
import GroupStore from '../../../stores/GroupStore';
import RoomSubList from '../../structures/RoomSubList';
@@ -41,6 +40,8 @@ import * as Receipt from "../../../utils/Receipt";
import {Resizer} from '../../../resizer';
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
import {DefaultTagID} from "../../../stores/room-list/models";
import * as Unread from "../../../Unread";
import RoomViewStore from "../../../stores/RoomViewStore";
@@ -161,7 +162,7 @@ export default createReactClass({
this.updateVisibleRooms();
});
this._roomListStoreToken = RoomListStore.addListener(() => {
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
this._delayedRefreshRoomList();
});
@@ -521,7 +522,7 @@ export default createReactClass({
},
getTagNameForRoomId: function(roomId) {
const lists = RoomListStore.getRoomLists();
const lists = RoomListStoreTempProxy.getRoomLists();
for (const tagName of Object.keys(lists)) {
for (const room of lists[tagName]) {
// Should be impossible, but guard anyways.
@@ -541,7 +542,7 @@ export default createReactClass({
},
getRoomLists: function() {
const lists = RoomListStore.getRoomLists();
const lists = RoomListStoreTempProxy.getRoomLists();
const filteredLists = {};
@@ -773,10 +774,10 @@ export default createReactClass({
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists[TAG_DM],
list: this.state.lists[DefaultTagID.DM],
label: _t('Direct Messages'),
tagName: TAG_DM,
incomingCall: incomingCallIfTaggedAs(TAG_DM),
tagName: DefaultTagID.DM,
incomingCall: incomingCallIfTaggedAs(DefaultTagID.DM),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
addRoomLabel: _t("Start chat"),
},
@@ -785,6 +786,7 @@ export default createReactClass({
label: _t('Rooms'),
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
addRoomLabel: _t("Create room"),
},
];
const tagSubLists = Object.keys(this.state.lists)

View File

@@ -0,0 +1,246 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations 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 * as React from "react";
import { _t, _td } from "../../../languageHandler";
import { Layout } from '../../../resizer/distributors/roomsublist2';
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2";
import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { Dispatcher } from "flux";
import dis from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void;
resizeNotifier: ResizeNotifier;
collapsed: boolean;
searchFilter: string;
}
interface IState {
sublists: ITagMap;
}
const TAG_ORDER: TagID[] = [
// -- Community Invites Placeholder --
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
// -- Custom Tags Placeholder --
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Archived,
];
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
const ALWAYS_VISIBLE_TAGS: TagID[] = [
DefaultTagID.DM,
DefaultTagID.Untagged,
];
interface ITagAesthetics {
sectionLabel: string;
addRoomLabel?: string;
onAddRoom?: (dispatcher: Dispatcher<ActionPayload>) => void;
isInvite: boolean;
defaultHidden: boolean;
}
const TAG_AESTHETICS: {
// @ts-ignore - TS wants this to be a string but we know better
[tagId: TagID]: ITagAesthetics;
} = {
[DefaultTagID.Invite]: {
sectionLabel: _td("Invites"),
isInvite: true,
defaultHidden: false,
},
[DefaultTagID.Favourite]: {
sectionLabel: _td("Favourites"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.DM]: {
sectionLabel: _td("Direct Messages"),
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Start chat"),
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_chat'}),
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("Rooms"),
isInvite: false,
defaultHidden: false,
addRoomLabel: _td("Create room"),
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_room'}),
},
[DefaultTagID.LowPriority]: {
sectionLabel: _td("Low priority"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.ServerNotice]: {
sectionLabel: _td("System Alerts"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.Archived]: {
sectionLabel: _td("Historical"),
isInvite: false,
defaultHidden: true,
},
};
export default class RoomList2 extends React.Component<IProps, IState> {
private sublistRefs: { [tagId: string]: React.RefObject<RoomSublist2> } = {};
private sublistSizes: { [tagId: string]: number } = {};
private sublistCollapseStates: { [tagId: string]: boolean } = {};
private unfilteredLayout: Layout;
private filteredLayout: Layout;
constructor(props: IProps) {
super(props);
this.state = {sublists: {}};
this.loadSublistSizes();
this.prepareLayouts();
}
public componentDidMount(): void {
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => {
console.log("new lists", store.orderedLists);
this.setState({sublists: store.orderedLists});
});
}
private loadSublistSizes() {
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
if (sizesJson) this.sublistSizes = JSON.parse(sizesJson);
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson);
}
private saveSublistSizes() {
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes));
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates));
}
private prepareLayouts() {
// TODO: Change layout engine for FTUE support
this.unfilteredLayout = new Layout((tagId: string, height: number) => {
const sublist = this.sublistRefs[tagId];
if (sublist) sublist.current.setHeight(height);
// TODO: Check overflow (see old impl)
// Don't store a height for collapsed sublists
if (!this.sublistCollapseStates[tagId]) {
this.sublistSizes[tagId] = height;
this.saveSublistSizes();
}
}, this.sublistSizes, this.sublistCollapseStates, {
allowWhitespace: false,
handleHeight: 1,
});
this.filteredLayout = new Layout((tagId: string, height: number) => {
const sublist = this.sublistRefs[tagId];
if (sublist) sublist.current.setHeight(height);
}, null, null, {
allowWhitespace: false,
handleHeight: 0,
});
}
private renderSublists(): React.ReactElement[] {
const components: React.ReactElement[] = [];
for (const orderedTagId of TAG_ORDER) {
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
// Populate community invites if we have the chance
// TODO
}
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
// Populate custom tags if needed
// TODO
}
const orderedRooms = this.state.sublists[orderedTagId] || [];
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed
}
const aesthetics: ITagAesthetics = TAG_AESTHETICS[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
components.push(<RoomSublist2
key={`sublist-${orderedTagId}`}
forRooms={true}
rooms={orderedRooms}
startAsHidden={aesthetics.defaultHidden}
label={_t(aesthetics.sectionLabel)}
onAddRoom={onAddRoomFn}
addRoomLabel={aesthetics.addRoomLabel}
isInvite={aesthetics.isInvite}
/>);
}
return components;
}
public render() {
const sublists = this.renderSublists();
return (
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}>
{({onKeyDownHandler}) => (
<div
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onKeyDown={onKeyDownHandler}
className="mx_RoomList"
role="tree"
aria-label={_t("Rooms")}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>{sublists}</div>
)}
</RovingTabIndexProvider>
);
}
}

View File

@@ -0,0 +1,226 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations 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 * as React from "react";
import { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from 'classnames';
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
import * as RoomNotifs from '../../../RoomNotifs';
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../../views/elements/AccessibleButton";
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
import * as FormattingUtils from '../../../utils/FormattingUtils';
import RoomTile2 from "./RoomTile2";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
forRooms: boolean;
rooms?: Room[];
startAsHidden: boolean;
label: string;
onAddRoom?: () => void;
addRoomLabel: string;
isInvite: boolean;
// TODO: Collapsed state
// TODO: Height
// TODO: Group invites
// TODO: Calls
// TODO: forceExpand?
// TODO: Header clicking
// TODO: Spinner support for historical
}
interface IState {
}
export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef();
public setHeight(size: number) {
// TODO: Do a thing (maybe - height changes are different in FTUE)
}
private hasTiles(): boolean {
return this.numTiles > 0;
}
private get numTiles(): number {
// TODO: Account for group invites
return (this.props.rooms || []).length;
}
private onAddRoom = (e) => {
e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom();
};
private renderTiles(): React.ReactElement[] {
const tiles: React.ReactElement[] = [];
if (this.props.rooms) {
for (const room of this.props.rooms) {
tiles.push(<RoomTile2 room={room} key={`room-${room.roomId}`}/>);
}
}
return tiles;
}
private renderHeader(): React.ReactElement {
const notifications = !this.props.isInvite
? RoomNotifs.aggregateNotificationCount(this.props.rooms)
: {count: 0, highlight: true};
const notifCount = notifications.count;
const notifHighlight = notifications.highlight;
// TODO: Title on collapsed
// TODO: Incoming call box
let chevron = null;
if (this.hasTiles()) {
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': false, // isCollapsed
'mx_RoomSubList_chevronDown': true, // !isCollapsed
});
chevron = (<div className={chevronClasses}/>);
}
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
{({onFocus, isActive, ref}) => {
// TODO: Use onFocus
const tabIndex = isActive ? 0 : -1;
// TODO: Collapsed state
let badge;
if (true) { // !isCollapsed
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': notifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (notifCount > 0) {
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
aria-label={_t("Jump to first unread room.")}
>
<div>
{FormattingUtils.formatCount(notifCount)}
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.hasTiles()) {
// Render the `!` badge for invites
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
aria-label={_t("Jump to first invite.")}
>
<div>
{FormattingUtils.formatCount(this.numTiles)}
</div>
</AccessibleButton>
);
}
}
let addRoomButton = null;
if (!!this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
// TODO: a11y (see old component)
return (
<div className={"mx_RoomSubList_labelContainer"}>
<AccessibleButton
inputRef={ref}
tabIndex={tabIndex}
className={"mx_RoomSubList_label"}
role="treeitem"
aria-level="1"
>
{chevron}
<span>{this.props.label}</span>
</AccessibleButton>
{badge}
{addRoomButton}
</div>
);
}}
</RovingTabIndexWrapper>
);
}
public render(): React.ReactElement {
// TODO: Proper rendering
// TODO: Error boundary
const tiles = this.renderTiles();
const classes = classNames({
// TODO: Proper collapse support
'mx_RoomSubList': true,
'mx_RoomSubList_hidden': false, // len && isCollapsed
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
});
let content = null;
if (tiles.length > 0) {
// TODO: Lazy list rendering
// TODO: Whatever scrolling magic needs to happen here
content = (
<IndicatorScrollbar className='mx_RoomSubList_scroll'>
{tiles}
</IndicatorScrollbar>
)
}
// TODO: onKeyDown support
return (
<div
className={classes}
role="group"
aria-label={this.props.label}
>
{this.renderHeader()}
{content}
</div>
);
}
}

View File

@@ -0,0 +1,219 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 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 { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import Tooltip from "../../views/elements/Tooltip";
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import * as RoomNotifs from '../../../RoomNotifs';
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
import * as Unread from '../../../Unread';
import * as FormattingUtils from "../../../utils/FormattingUtils";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
room: Room;
// TODO: Allow falsifying counts (for invites and stuff)
// TODO: Transparency? Was this ever used?
// TODO: Incoming call boxes?
}
interface IBadgeState {
showBadge: boolean; // if numUnread > 0 && !showBadge -> bold room
numUnread: number; // used only if showBadge or showBadgeHighlight is true
hasUnread: number; // used to make the room bold
showBadgeHighlight: boolean; // make the badge red
isInvite: boolean; // show a `!` instead of a number
}
interface IState extends IBadgeState {
hover: boolean;
}
export default class RoomTile2 extends React.Component<IProps, IState> {
private roomTile = createRef();
// TODO: Custom status
// TODO: Lock icon
// TODO: Presence indicator
// TODO: e2e shields
// TODO: Handle changes to room aesthetics (name, join rules, etc)
// TODO: scrollIntoView?
// TODO: hover, badge, etc
// TODO: isSelected for hover effects
// TODO: Context menu
// TODO: a11y
constructor(props: IProps) {
super(props);
this.state = {
hover: false,
...this.getBadgeState(),
};
}
public componentWillUnmount() {
// TODO: Listen for changes to the badge count and update as needed
}
private updateBadgeCount() {
this.setState({...this.getBadgeState()});
}
private getBadgeState(): IBadgeState {
// TODO: Make this code path faster
const highlightCount = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
const numUnread = RoomNotifs.getUnreadNotificationCount(this.props.room);
const showBadge = Unread.doesRoomHaveUnreadMessages(this.props.room);
const myMembership = getEffectiveMembership(this.props.room.getMyMembership());
const isInvite = myMembership === EffectiveMembership.Invite;
const notifState = RoomNotifs.getRoomNotifsState(this.props.room.roomId);
const shouldShowNotifBadge = RoomNotifs.shouldShowNotifBadge(notifState);
const shouldShowHighlightBadge = RoomNotifs.shouldShowMentionBadge(notifState);
return {
showBadge: (showBadge && shouldShowNotifBadge) || isInvite,
numUnread,
hasUnread: showBadge,
showBadgeHighlight: (highlightCount > 0 && shouldShowHighlightBadge) || isInvite,
isInvite,
};
}
private onTileMouseEnter = () => {
this.setState({hover: true});
};
private onTileMouseLeave = () => {
this.setState({hover: false});
};
private onTileClick = (ev: React.KeyboardEvent) => {
dis.dispatch({
action: 'view_room',
// TODO: Support show_room_tile in new room list
show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId,
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
});
};
public render(): React.ReactElement {
// TODO: Collapsed state
// TODO: Invites
// TODO: a11y proper
// TODO: Render more than bare minimum
const classes = classNames({
'mx_RoomTile': true,
// 'mx_RoomTile_selected': this.state.selected,
'mx_RoomTile_unread': this.state.numUnread > 0 || this.state.hasUnread,
'mx_RoomTile_unreadNotify': this.state.showBadge,
'mx_RoomTile_highlight': this.state.showBadgeHighlight,
'mx_RoomTile_invited': this.state.isInvite,
// 'mx_RoomTile_menuDisplayed': isMenuDisplayed,
'mx_RoomTile_noBadges': !this.state.showBadge,
// 'mx_RoomTile_transparent': this.props.transparent,
// 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
});
const avatarClasses = classNames({
'mx_RoomTile_avatar': true,
});
let badge;
if (this.state.showBadge) {
const badgeClasses = classNames({
'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed
});
const formattedCount = this.state.isInvite ? `!` : FormattingUtils.formatCount(this.state.numUnread);
badge = <div className={badgeClasses}>{formattedCount}</div>;
}
// TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name;
if (typeof name !== 'string') name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const nameClasses = classNames({
'mx_RoomTile_name': true,
'mx_RoomTile_invite': this.state.isInvite,
'mx_RoomTile_badgeShown': this.state.showBadge,
});
// TODO: Support collapsed state properly
let tooltip = null;
if (false) { // isCollapsed
if (this.state.hover) {
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto"/>
}
}
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTile}>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onMouseEnter={this.onTileMouseEnter}
onMouseLeave={this.onTileMouseLeave}
onClick={this.onTileClick}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24}/>
</div>
</div>
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
</div>
{badge}
</div>
{tooltip}
</AccessibleButton>
}
</RovingTabIndexWrapper>
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,281 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 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 from 'react';
import {_t} from "../../../../../languageHandler";
import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore";
import * as sdk from "../../../../../index";
import {enumerateThemes, ThemeWatcher} from "../../../../../theme";
import Field from "../../../elements/Field";
import Slider from "../../../elements/Slider";
import AccessibleButton from "../../../elements/AccessibleButton";
import dis from "../../../../../dispatcher/dispatcher";
import { FontWatcher } from "../../../../../FontWatcher";
export default class AppearanceUserSettingsTab extends React.Component {
constructor() {
super();
this.state = {
fontSize: SettingsStore.getValue("fontSize", null),
...this._calculateThemeState(),
customThemeUrl: "",
customThemeMessage: {isError: false, text: ""},
useCustomFontSize: SettingsStore.getValue("useCustomFontSize"),
};
}
_calculateThemeState() {
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
// show the right values for things.
const themeChoice = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "theme");
const systemThemeExplicit = SettingsStore.getValueAt(
SettingLevel.DEVICE, "use_system_theme", null, false, true);
const themeExplicit = SettingsStore.getValueAt(
SettingLevel.DEVICE, "theme", null, false, true);
// If the user has enabled system theme matching, use that.
if (systemThemeExplicit) {
return {
theme: themeChoice,
useSystemTheme: true,
};
}
// If the user has set a theme explicitly, use that (no system theme matching)
if (themeExplicit) {
return {
theme: themeChoice,
useSystemTheme: false,
};
}
// Otherwise assume the defaults for the settings
return {
theme: themeChoice,
useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"),
};
}
_onThemeChange = (e) => {
const newTheme = e.target.value;
if (this.state.theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set,
// so remember what the value was before we tried to set it so we can revert
const oldTheme = SettingsStore.getValue('theme');
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme).catch(() => {
dis.dispatch({action: 'recheck_theme'});
this.setState({theme: oldTheme});
});
this.setState({theme: newTheme});
// The settings watcher doesn't fire until the echo comes back from the
// server, so to make the theme change immediately we need to manually
// do the dispatch now
// XXX: The local echoed value appears to be unreliable, in particular
// when settings custom themes(!) so adding forceTheme to override
// the value from settings.
dis.dispatch({action: 'recheck_theme', forceTheme: newTheme});
};
_onUseSystemThemeChanged = (checked) => {
this.setState({useSystemTheme: checked});
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
dis.dispatch({action: 'recheck_theme'});
};
_onFontSizeChanged = (size) => {
this.setState({fontSize: size});
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, size);
};
_onValidateFontSize = ({value}) => {
console.log({value});
const parsedSize = parseFloat(value);
const min = FontWatcher.MIN_SIZE;
const max = FontWatcher.MAX_SIZE;
if (isNaN(parsedSize)) {
return {valid: false, feedback: _t("Size must be a number")};
}
if (!(min <= parsedSize && parsedSize <= max)) {
return {
valid: false,
feedback: _t('Custom font size can only be between %(min)s pt and %(max)s pt', {min, max}),
};
}
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, value);
return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})};
}
_onAddCustomTheme = async () => {
let currentThemes = SettingsStore.getValue("custom_themes");
if (!currentThemes) currentThemes = [];
currentThemes = currentThemes.map(c => c); // cheap clone
if (this._themeTimer) {
clearTimeout(this._themeTimer);
}
try {
const r = await fetch(this.state.customThemeUrl);
const themeInfo = await r.json();
if (!themeInfo || typeof(themeInfo['name']) !== 'string' || typeof(themeInfo['colors']) !== 'object') {
this.setState({customThemeMessage: {text: _t("Invalid theme schema."), isError: true}});
return;
}
currentThemes.push(themeInfo);
} catch (e) {
console.error(e);
this.setState({customThemeMessage: {text: _t("Error downloading theme information."), isError: true}});
return; // Don't continue on error
}
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
this.setState({customThemeUrl: "", customThemeMessage: {text: _t("Theme added!"), isError: false}});
this._themeTimer = setTimeout(() => {
this.setState({customThemeMessage: {text: "", isError: false}});
}, 3000);
};
_onCustomThemeChange = (e) => {
this.setState({customThemeUrl: e.target.value});
};
render() {
return (
<div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{_t("Appearance")}</div>
{this._renderThemeSection()}
{SettingsStore.isFeatureEnabled("feature_font_scaling") ? this._renderFontSection() : null}
</div>
);
}
_renderThemeSection() {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
const LabelledToggleSwitch = sdk.getComponent("views.elements.LabelledToggleSwitch");
const themeWatcher = new ThemeWatcher();
let systemThemeSection;
if (themeWatcher.isSystemThemeSupported()) {
systemThemeSection = <div>
<LabelledToggleSwitch
value={this.state.useSystemTheme}
label={SettingsStore.getDisplayName("use_system_theme")}
onChange={this._onUseSystemThemeChanged}
/>
</div>;
}
let customThemeForm;
if (SettingsStore.isFeatureEnabled("feature_custom_themes")) {
let messageElement = null;
if (this.state.customThemeMessage.text) {
if (this.state.customThemeMessage.isError) {
messageElement = <div className='text-error'>{this.state.customThemeMessage.text}</div>;
} else {
messageElement = <div className='text-success'>{this.state.customThemeMessage.text}</div>;
}
}
customThemeForm = (
<div className='mx_SettingsTab_section'>
<form onSubmit={this._onAddCustomTheme}>
<Field
label={_t("Custom theme URL")}
type='text'
id='mx_GeneralUserSettingsTab_customThemeInput'
autoComplete="off"
onChange={this._onCustomThemeChange}
value={this.state.customThemeUrl}
/>
<AccessibleButton
onClick={this._onAddCustomTheme}
type="submit" kind="primary_sm"
disabled={!this.state.customThemeUrl.trim()}
>{_t("Add theme")}</AccessibleButton>
{messageElement}
</form>
</div>
);
}
const themes = Object.entries(enumerateThemes())
.map(p => ({id: p[0], name: p[1]})); // convert pairs to objects for code readability
const builtInThemes = themes.filter(p => !p.id.startsWith("custom-"));
const customThemes = themes.filter(p => !builtInThemes.includes(p))
.sort((a, b) => a.name.localeCompare(b.name));
const orderedThemes = [...builtInThemes, ...customThemes];
return (
<div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_themeSection">
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
{systemThemeSection}
<Field
id="theme" label={_t("Theme")} element="select"
value={this.state.theme} onChange={this._onThemeChange}
disabled={this.state.useSystemTheme}
>
{orderedThemes.map(theme => {
return <option key={theme.id} value={theme.id}>{theme.name}</option>;
})}
</Field>
{customThemeForm}
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} />
</div>
);
}
_renderFontSection() {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_fontScaling">
<span className="mx_SettingsTab_subheading">{_t("Font size")}</span>
<div className="mx_AppearanceUserSettingsTab_fontSlider">
<div className="mx_AppearanceUserSettingsTab_fontSlider_smallText">Aa</div>
<Slider
values={[13, 15, 16, 18, 20]}
value={this.state.fontSize}
onSelectionChange={this._onFontSizeChanged}
displayFunc={value => {}}
disabled={this.state.useCustomFontSize}
/>
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
</div>
<SettingsFlag
name="useCustomFontSize"
level={SettingLevel.ACCOUNT}
onChange={(checked)=> this.setState({useCustomFontSize: checked})}
/>
<Field
type="text"
label={_t("Font size")}
autoComplete="off"
placeholder={this.state.fontSize.toString()}
value={this.state.fontSize.toString()}
id="font_size_field"
onValidate={this._onValidateFontSize}
onChange={(value) => this.setState({fontSize: value.target.value})}
disabled={!this.state.useCustomFontSize}
/>
</div>;
}
}

View File

@@ -19,7 +19,6 @@ limitations under the License.
import React from 'react';
import {_t} from "../../../../../languageHandler";
import ProfileSettings from "../../ProfileSettings";
import Field from "../../../elements/Field";
import * as languageHandler from "../../../../../languageHandler";
import {SettingLevel} from "../../../../../settings/SettingsStore";
import SettingsStore from "../../../../../settings/SettingsStore";
@@ -27,7 +26,6 @@ import LanguageDropdown from "../../../elements/LanguageDropdown";
import AccessibleButton from "../../../elements/AccessibleButton";
import DeactivateAccountDialog from "../../../dialogs/DeactivateAccountDialog";
import PropTypes from "prop-types";
import {enumerateThemes, ThemeWatcher} from "../../../../../theme";
import PlatformPeg from "../../../../../PlatformPeg";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../..";
@@ -62,9 +60,6 @@ export default class GeneralUserSettingsTab extends React.Component {
emails: [],
msisdns: [],
loading3pids: true, // whether or not the emails and msisdns have been loaded
...this._calculateThemeState(),
customThemeUrl: "",
customThemeMessage: {isError: false, text: ""},
};
this.dispatcherRef = dis.register(this._onAction);
@@ -93,39 +88,6 @@ export default class GeneralUserSettingsTab extends React.Component {
dis.unregister(this.dispatcherRef);
}
_calculateThemeState() {
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
// show the right values for things.
const themeChoice = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "theme");
const systemThemeExplicit = SettingsStore.getValueAt(
SettingLevel.DEVICE, "use_system_theme", null, false, true);
const themeExplicit = SettingsStore.getValueAt(
SettingLevel.DEVICE, "theme", null, false, true);
// If the user has enabled system theme matching, use that.
if (systemThemeExplicit) {
return {
theme: themeChoice,
useSystemTheme: true,
};
}
// If the user has set a theme explicitly, use that (no system theme matching)
if (themeExplicit) {
return {
theme: themeChoice,
useSystemTheme: false,
};
}
// Otherwise assume the defaults for the settings
return {
theme: themeChoice,
useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"),
};
}
_onAction = (payload) => {
if (payload.action === 'id_server_changed') {
this.setState({haveIdServer: Boolean(MatrixClientPeg.get().getIdentityServerUrl())});
@@ -219,33 +181,6 @@ export default class GeneralUserSettingsTab extends React.Component {
PlatformPeg.get().reload();
};
_onThemeChange = (e) => {
const newTheme = e.target.value;
if (this.state.theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set,
// so remember what the value was before we tried to set it so we can revert
const oldTheme = SettingsStore.getValue('theme');
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme).catch(() => {
dis.dispatch({action: 'recheck_theme'});
this.setState({theme: oldTheme});
});
this.setState({theme: newTheme});
// The settings watcher doesn't fire until the echo comes back from the
// server, so to make the theme change immediately we need to manually
// do the dispatch now
// XXX: The local echoed value appears to be unreliable, in particular
// when settings custom themes(!) so adding forceTheme to override
// the value from settings.
dis.dispatch({action: 'recheck_theme', forceTheme: newTheme});
};
_onUseSystemThemeChanged = (checked) => {
this.setState({useSystemTheme: checked});
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
dis.dispatch({action: 'recheck_theme'});
};
_onPasswordChangeError = (err) => {
// TODO: Figure out a design that doesn't involve replacing the current dialog
let errMsg = err.error || "";
@@ -282,41 +217,6 @@ export default class GeneralUserSettingsTab extends React.Component {
});
};
_onAddCustomTheme = async () => {
let currentThemes = SettingsStore.getValue("custom_themes");
if (!currentThemes) currentThemes = [];
currentThemes = currentThemes.map(c => c); // cheap clone
if (this._themeTimer) {
clearTimeout(this._themeTimer);
}
try {
const r = await fetch(this.state.customThemeUrl);
const themeInfo = await r.json();
if (!themeInfo || typeof(themeInfo['name']) !== 'string' || typeof(themeInfo['colors']) !== 'object') {
this.setState({customThemeMessage: {text: _t("Invalid theme schema."), isError: true}});
return;
}
currentThemes.push(themeInfo);
} catch (e) {
console.error(e);
this.setState({customThemeMessage: {text: _t("Error downloading theme information."), isError: true}});
return; // Don't continue on error
}
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
this.setState({customThemeUrl: "", customThemeMessage: {text: _t("Theme added!"), isError: false}});
this._themeTimer = setTimeout(() => {
this.setState({customThemeMessage: {text: "", isError: false}});
}, 3000);
};
_onCustomThemeChange = (e) => {
this.setState({customThemeUrl: e.target.value});
};
_renderProfileSection() {
return (
<div className="mx_SettingsTab_section">
@@ -401,77 +301,6 @@ export default class GeneralUserSettingsTab extends React.Component {
);
}
_renderThemeSection() {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
const LabelledToggleSwitch = sdk.getComponent("views.elements.LabelledToggleSwitch");
const themeWatcher = new ThemeWatcher();
let systemThemeSection;
if (themeWatcher.isSystemThemeSupported()) {
systemThemeSection = <div>
<LabelledToggleSwitch
value={this.state.useSystemTheme}
label={SettingsStore.getDisplayName("use_system_theme")}
onChange={this._onUseSystemThemeChanged}
/>
</div>;
}
let customThemeForm;
if (SettingsStore.isFeatureEnabled("feature_custom_themes")) {
let messageElement = null;
if (this.state.customThemeMessage.text) {
if (this.state.customThemeMessage.isError) {
messageElement = <div className='text-error'>{this.state.customThemeMessage.text}</div>;
} else {
messageElement = <div className='text-success'>{this.state.customThemeMessage.text}</div>;
}
}
customThemeForm = (
<div className='mx_SettingsTab_section'>
<form onSubmit={this._onAddCustomTheme}>
<Field
label={_t("Custom theme URL")}
type='text'
autoComplete="off"
onChange={this._onCustomThemeChange}
value={this.state.customThemeUrl}
/>
<AccessibleButton
onClick={this._onAddCustomTheme}
type="submit" kind="primary_sm"
disabled={!this.state.customThemeUrl.trim()}
>{_t("Add theme")}</AccessibleButton>
{messageElement}
</form>
</div>
);
}
const themes = Object.entries(enumerateThemes())
.map(p => ({id: p[0], name: p[1]})); // convert pairs to objects for code readability
const builtInThemes = themes.filter(p => !p.id.startsWith("custom-"));
const customThemes = themes.filter(p => !builtInThemes.includes(p))
.sort((a, b) => a.name.localeCompare(b.name));
const orderedThemes = [...builtInThemes, ...customThemes];
return (
<div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_themeSection">
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
{systemThemeSection}
<Field label={_t("Theme")} element="select"
value={this.state.theme} onChange={this._onThemeChange}
disabled={this.state.useSystemTheme}
>
{orderedThemes.map(theme => {
return <option key={theme.id} value={theme.id}>{theme.name}</option>;
})}
</Field>
{customThemeForm}
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} />
</div>
);
}
_renderDiscoverySection() {
const SetIdServer = sdk.getComponent("views.settings.SetIdServer");
@@ -560,7 +389,6 @@ export default class GeneralUserSettingsTab extends React.Component {
{this._renderProfileSection()}
{this._renderAccountSection()}
{this._renderLanguageSection()}
{this._renderThemeSection()}
<div className="mx_SettingsTab_heading">{discoWarning} {_t("Discovery")}</div>
{this._renderDiscoverySection()}
{this._renderIntegrationManagerSection() /* Has its own title */}