Merge branch 'develop' into gsouquet/switch-rooms

This commit is contained in:
Germain Souquet 2021-05-26 09:51:27 +01:00
commit c7c6a818f0
89 changed files with 2555 additions and 1232 deletions

View file

@ -119,7 +119,10 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
if (this.props.room.roomId !== room.roomId) return;
if (ev.getType() === 'm.room.join_rules' || ev.getType() === 'm.room.member') {
this.setState({icon: this.calculateIcon()});
const newIcon = this.calculateIcon();
if (newIcon !== this.state.icon) {
this.setState({icon: newIcon});
}
}
};

View file

@ -38,6 +38,7 @@ import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/Recent
import ProgressBar from "../elements/ProgressBar";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -74,37 +75,47 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
onFinished,
}) => {
const cli = useContext(MatrixClientContext);
const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]);
const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [progress, setProgress] = useState<number>(null);
const [error, setError] = useState<Error>(null);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const lcQuery = query.toLowerCase().trim();
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const existingSubspacesSet = new Set(existingSubspaces);
const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId));
const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]);
const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]);
const joinRule = space.getJoinRule();
const [spaces, rooms, dms] = visibleRooms.reduce((arr, room) => {
if (room.getMyMembership() !== "join") return arr;
if (!room.name.toLowerCase().includes(lcQuery)) return arr;
const [spaces, rooms, dms] = useMemo(() => {
let rooms = visibleRooms;
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
if (lcQuery) {
const matcher = new QueryMatcher<Room>(visibleRooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
rooms = matcher.match(lcQuery);
}
return arr;
}, [[], [], []]);
const joinRule = space.getJoinRule();
return sortRooms(rooms).reduce((arr, room) => {
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
}
return arr;
}, [[], [], []]);
}, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]);
const addRooms = async () => {
setError(null);

View file

@ -1,6 +1,6 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
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.
@ -15,27 +15,46 @@ 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, {ChangeEvent, createRef, KeyboardEvent, SyntheticEvent} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import withValidation from '../elements/Validation';
import { _t } from '../../../languageHandler';
import withValidation, {IFieldState} from '../elements/Validation';
import {_t} from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard";
import {privateShouldBeEncrypted} from "../../../createRoom";
import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
interface IProps {
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
onFinished(proceed: boolean, opts?: IOpts): void;
}
interface IState {
isPublic: boolean;
isEncrypted: boolean;
name: string;
topic: string;
alias: string;
detailsOpen: boolean;
noFederate: boolean;
nameIsValid: boolean;
canChangeEncryption: boolean;
}
@replaceableComponent("views.dialogs.CreateRoomDialog")
export default class CreateRoomDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
defaultPublic: PropTypes.bool,
parentSpace: PropTypes.instanceOf(Room),
};
export default class CreateRoomDialog extends React.Component<IProps, IState> {
private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>();
constructor(props) {
super(props);
@ -44,7 +63,7 @@ export default class CreateRoomDialog extends React.Component {
this.state = {
isPublic: this.props.defaultPublic || false,
isEncrypted: privateShouldBeEncrypted(),
name: "",
name: this.props.defaultName || "",
topic: "",
alias: "",
detailsOpen: false,
@ -54,26 +73,25 @@ export default class CreateRoomDialog extends React.Component {
};
MatrixClientPeg.get().doesServerForceEncryptionForPreset("private")
.then(isForced => this.setState({canChangeEncryption: !isForced}));
.then(isForced => this.setState({ canChangeEncryption: !isForced }));
}
_roomCreateOptions() {
const opts = {};
const createOpts = opts.createOpts = {};
private roomCreateOptions() {
const opts: IOpts = {};
const createOpts: IOpts["createOpts"] = opts.createOpts = {};
createOpts.name = this.state.name;
if (this.state.isPublic) {
createOpts.visibility = "public";
createOpts.preset = "public_chat";
createOpts.visibility = Visibility.Public;
createOpts.preset = Preset.PublicChat;
opts.guestAccess = false;
const {alias} = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart;
const { alias } = this.state;
createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
}
if (this.state.topic) {
createOpts.topic = this.state.topic;
}
if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false};
createOpts.creation_content = { 'm.federate': false };
}
if (!this.state.isPublic) {
@ -98,16 +116,14 @@ export default class CreateRoomDialog extends React.Component {
}
componentDidMount() {
this._detailsRef.addEventListener("toggle", this.onDetailsToggled);
// move focus to first field when showing dialog
this._nameFieldRef.focus();
this.nameField.current.focus();
}
componentWillUnmount() {
this._detailsRef.removeEventListener("toggle", this.onDetailsToggled);
}
_onKeyDown = event => {
private onKeyDown = (event: KeyboardEvent) => {
if (event.key === Key.ENTER) {
this.onOk();
event.preventDefault();
@ -115,26 +131,26 @@ export default class CreateRoomDialog extends React.Component {
}
};
onOk = async () => {
const activeElement = document.activeElement;
private onOk = async () => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
await this._nameFieldRef.validate({allowEmpty: false});
if (this._aliasFieldRef) {
await this._aliasFieldRef.validate({allowEmpty: false});
await this.nameField.current.validate({allowEmpty: false});
if (this.aliasField.current) {
await this.aliasField.current.validate({allowEmpty: false});
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) {
this.props.onFinished(true, this._roomCreateOptions());
await new Promise<void>(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) {
this.props.onFinished(true, this.roomCreateOptions());
} else {
let field;
if (!this.state.nameIsValid) {
field = this._nameFieldRef;
} else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) {
field = this._aliasFieldRef;
field = this.nameField.current;
} else if (this.aliasField.current && !this.aliasField.current.isValid) {
field = this.aliasField.current;
}
if (field) {
field.focus();
@ -143,49 +159,45 @@ export default class CreateRoomDialog extends React.Component {
}
};
onCancel = () => {
private onCancel = () => {
this.props.onFinished(false);
};
onNameChange = ev => {
this.setState({name: ev.target.value});
private onNameChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ name: ev.target.value });
};
onTopicChange = ev => {
this.setState({topic: ev.target.value});
private onTopicChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ topic: ev.target.value });
};
onPublicChange = isPublic => {
this.setState({isPublic});
private onPublicChange = (isPublic: boolean) => {
this.setState({ isPublic });
};
onEncryptedChange = isEncrypted => {
this.setState({isEncrypted});
private onEncryptedChange = (isEncrypted: boolean) => {
this.setState({ isEncrypted });
};
onAliasChange = alias => {
this.setState({alias});
private onAliasChange = (alias: string) => {
this.setState({ alias });
};
onDetailsToggled = ev => {
this.setState({detailsOpen: ev.target.open});
private onDetailsToggled = (ev: SyntheticEvent<HTMLDetailsElement>) => {
this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open });
};
onNoFederateChange = noFederate => {
this.setState({noFederate});
private onNoFederateChange = (noFederate: boolean) => {
this.setState({ noFederate });
};
collectDetailsRef = ref => {
this._detailsRef = ref;
};
onNameValidate = async fieldState => {
const result = await CreateRoomDialog._validateRoomName(fieldState);
private onNameValidate = async (fieldState: IFieldState) => {
const result = await CreateRoomDialog.validateRoomName(fieldState);
this.setState({nameIsValid: result.valid});
return result;
};
static _validateRoomName = withValidation({
private static validateRoomName = withValidation({
rules: [
{
key: "required",
@ -196,18 +208,17 @@ export default class CreateRoomDialog extends React.Component {
});
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
let aliasField;
if (this.state.isPublic) {
const domain = MatrixClientPeg.get().getDomain();
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
<RoomAliasField ref={ref => this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} />
<RoomAliasField
ref={this.aliasField}
onChange={this.onAliasChange}
domain={domain}
value={this.state.alias}
/>
</div>
);
}
@ -270,16 +281,34 @@ export default class CreateRoomDialog extends React.Component {
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={title}
>
<form onSubmit={this.onOk} onKeyDown={this._onKeyDown}>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content">
<Field ref={ref => this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" />
<Field label={ _t('Topic (optional)') } onChange={this.onTopicChange} value={this.state.topic} className="mx_CreateRoomDialog_topic" />
<LabelledToggleSwitch label={ _t("Make this room public")} onChange={this.onPublicChange} value={this.state.isPublic} />
<Field
ref={this.nameField}
label={_t('Name')}
onChange={this.onNameChange}
onValidate={this.onNameValidate}
value={this.state.name}
className="mx_CreateRoomDialog_name"
/>
<Field
label={_t('Topic (optional)')}
onChange={this.onTopicChange}
value={this.state.topic}
className="mx_CreateRoomDialog_topic"
/>
<LabelledToggleSwitch
label={_t("Make this room public")}
onChange={this.onPublicChange}
value={this.state.isPublic}
/>
{ publicPrivateLabel }
{ e2eeSection }
{ aliasField }
<details ref={this.collectDetailsRef} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }</summary>
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">
{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }
</summary>
<LabelledToggleSwitch
label={_t(
"Block anyone not part of %(serverName)s from ever joining this room.",

View file

@ -30,7 +30,6 @@ import ToggleSwitch from "../elements/ToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {allSettled} from "../../../utils/promise";
import {useDispatcher} from "../../../hooks/useDispatcher";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
@ -91,7 +90,7 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, ""));
}
const results = await allSettled(promises);
const results = await Promise.allSettled(promises);
setBusy(false);
const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) {

View file

@ -1,7 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2016, 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.
@ -16,39 +15,43 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from "react";
import { MatrixError } from "matrix-js-sdk/src/http-api";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
import {
ChevronFace,
ContextMenu,
useContextMenu,
ContextMenuButton,
MenuItemRadio,
MenuItem,
MenuGroup,
MenuItem,
MenuItemRadio,
useContextMenu,
} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import {useSettingValue} from "../../../hooks/useSettings";
import * as sdk from "../../../index";
import { useSettingValue } from "../../../hooks/useSettings";
import Modal from "../../../Modal";
import SettingsStore from "../../../settings/SettingsStore";
import withValidation from "../elements/Validation";
import { SettingLevel } from "../../../settings/SettingLevel";
import TextInputDialog from "../dialogs/TextInputDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import UIStore from "../../../stores/UIStore";
export const ALL_ROOMS = Symbol("ALL_ROOMS");
const SETTING_NAME = "room_directory_servers";
const inPlaceOf = (elementRect) => ({
right: window.innerWidth - elementRect.right,
const inPlaceOf = (elementRect: Pick<DOMRect, "right" | "top">) => ({
right: UIStore.instance.windowWidth - elementRect.right,
top: elementRect.top,
chevronOffset: 0,
chevronFace: "none",
chevronFace: ChevronFace.None,
});
const validServer = withValidation({
const validServer = withValidation<undefined, { error?: MatrixError }>({
deriveData: async ({ value }) => {
try {
// check if we can successfully load this server's room directory
@ -78,15 +81,49 @@ const validServer = withValidation({
],
});
/* eslint-disable camelcase */
export interface IFieldType {
regexp: string;
placeholder: string;
}
export interface IInstance {
desc: string;
icon?: string;
fields: object;
network_id: string;
// XXX: this is undocumented but we rely on it.
// we inject a fake entry with a symbolic instance_id.
instance_id: string | symbol;
}
export interface IProtocol {
user_fields: string[];
location_fields: string[];
icon: string;
field_types: Record<string, IFieldType>;
instances: IInstance[];
}
/* eslint-enable camelcase */
export type Protocols = Record<string, IProtocol>;
interface IProps {
protocols: Protocols;
selectedServerName: string;
selectedInstanceId: string | symbol;
onOptionChange(server: string, instanceId?: string | symbol): void;
}
// This dropdown sources homeservers from three places:
// + your currently connected homeserver
// + homeservers in config.json["roomDirectory"]
// + homeservers in SettingsStore["room_directory_servers"]
// if a server exists in multiple, only keep the top-most entry.
const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
const _userDefinedServers = useSettingValue(SETTING_NAME);
const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const _userDefinedServers: string[] = useSettingValue(SETTING_NAME);
const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
const handlerFactory = (server, instanceId) => {
@ -98,7 +135,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const setUserDefinedServers = servers => {
_setUserDefinedServers(servers);
SettingsStore.setValue(SETTING_NAME, null, "account", servers);
SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, servers);
};
// keep local echo up to date with external changes
useEffect(() => {
@ -112,7 +149,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const roomDirectory = config.roomDirectory || {};
const hsName = MatrixClientPeg.getHomeserverName();
const configServers = new Set(roomDirectory.servers);
const configServers = new Set<string>(roomDirectory.servers);
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
@ -136,9 +173,15 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
// add a fake protocol with the ALL_ROOMS symbol
protocolsList.push({
instances: [{
fields: [],
network_id: "",
instance_id: ALL_ROOMS,
desc: _t("All rooms"),
}],
location_fields: [],
user_fields: [],
field_types: {},
icon: "",
});
}
@ -172,7 +215,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
if (removableServers.has(server)) {
const onClick = async () => {
closeMenu();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, {
title: _t("Are you sure?"),
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
@ -191,7 +233,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
setUserDefinedServers(servers.filter(s => s !== server));
// the selected server is being removed, reset to our HS
if (serverSelected === server) {
if (serverSelected) {
onOptionChange(hsName, undefined);
}
};
@ -223,7 +265,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const onClick = async () => {
closeMenu();
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, {
title: _t("Add a new server"),
description: _t("Enter the name of a new server you want to explore."),
@ -284,9 +325,4 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
</div>;
};
NetworkDropdown.propTypes = {
onOptionChange: PropTypes.func.isRequired,
protocols: PropTypes.object,
};
export default NetworkDropdown;

View file

@ -17,7 +17,8 @@
import React, { FunctionComponent, useEffect, useRef } from 'react';
import dis from '../../../dispatcher/dispatcher';
import ICanvasEffect from '../../../effects/ICanvasEffect';
import {CHAT_EFFECTS} from '../../../effects'
import { CHAT_EFFECTS } from '../../../effects'
import UIStore, { UI_EVENTS } from "../../../stores/UIStore";
interface IProps {
roomWidth: number;
@ -45,8 +46,8 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
useEffect(() => {
const resize = () => {
if (canvasRef.current) {
canvasRef.current.height = window.innerHeight;
if (canvasRef.current && canvasRef.current?.height !== UIStore.instance.windowHeight) {
canvasRef.current.height = UIStore.instance.windowHeight;
}
};
const onAction = (payload: { action: string }) => {
@ -58,12 +59,12 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
}
const dispatcherRef = dis.register(onAction);
const canvas = canvasRef.current;
canvas.height = window.innerHeight;
window.addEventListener('resize', resize, true);
canvas.height = UIStore.instance.windowHeight;
UIStore.instance.on(UI_EVENTS.Resize, resize);
return () => {
dis.unregister(dispatcherRef);
window.removeEventListener('resize', resize);
UIStore.instance.off(UI_EVENTS.Resize, resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
for (const effect in currentEffects) {

View file

@ -16,32 +16,31 @@ limitations under the License.
import React from "react";
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
w?: number;
h?: number;
children?: React.ReactNode;
}
@replaceableComponent("views.elements.InlineSpinner")
export default class InlineSpinner extends React.Component {
export default class InlineSpinner extends React.PureComponent<IProps> {
static defaultProps = {
w: 16,
h: 16,
}
render() {
const w = this.props.w || 16;
const h = this.props.h || 16;
const imgClass = this.props.imgClassName || "";
let imageSource;
if (SettingsStore.getValue('feature_new_spinner')) {
imageSource = require("../../../../res/img/spinner.svg");
} else {
imageSource = require("../../../../res/img/spinner.gif");
}
return (
<div className="mx_InlineSpinner">
<img
src={imageSource}
width={w}
height={h}
className={imgClass}
<div
className="mx_InlineSpinner_icon mx_Spinner_icon"
style={{width: this.props.w, height: this.props.h}}
aria-label={_t("Loading...")}
/>
>
{this.props.children}
</div>
</div>
);
}

View file

@ -19,6 +19,7 @@ import {EventType} from 'matrix-js-sdk/src/@types/event';
import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton";
import Spinner from "./Spinner";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useTimeout} from "../../../hooks/useTimeout";
import Analytics from "../../../Analytics";
@ -88,6 +89,12 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
>
{ children }
<div className="mx_MiniAvatarUploader_indicator">
{ busy ?
<Spinner w={20} h={20} /> :
<div className="mx_MiniAvatarUploader_cameraIcon"></div> }
</div>
<div className={classNames("mx_Tooltip", {
"mx_Tooltip_visible": visible,
"mx_Tooltip_invisible": !visible,

View file

@ -18,33 +18,21 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
const Spinner = ({w = 32, h = 32, imgClassName, message}) => {
let imageSource;
if (SettingsStore.getValue('feature_new_spinner')) {
imageSource = require("../../../../res/img/spinner.svg");
} else {
imageSource = require("../../../../res/img/spinner.gif");
}
const Spinner = ({w = 32, h = 32, message}) => (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
<div
className="mx_Spinner_icon"
style={{width: w, height: h}}
aria-label={_t("Loading...")}
></div>
</div>
);
return (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message}</div>&nbsp;</React.Fragment> }
<img
src={imageSource}
width={w}
height={h}
className={imgClassName}
aria-label={_t("Loading...")}
/>
</div>
);
};
Spinner.propTypes = {
w: PropTypes.number,
h: PropTypes.number,
imgClassName: PropTypes.string,
message: PropTypes.node,
};

View file

@ -22,6 +22,7 @@ import React, {Component, CSSProperties} from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
const MIN_TOOLTIP_HEIGHT = 25;
@ -97,15 +98,15 @@ export default class Tooltip extends React.Component<IProps> {
// we need so that we're still centered.
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
}
const width = UIStore.instance.windowWidth;
const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset;
const top = baseTop + offset;
const right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
const right = width - parentBox.right - window.pageXOffset - 16;
const left = parentBox.right + window.pageXOffset + 6;
const horizontalCenter = parentBox.right - window.pageXOffset - (parentBox.width / 2);
switch (this.props.alignment) {
case Alignment.Natural:
if (parentBox.right > window.innerWidth / 2) {
if (parentBox.right > width / 2) {
style.right = right;
style.top = top;
break;

View file

@ -19,19 +19,30 @@ import React from 'react';
import * as sdk from '../../../index';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.TooltipButton")
export default class TooltipButton extends React.Component {
state = {
hover: false,
};
interface IProps {
helpText: string;
}
onMouseOver = () => {
interface IState {
hover: boolean;
}
@replaceableComponent("views.elements.TooltipButton")
export default class TooltipButton extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
hover: false,
};
}
private onMouseOver = () => {
this.setState({
hover: true,
});
};
onMouseLeave = () => {
private onMouseLeave = () => {
this.setState({
hover: false,
});

View file

@ -198,7 +198,8 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
const cli = this.context;
let addReactionButton;
if (cli.getRoom(mxEvent.getRoomId()).currentState.maySendEvent(EventType.Reaction, cli.getUserId())) {
const room = cli.getRoom(mxEvent.getRoomId());
if (room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, cli.getUserId())) {
addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />;
}

View file

@ -36,6 +36,7 @@ import {toRightOf} from "../../structures/ContextMenu";
import {copyPlaintext} from "../../../utils/strings";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
@replaceableComponent("views.messages.TextualBody")
export default class TextualBody extends React.Component {
@ -143,7 +144,7 @@ export default class TextualBody extends React.Component {
_addCodeExpansionButton(div, pre) {
// Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / window.innerHeight * 100;
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
if (percentageOfViewport < 30) return;
const button = document.createElement("span");

View file

@ -45,6 +45,8 @@ import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../struc
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import {useRoomMemberCount} from "../../../hooks/useRoomMembers";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import RoomName from "../elements/RoomName";
import UIStore from "../../../stores/UIStore";
interface IProps {
room: Room;
@ -115,8 +117,8 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
const rect = handle.current.getBoundingClientRect();
contextMenu = <WidgetContextMenu
chevronFace={ChevronFace.None}
right={window.innerWidth - rect.right}
bottom={window.innerHeight - rect.top}
right={UIStore.instance.windowWidth - rect.right}
bottom={UIStore.instance.windowHeight - rect.top}
onFinished={closeMenu}
app={app}
/>;
@ -249,7 +251,13 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
/>
</div>
<h2 title={room.name}>{ room.name }</h2>
<RoomName room={room}>
{ name => (
<h2 title={name}>
{ name }
</h2>
)}
</RoomName>
<div className="mx_RoomSummaryCard_alias" title={alias}>
{ alias }
</div>

View file

@ -66,6 +66,7 @@ import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRight
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {mediaFromMxc} from "../../../customisations/Media";
import UIStore from "../../../stores/UIStore";
export interface IDevice {
deviceId: string;
@ -1307,7 +1308,7 @@ const BasicUserInfo: React.FC<{
}
if (pendingUpdateCount > 0) {
spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />;
spinner = <Spinner />;
}
let memberDetails;
@ -1448,8 +1449,8 @@ const UserInfoHeader: React.FC<{
<MemberAvatar
key={member.userId} // to instantly blank the avatar when UserInfo changes members
member={member}
width={2 * 0.3 * window.innerHeight} // 2x@30vh
height={2 * 0.3 * window.innerHeight} // 2x@30vh
width={2 * 0.3 * UIStore.instance.windowHeight} // 2x@30vh
height={2 * 0.3 * UIStore.instance.windowHeight} // 2x@30vh
resizeMethod="scale"
fallbackUserId={member.userId}
onClick={onMemberAvatarClick}

View file

@ -30,6 +30,7 @@ import { Action } from "../../../dispatcher/actions";
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import UIStore from "../../../stores/UIStore";
interface IProps {
room: Room;
@ -65,7 +66,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
contextMenu = (
<WidgetContextMenu
chevronFace={ChevronFace.None}
right={window.innerWidth - rect.right - 12}
right={UIStore.instance.windowWidth - rect.right - 12}
top={rect.bottom + 12}
onFinished={closeMenu}
app={app}

View file

@ -36,6 +36,7 @@ import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayout
import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers";
import {useStateCallback} from "../../../hooks/useStateCallback";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
@replaceableComponent("views.rooms.AppsDrawer")
export default class AppsDrawer extends React.Component {
@ -290,7 +291,7 @@ const PersistentVResizer = ({
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
if (!minHeight) minHeight = 100;
if (!maxHeight) maxHeight = (window.innerHeight / 4) * 3;
if (!maxHeight) maxHeight = (UIStore.instance.windowHeight / 4) * 3;
// Convert from percentage to height. Note that the default height is 280px.
if (defaultHeight) {

View file

@ -133,6 +133,12 @@ export default class MemberList extends React.Component {
}
}
get canInvite() {
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
@ -141,6 +147,7 @@ export default class MemberList extends React.Component {
members: members,
filteredJoinedMembers: this._filterMembers(members, 'join'),
filteredInvitedMembers: this._filterMembers(members, 'invite'),
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
@ -196,6 +203,8 @@ export default class MemberList extends React.Component {
event.getType() === "m.room.third_party_invite") {
this._updateList();
}
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
};
_updateList = rate_limited_func(() => {
@ -455,8 +464,6 @@ export default class MemberList extends React.Component {
let inviteButton;
if (room && room.getMyMembership() === 'join') {
const canInvite = room.canInvite(cli.getUserId());
let inviteButtonText = _t("Invite to this room");
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
@ -467,7 +474,7 @@ export default class MemberList extends React.Component {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!canInvite}>
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!this.state.canInvite}>
<span>{ inviteButtonText }</span>
</AccessibleButton>;
}

View file

@ -46,17 +46,15 @@ import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../con
import AccessibleButton from "../elements/AccessibleButton";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import CallHandler from "../../../CallHandler";
import SpaceStore, {SUGGESTED_ROOMS} from "../../../stores/SpaceStore";
import SpaceStore, {ISuggestedRoom, SUGGESTED_ROOMS} from "../../../stores/SpaceStore";
import {showAddExistingRooms, showCreateNewRoom, showSpaceInvite} from "../../../utils/space";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import RoomAvatar from "../avatars/RoomAvatar";
import { ISpaceSummaryRoom } from "../../structures/SpaceRoomDirectory";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void;
onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
resizeNotifier: ResizeNotifier;
isMinimized: boolean;
activeSpace: Room;
@ -66,7 +64,7 @@ interface IState {
sublists: ITagMap;
isNameFiltering: boolean;
currentRoomId?: string;
suggestedRooms: ISpaceSummaryRoom[];
suggestedRooms: ISuggestedRoom[];
}
const TAG_ORDER: TagID[] = [
@ -363,7 +361,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
return room;
};
private updateSuggestedRooms = (suggestedRooms: ISpaceSummaryRoom[]) => {
private updateSuggestedRooms = (suggestedRooms: ISuggestedRoom[]) => {
this.setState({ suggestedRooms });
};
@ -405,9 +403,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const newSublists = objectWithOnly(newLists, newListIds);
const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
this.setState({sublists, isNameFiltering}, () => {
this.props.onResize();
});
this.setState({sublists, isNameFiltering});
}
};
@ -428,7 +424,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
private renderSuggestedRooms(): ReactComponentElement<typeof ExtraTile>[] {
return this.state.suggestedRooms.map(room => {
const name = room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room");
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room");
const avatar = (
<RoomAvatar
oobData={{
@ -443,7 +439,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const viewRoom = () => {
defaultDispatcher.dispatch({
action: "view_room",
room_alias: room.canonical_alias || room.aliases?.[0],
room_id: room.room_id,
via_servers: room.viaServers,
oobData: {
avatarUrl: room.avatar_url,
name,
@ -536,7 +534,6 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
addRoomLabel={aesthetics.addRoomLabel ? _t(aesthetics.addRoomLabel) : aesthetics.addRoomLabel}
addRoomContextMenu={aesthetics.addRoomContextMenu}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
resizeNotifier={this.props.resizeNotifier}

View file

@ -14,14 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import React, {useEffect, useState} from "react";
import { _t } from "../../../languageHandler";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/SpaceStore";
const RoomListNumResults: React.FC = () => {
interface IProps {
onVisibilityChange?: () => void
}
const RoomListNumResults: React.FC<IProps> = ({ onVisibilityChange }) => {
const [count, setCount] = useState<number>(null);
useEventEmitter(RoomListStore.instance, LISTS_UPDATE_EVENT, () => {
if (RoomListStore.instance.getFirstNameFilterCondition()) {
@ -32,6 +36,12 @@ const RoomListNumResults: React.FC = () => {
}
});
useEffect(() => {
if (onVisibilityChange) {
onVisibilityChange();
}
}, [count, onVisibilityChange]);
if (typeof count !== "number") return null;
return <div className="mx_LeftPanel_roomListFilterCount">

View file

@ -18,6 +18,7 @@ limitations under the License.
import * as React from "react";
import { createRef, ReactComponentElement } from "react";
import { normalize } from "matrix-js-sdk/src/utils";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from 'classnames';
import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
@ -73,7 +74,6 @@ interface IProps {
addRoomLabel: string;
isMinimized: boolean;
tagId: TagID;
onResize: () => void;
showSkeleton?: boolean;
alwaysVisible?: boolean;
resizeNotifier: ResizeNotifier;
@ -259,7 +259,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
const nameCondition = RoomListStore.instance.getFirstNameFilterCondition();
if (nameCondition) {
stateUpdates.filteredExtraTiles = this.props.extraTiles
.filter(t => nameCondition.matches(t.props.displayName || ""));
.filter(t => nameCondition.matches(normalize(t.props.displayName || "")));
} else if (this.state.filteredExtraTiles) {
stateUpdates.filteredExtraTiles = null;
}
@ -472,7 +472,6 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private toggleCollapsed = () => {
this.layout.isCollapsed = this.state.isExpanded;
this.setState({isExpanded: !this.layout.isCollapsed});
setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
};
private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
@ -529,7 +528,6 @@ export default class RoomSublist extends React.Component<IProps, IState> {
tiles.push(<RoomTile
room={room}
key={`room-${room.roomId}`}
resizeNotifier={this.props.resizeNotifier}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}

View file

@ -53,14 +53,12 @@ import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/Community
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
interface IProps {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
resizeNotifier: ResizeNotifier;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
@ -100,13 +98,12 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled
messagePreview: this.generatePreview(),
messagePreview: "",
};
this.generatePreview();
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
}
private countUnsentEvents(): number {
@ -121,12 +118,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update
};
private onResize = () => {
if (this.showMessagePreview && !this.state.messagePreview) {
this.setState({messagePreview: this.generatePreview()});
}
};
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (!room?.roomId === this.props.room.roomId) return;
this.setState({hasUnsentEvents: this.countUnsentEvents() > 0});
@ -146,8 +137,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>) {
if (prevProps.showMessagePreview !== this.props.showMessagePreview && this.showMessagePreview) {
this.setState({messagePreview: this.generatePreview()});
const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview;
const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized;
if (showMessageChanged || minimizedChanged) {
this.generatePreview();
}
if (prevProps.room?.roomId !== this.props.room?.roomId) {
MessagePreviewStore.instance.off(
@ -206,9 +199,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
this.props.room.off("Room.name", this.onRoomNameUpdate);
}
if (this.props.resizeNotifier) {
this.props.resizeNotifier.off("middlePanelResized", this.onResize);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
@ -236,17 +226,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
private onRoomPreviewChanged = (room: Room) => {
if (this.props.room && room.roomId === this.props.room.roomId) {
// generatePreview() will return nothing if the user has previews disabled
this.setState({messagePreview: this.generatePreview()});
this.generatePreview();
}
};
private generatePreview(): string | null {
private async generatePreview() {
if (!this.showMessagePreview) {
return null;
}
return MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
this.setState({ messagePreview });
}
private scrollIntoView = () => {

View file

@ -40,7 +40,7 @@ const STICKERPICKER_Z_INDEX = 3500;
const PERSISTED_ELEMENT_KEY = "stickerPicker";
@replaceableComponent("views.rooms.Stickerpicker")
export default class Stickerpicker extends React.Component {
export default class Stickerpicker extends React.PureComponent {
static currentWidget;
constructor(props) {
@ -341,21 +341,27 @@ export default class Stickerpicker extends React.Component {
* @param {Event} ev Event that triggered the function call
*/
_onHideStickersClick(ev) {
this.setState({showStickers: false});
if (this.state.showStickers) {
this.setState({showStickers: false});
}
}
/**
* Called when the window is resized
*/
_onResize() {
this.setState({showStickers: false});
if (this.state.showStickers) {
this.setState({showStickers: false});
}
}
/**
* The stickers picker was hidden
*/
_onFinished() {
this.setState({showStickers: false});
if (this.state.showStickers) {
this.setState({showStickers: false});
}
}
/**

View file

@ -16,36 +16,44 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
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";
import * as WhoIsTyping from '../../../WhoIsTyping';
import Timer from '../../../utils/Timer';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import MemberAvatar from '../avatars/MemberAvatar';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
// the room this statusbar is representing.
room: Room;
onShown?: () => void;
onHidden?: () => void;
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: number;
}
interface IState {
usersTyping: RoomMember[];
// a map with userid => Timer to delay
// hiding the "x is typing" message for a
// user so hiding it can coincide
// with the sent message by the other side
// resulting in less timeline jumpiness
delayedStopTypingTimers: Record<string, Timer>;
}
@replaceableComponent("views.rooms.WhoIsTypingTile")
export default class WhoIsTypingTile extends React.Component {
static propTypes = {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
onShown: PropTypes.func,
onHidden: PropTypes.func,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number,
};
export default class WhoIsTypingTile extends React.Component<IProps, IState> {
static defaultProps = {
whoIsTypingLimit: 3,
};
state = {
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
// a map with userid => Timer to delay
// hiding the "x is typing" message for a
// user so hiding it can coincide
// with the sent message by the other side
// resulting in less timeline jumpiness
delayedStopTypingTimers: {},
};
@ -71,37 +79,39 @@ export default class WhoIsTypingTile extends React.Component {
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
client.removeListener("Room.timeline", this.onRoomTimeline);
}
Object.values(this.state.delayedStopTypingTimers).forEach((t) => t.abort());
Object.values(this.state.delayedStopTypingTimers).forEach((t) => (t as Timer).abort());
}
_isVisible(state) {
private _isVisible(state: IState): boolean {
return state.usersTyping.length !== 0 || Object.keys(state.delayedStopTypingTimers).length !== 0;
}
isVisible = () => {
public isVisible = (): boolean => {
return this._isVisible(this.state);
};
onRoomTimeline = (event, room) => {
private onRoomTimeline = (event: MatrixEvent, room: Room): void => {
if (room?.roomId === this.props.room?.roomId) {
const userId = event.getSender();
// remove user from usersTyping
const usersTyping = this.state.usersTyping.filter((m) => m.userId !== userId);
this.setState({usersTyping});
if (usersTyping.length !== this.state.usersTyping.length) {
this.setState({usersTyping});
}
// abort timer if any
this._abortUserTimer(userId);
this.abortUserTimer(userId);
}
};
onRoomMemberTyping = (ev, member) => {
private onRoomMemberTyping = (): void => {
const usersTyping = WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room);
this.setState({
delayedStopTypingTimers: this._updateDelayedStopTypingTimers(usersTyping),
delayedStopTypingTimers: this.updateDelayedStopTypingTimers(usersTyping),
usersTyping,
});
};
_updateDelayedStopTypingTimers(usersTyping) {
private updateDelayedStopTypingTimers(usersTyping: RoomMember[]): Record<string, Timer> {
const usersThatStoppedTyping = this.state.usersTyping.filter((a) => {
return !usersTyping.some((b) => a.userId === b.userId);
});
@ -129,7 +139,7 @@ export default class WhoIsTypingTile extends React.Component {
delayedStopTypingTimers[m.userId] = timer;
timer.start();
timer.finished().then(
() => this._removeUserTimer(m.userId), // on elapsed
() => this.removeUserTimer(m.userId), // on elapsed
() => {/* aborted */},
);
}
@ -139,15 +149,15 @@ export default class WhoIsTypingTile extends React.Component {
return delayedStopTypingTimers;
}
_abortUserTimer(userId) {
private abortUserTimer(userId: string): void {
const timer = this.state.delayedStopTypingTimers[userId];
if (timer) {
timer.abort();
this._removeUserTimer(userId);
this.removeUserTimer(userId);
}
}
_removeUserTimer(userId) {
private removeUserTimer(userId: string): void {
const timer = this.state.delayedStopTypingTimers[userId];
if (timer) {
const delayedStopTypingTimers = Object.assign({}, this.state.delayedStopTypingTimers);
@ -156,7 +166,7 @@ export default class WhoIsTypingTile extends React.Component {
}
}
_renderTypingIndicatorAvatars(users, limit) {
private renderTypingIndicatorAvatars(users: RoomMember[], limit: number): JSX.Element[] {
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
@ -210,7 +220,7 @@ export default class WhoIsTypingTile extends React.Component {
return (
<li className="mx_WhoIsTypingTile" aria-atomic="true">
<div className="mx_WhoIsTypingTile_avatars">
{ this._renderTypingIndicatorAvatars(usersTyping, this.props.whoIsTypingLimit) }
{ this.renderTypingIndicatorAvatars(usersTyping, this.props.whoIsTypingLimit) }
</div>
<div className="mx_WhoIsTypingTile_label">
{ typingString }