Merge branch 'develop' into gsouquet-e2ee-warning

This commit is contained in:
Germain Souquet 2021-06-10 10:25:20 +01:00
commit c2d1eb3e8e
415 changed files with 17812 additions and 6764 deletions

View file

@ -1,51 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
export default class AutoHideScrollbar extends React.Component {
constructor(props) {
super(props);
this._collectContainerRef = this._collectContainerRef.bind(this);
}
_collectContainerRef(ref) {
if (ref && !this.containerRef) {
this.containerRef = ref;
}
if (this.props.wrappedRef) {
this.props.wrappedRef(ref);
}
}
getScrollTop() {
return this.containerRef.scrollTop;
}
render() {
return (<div
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}
}

View file

@ -0,0 +1,65 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
interface IProps {
className?: string;
onScroll?: () => void;
onWheel?: () => void;
style?: React.CSSProperties
tabIndex?: number,
wrappedRef?: (ref: HTMLDivElement) => void;
}
export default class AutoHideScrollbar extends React.Component<IProps> {
private containerRef: React.RefObject<HTMLDivElement> = React.createRef();
public componentDidMount() {
if (this.containerRef.current && this.props.onScroll) {
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true });
}
if (this.props.wrappedRef) {
this.props.wrappedRef(this.containerRef.current);
}
}
public componentWillUnmount() {
if (this.containerRef.current && this.props.onScroll) {
this.containerRef.current.removeEventListener("scroll", this.props.onScroll);
}
}
public getScrollTop(): number {
return this.containerRef.current.scrollTop;
}
public render() {
return (<div
ref={this.containerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}
}

View file

@ -23,6 +23,7 @@ import classNames from "classnames";
import {Key} from "../../Keyboard";
import {Writeable} from "../../@types/common";
import {replaceableComponent} from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -222,10 +223,12 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
};
private onKeyDown = (ev: React.KeyboardEvent) => {
// don't let keyboard handling escape the context menu
ev.stopPropagation();
if (!this.props.managed) {
if (ev.key === Key.ESCAPE) {
this.props.onFinished();
ev.stopPropagation();
ev.preventDefault();
}
return;
@ -258,7 +261,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
if (handled) {
// consume all other keys in context menu
ev.stopPropagation();
ev.preventDefault();
}
};
@ -409,12 +411,12 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
}
return menuOptions;
@ -429,12 +431,12 @@ export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFac
const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) {
if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
}
return menuOptions;
@ -450,7 +452,7 @@ export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFa
// Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft;
// Align the menu vertically above the menu
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding;
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
return menuOptions;
};

View file

@ -50,6 +50,9 @@ class FilePanel extends React.Component {
if (room?.roomId !== this.props?.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
const client = MatrixClientPeg.get();
client.decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted()) {
this.decryptingEvents.add(ev.getId());
} else {
@ -200,10 +203,10 @@ class FilePanel extends React.Component {
previousPhase={RightPanelPhases.RoomSummary}
>
<div className="mx_RoomView_empty">
{ _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
{ _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
</div>
</BaseCard>;
} else if (this.noRoom) {

View file

@ -123,12 +123,19 @@ class GroupFilterPanel extends React.Component {
mx_GroupFilterPanel_items_selected: itemsSelected,
});
let betaDot;
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
betaDot = <div className="mx_BetaDot" />;
}
let createButton = (
<ActionButton
tooltip
label={_t("Communities")}
action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus" />
className="mx_TagTile mx_TagTile_plus">
{ betaDot }
</ActionButton>
);
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
@ -153,17 +160,17 @@ class GroupFilterPanel extends React.Component {
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
{ tags }
<div>
{createButton}
</div>
{ provided.placeholder }
<div
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
{ tags }
<div>
{createButton}
</div>
{ provided.placeholder }
</div>
) }
</Droppable>
</AutoHideScrollbar>

View file

@ -36,14 +36,14 @@ import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk/src/models/group";
import {allSettled, sleep} from "../../utils/promise";
import {sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {mediaFromMxc} from "../../customisations/Media";
import {replaceableComponent} from "../../utils/replaceableComponent";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
`<h1>HTML for your community's page</h1>
<p>
Use the long description to introduce new members to the community, or distribute
some important <a href="foo">links</a>
@ -99,7 +99,7 @@ class CategoryRoomList extends React.Component {
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
allSettled(addrs.map((addr) => {
Promise.allSettled(addrs.map((addr) => {
return GroupStore
.addRoomToGroupSummary(this.props.groupId, addr.address)
.catch(() => { errorList.push(addr.address); });
@ -110,14 +110,16 @@ class CategoryRoomList extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog(
'Failed to add the following room to the group summary',
'', ErrorDialog,
{
title: _t(
"Failed to add the following rooms to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
});
'',
ErrorDialog,
{
title: _t(
"Failed to add the following rooms to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
},
);
});
},
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
@ -146,8 +148,8 @@ class CategoryRoomList extends React.Component {
let catHeader = <div />;
if (this.props.category && this.props.category.profile) {
catHeader = <div className="mx_GroupView_featuredThings_category">
{ this.props.category.profile.name }
</div>;
{ this.props.category.profile.name }
</div>;
}
return <div className="mx_GroupView_featuredThings_container">
{ catHeader }
@ -190,13 +192,14 @@ class FeaturedRoom extends React.Component {
Modal.createTrackedDialog(
'Failed to remove room from group summary',
'', ErrorDialog,
{
title: _t(
"Failed to remove the room from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
});
{
title: _t(
"Failed to remove the room from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}),
},
);
});
};
@ -271,7 +274,7 @@ class RoleUserList extends React.Component {
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
allSettled(addrs.map((addr) => {
Promise.allSettled(addrs.map((addr) => {
return GroupStore
.addUserToGroupSummary(addr.address)
.catch(() => { errorList.push(addr.address); });
@ -283,13 +286,14 @@ class RoleUserList extends React.Component {
Modal.createTrackedDialog(
'Failed to add the following users to the community summary',
'', ErrorDialog,
{
title: _t(
"Failed to add the following users to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
});
{
title: _t(
"Failed to add the following users to the summary of %(groupId)s:",
{groupId: this.props.groupId},
),
description: errorList.join(", "),
},
);
});
},
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
@ -299,11 +303,11 @@ class RoleUserList extends React.Component {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const addButton = this.props.editing ?
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}>
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a User') }
</div>
</AccessibleButton>) : <div />;
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a User') }
</div>
</AccessibleButton>) : <div />;
const userNodes = this.props.users.map((u) => {
return <FeaturedUser
key={u.user_id}
@ -352,14 +356,16 @@ class FeaturedUser extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog(
'Failed to remove user from community summary',
'', ErrorDialog,
{
title: _t(
"Failed to remove a user from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
});
'',
ErrorDialog,
{
title: _t(
"Failed to remove a user from the summary of %(groupId)s",
{groupId: this.props.groupId},
),
description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}),
},
);
});
};
@ -767,8 +773,8 @@ export default class GroupView extends React.Component {
title: _t("Leave Community"),
description: (
<span>
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
{ warnings }
{ _t("Leave %(groupName)s?", {groupName: this.props.groupId}) }
{ warnings }
</span>
),
button: _t("Leave"),
@ -1055,10 +1061,11 @@ export default class GroupView extends React.Component {
return null;
}
const membershipButtonClasses = classnames([
'mx_RoomHeader_textButton',
'mx_GroupView_textButton',
],
const membershipButtonClasses = classnames(
[
'mx_RoomHeader_textButton',
'mx_GroupView_textButton',
],
membershipButtonExtraClasses,
);

View file

@ -24,13 +24,16 @@ import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig";
import {replaceableComponent} from "../../utils/replaceableComponent";
interface IProps {}
interface IProps {
onClick?(): void;
}
interface IState {}
@replaceableComponent("structures.HostSignupAction")
export default class HostSignupAction extends React.PureComponent<IProps, IState> {
private openDialog = async () => {
this.props.onClick?.();
await HostSignupStore.instance.setHostSignupActive(true);
}

View file

@ -59,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component {
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
this._scrollElement.addEventListener("scroll", this.checkOverflow);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.checkOverflow();
}
}

View file

@ -43,6 +43,7 @@ import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
interface IProps {
isMinimized: boolean;
@ -66,6 +67,7 @@ const cssClasses = [
@replaceableComponent("structures.LeftPanel")
export default class LeftPanel extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef();
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string;
@ -90,10 +92,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
});
}
// We watch the middle panel because we don't actually get resized, the middle panel does.
// We listen to the noisy channel to avoid choppy reaction times.
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
public componentDidMount() {
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true });
}
public componentWillUnmount() {
@ -103,7 +109,15 @@ export default class LeftPanel extends React.Component<IProps, IState> {
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevState.activeSpace !== this.state.activeSpace) {
this.refreshStickyHeaders();
}
}
private updateActiveSpace = (activeSpace: Room) => {
@ -114,6 +128,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
dis.fire(Action.ViewRoomDirectory);
};
private refreshStickyHeaders = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
}
private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible;
if (newVal !== this.state.showBreadcrumbs) {
@ -156,9 +175,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<HTMLDivElement, {
@ -228,7 +244,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
const offset = UIStore.instance.windowHeight -
(list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom;
@ -247,14 +264,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist_headerContainer_sticky");
}
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
if (listDimensions) {
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = listDimensions.width - headerRightMargin;
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
}
} else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
}
if (header.style.width) {
header.style.removeProperty('width');
}
@ -276,16 +299,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
private onScroll = (ev: Event) => {
const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list);
};
private onResize = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
private onFocus = (ev: React.FocusEvent) => {
this.focusedElement = ev.target;
};
@ -347,7 +365,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (element) {
classes = element.classList;
}
} while (element && !cssClasses.some(c => classes.contains(c)));
} while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null));
if (element) {
element.focus();
@ -416,12 +434,13 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={null}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
onResize={this.onResize}
activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
/>;
const containerClasses = classNames({
@ -435,17 +454,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
);
return (
<div className={containerClasses}>
<div className={containerClasses} ref={this.ref}>
{leftLeftPanel}
<aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
{this.renderBreadcrumbs()}
<RoomListNumResults />
<RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
<div className="mx_LeftPanel_roomListWrapper">
<div
className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
@ -454,7 +472,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{roomList}
</div>
</div>
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
{ !this.props.isMinimized && <LeftPanelWidget /> }
</aside>
</div>
);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect, useMemo} from "react";
import React, {useContext, useMemo} from "react";
import {Resizable} from "re-resizable";
import classNames from "classnames";
@ -27,16 +27,13 @@ import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
import {useAccountData} from "../../hooks/useAccountData";
import AppTile from "../views/elements/AppTile";
import {useSettingValue} from "../../hooks/useSettings";
interface IProps {
onResize(): void;
}
import UIStore from "../../stores/UIStore";
const MIN_HEIGHT = 100;
const MAX_HEIGHT = 500; // or 50% of the window height
const INITIAL_HEIGHT = 280;
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const LeftPanelWidget: React.FC = () => {
const cli = useContext(MatrixClientContext);
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
@ -56,7 +53,6 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded, onResize]);
const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1;
@ -68,8 +64,7 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
content = <Resizable
size={{height} as any}
minHeight={MIN_HEIGHT}
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)}
onResize={onResize}
maxHeight={Math.min(UIStore.instance.windowHeight / 2, MAX_HEIGHT)}
onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height);
}}

View file

@ -27,7 +27,7 @@ import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
import { IMatrixClientCreds } from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import TagOrderActions from '../../actions/TagOrderActions';
@ -59,6 +59,9 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
// 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.
@ -119,6 +122,7 @@ interface IState {
usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number;
useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
}
/**
@ -160,6 +164,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: [],
};
// stash the MatrixClient in case we log out before we are unmounted
@ -175,6 +180,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._updateServerNoticeEvents();
@ -199,6 +205,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -206,15 +213,11 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.detach();
}
// Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy.
//
// This is required because `LoggedInView` maintains its own state and if this state
// updates after the client peg has been made null (during logout), then it will
// attempt to re-render and the children will throw errors.
shouldComponentUpdate() {
return Boolean(MatrixClientPeg.get());
}
private onCallsChanged = () => {
this.setState({
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
});
};
canResetTimelineInRoom = (roomId) => {
if (!this._roomView.current) {
@ -355,7 +358,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
if (event) events.push(event);
}
@ -661,6 +664,12 @@ class LoggedInView extends React.Component<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
);
});
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
@ -685,6 +694,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<CallContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
{audioFeedArraysForCalls}
</MatrixClientContext.Provider>
);
}

View file

@ -86,6 +86,9 @@ import {RoomUpdateCause} from "../../stores/room-list/models";
import defaultDispatcher from "../../dispatcher/dispatcher";
import SecurityCustomisations from "../../customisations/Security";
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
/** constants for MatrixChat.state.view */
export enum Views {
// a special initial state which is only used at startup, while we are
@ -223,13 +226,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
firstSyncPromise: IDeferred<void>;
private screenAfterLogin?: IScreen;
private windowWidth: number;
private pageChanging: boolean;
private tokenLogin?: boolean;
private accountPassword?: string;
private accountPasswordTimer?: NodeJS.Timeout;
private focusComposer: boolean;
private subTitleStatus: string;
private prevWindowWidth: number;
private readonly loggedInView: React.RefObject<LoggedInViewType>;
private readonly dispatcherRef: any;
@ -275,9 +278,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
}
this.windowWidth = 10000;
this.handleResize();
window.addEventListener('resize', this.handleResize);
this.prevWindowWidth = UIStore.instance.windowWidth || 1000;
UIStore.instance.on(UI_EVENTS.Resize, this.handleResize);
this.pageChanging = false;
@ -376,7 +378,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn();
}
const promisesList = [this.firstSyncPromise.promise];
const promisesList: Promise<any>[] = [this.firstSyncPromise.promise];
if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account
@ -434,7 +436,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.unregister(this.dispatcherRef);
this.themeWatcher.stop();
this.fontWatcher.stop();
window.removeEventListener('resize', this.handleResize);
UIStore.destroy();
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
@ -484,42 +486,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
startPageChangeTimer() {
// Tor doesn't support performance
if (!performance || !performance.mark) return null;
// This shouldn't happen because UNSAFE_componentWillUpdate and componentDidUpdate
// are used.
if (this.pageChanging) {
console.warn('MatrixChat.startPageChangeTimer: timer already started');
return;
}
this.pageChanging = true;
performance.mark('element_MatrixChat_page_change_start');
PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE);
}
stopPageChangeTimer() {
// Tor doesn't support performance
if (!performance || !performance.mark) return null;
const perfMonitor = PerformanceMonitor.instance;
if (!this.pageChanging) {
console.warn('MatrixChat.stopPageChangeTimer: timer not started');
return;
}
this.pageChanging = false;
performance.mark('element_MatrixChat_page_change_stop');
performance.measure(
'element_MatrixChat_page_change_delta',
'element_MatrixChat_page_change_start',
'element_MatrixChat_page_change_stop',
);
performance.clearMarks('element_MatrixChat_page_change_start');
performance.clearMarks('element_MatrixChat_page_change_stop');
const measurement = performance.getEntriesByName('element_MatrixChat_page_change_delta').pop();
perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE);
// In practice, sometimes the entries list is empty, so we get no measurement
if (!measurement) return null;
const entries = perfMonitor.getEntries({
name: PerformanceEntryNames.PAGE_CHANGE,
});
const measurement = entries.pop();
return measurement.duration;
return measurement
? measurement.duration
: null;
}
shouldTrackPageChange(prevState: IState, state: IState) {
@ -683,7 +665,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break;
}
case 'view_create_room':
this.createRoom(payload.public);
this.createRoom(payload.public, payload.defaultName);
break;
case 'view_create_group': {
let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog")
@ -740,6 +722,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.showScreenAfterLogin();
break;
case 'toggle_my_groups':
// persist that the user has interacted with this, use it to dismiss the beta dot
localStorage.setItem("mx_seenSpacesBeta", "1");
// We just dispatch the page change rather than have to worry about
// what the logic is for each of these branches.
if (this.state.page_type === PageTypes.MyGroups) {
@ -906,6 +890,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) {
// Not all timeline events are decrypted ahead of time anymore
// Only the critical ones for a typical UI are
// This will start the decryption process for all events when a
// user views a room
room.decryptAllEvents();
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) {
presentedId = theAlias;
@ -1022,7 +1011,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
private async createRoom(defaultPublic = false) {
private async createRoom(defaultPublic = false, defaultName?: string) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
if (communityId) {
// double check the user will have permission to associate this room with the community
@ -1036,7 +1025,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic });
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
defaultPublic,
defaultName,
});
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
@ -1094,7 +1086,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoomWarnings(roomId: string) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications.
const warnings = [];
@ -1133,7 +1125,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"),
description: (
@ -1625,11 +1617,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'start_registration',
params: params,
});
PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER);
} else if (screen === 'login') {
dis.dispatch({
action: 'start_login',
params: params,
});
PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN);
} else if (screen === 'forgot_password') {
dis.dispatch({
action: 'start_password_recovery',
@ -1684,6 +1678,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const type = screen === "start_sso" ? "sso" : "cas";
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
} else if (screen === 'groups') {
if (SettingsStore.getValue("feature_spaces")) {
dis.dispatch({ action: "view_home_page" });
return;
}
dis.dispatch({
action: 'view_my_groups',
});
@ -1767,6 +1765,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params.action,
});
} else if (screen.indexOf('group/') === 0) {
if (SettingsStore.getValue("feature_spaces")) {
dis.dispatch({ action: "view_home_page" });
return;
}
const groupId = screen.substring(6);
// TODO: Check valid group ID
@ -1817,18 +1820,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
handleResize = () => {
const hideLhsThreshold = 1000;
const showLhsThreshold = 1000;
const LHS_THRESHOLD = 1000;
const width = UIStore.instance.windowWidth;
if (this.windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
dis.dispatch({ action: 'hide_left_panel' });
}
if (this.windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
if (this.prevWindowWidth < LHS_THRESHOLD && width >= LHS_THRESHOLD) {
dis.dispatch({ action: 'show_left_panel' });
}
if (this.prevWindowWidth >= LHS_THRESHOLD && width < LHS_THRESHOLD) {
dis.dispatch({ action: 'hide_left_panel' });
}
this.prevWindowWidth = width;
this.state.resizeNotifier.notifyWindowResized();
this.windowWidth = window.innerWidth;
};
private dispatchTimelineResize() {
@ -1949,6 +1953,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Create and start the client
await Lifecycle.setLoggedIn(credentials);
await this.postLoginSetup();
PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN);
PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER);
};
// complete security / e2e setup has finished
@ -2085,6 +2091,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin}
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername}
{...this.getServerProperties()}
/>
);

View file

@ -19,21 +19,22 @@ limitations under the License.
import React, {createRef} from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import RoomContext from "../../contexts/RoomContext";
import {Layout, LayoutPropType} from "../../settings/Layout";
import {_t} from "../../languageHandler";
import {haveTileForEvent} from "../views/rooms/EventTile";
import {textForEvent} from "../../TextForEvent";
import {hasText} from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import {replaceableComponent} from "../../utils/replaceableComponent";
import defaultDispatcher from '../../dispatcher/dispatcher';
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@ -120,6 +121,9 @@ export default class MessagePanel extends React.Component {
// callback which is called when the panel is scrolled.
onScroll: PropTypes.func,
// callback which is called when the user interacts with the room timeline
onUserScroll: PropTypes.func,
// callback which is called when more content is needed.
onFillRequest: PropTypes.func,
@ -148,6 +152,8 @@ export default class MessagePanel extends React.Component {
enableFlair: PropTypes.bool,
};
static contextType = RoomContext;
constructor(props) {
super(props);
@ -377,7 +383,7 @@ export default class MessagePanel extends React.Component {
// Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true;
return !shouldHideEvent(mxEv);
return !shouldHideEvent(mxEv, this.context);
}
_readMarkerForEvent(eventId, isLastEvent) {
@ -427,8 +433,10 @@ export default class MessagePanel extends React.Component {
// we get a new DOM node (restarting the animation) when the ghost
// moves to a different event.
return (
<li key={"_readuptoghost_"+eventId}
className="mx_RoomView_myReadMarker_container">
<li
key={"_readuptoghost_"+eventId}
className="mx_RoomView_myReadMarker_container"
>
{ hr }
</li>
);
@ -469,6 +477,10 @@ export default class MessagePanel extends React.Component {
return {nextEvent, nextTile};
}
get _roomHasPendingEdit() {
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`);
}
_getEventTiles() {
this.eventNodes = {};
@ -542,11 +554,13 @@ export default class MessagePanel extends React.Component {
}
if (!grouper) {
const wantTile = this._shouldShowEvent(mxEv);
const isGrouped = false;
if (wantTile) {
// make sure we unpack the array returned by _getTilesForEvent,
// otherwise react will auto-generate keys and we will end up
// replacing all of the DOM elements every time we paginate.
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile));
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped,
nextEvent, nextTile));
prevEvent = mxEv;
}
@ -555,6 +569,13 @@ export default class MessagePanel extends React.Component {
}
}
if (!this.props.editState && this._roomHasPendingEdit) {
defaultDispatcher.dispatch({
action: "edit_event",
event: this.props.room.findEventById(this._roomHasPendingEdit),
});
}
if (grouper) {
ret.push(...grouper.getTiles());
}
@ -562,7 +583,7 @@ export default class MessagePanel extends React.Component {
return ret;
}
_getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) {
_getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) {
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
const EventTile = sdk.getComponent('rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
@ -570,7 +591,6 @@ export default class MessagePanel extends React.Component {
const isEditing = this.props.editState &&
this.props.editState.getEvent().getId() === mxEv.getId();
// local echoes have a fake date, which could even be yesterday. Treat them
// as 'today' for the date separators.
let ts1 = mxEv.getTs();
@ -582,7 +602,7 @@ export default class MessagePanel extends React.Component {
// do we need a date separator since the last event?
const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
if (wantsDateSeparator) {
if (wantsDateSeparator && !isGrouped) {
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
ret.push(dateSeparator);
}
@ -598,10 +618,6 @@ export default class MessagePanel extends React.Component {
const eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId);
// we can't use local echoes as scroll tokens, because their event IDs change.
// Local echos have a send "status".
const scrollToken = mxEv.status ? undefined : eventId;
const readReceipts = this._readReceiptsByEvent[eventId];
let isLastSuccessful = false;
@ -630,39 +646,36 @@ export default class MessagePanel extends React.Component {
// use txnId as key if available so that we don't remount during sending
ret.push(
<li
key={mxEv.getTxnId() || eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}
>
<TileErrorBoundary mxEvent={mxEv}>
<EventTile
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
lastInSection={willWantDateSeparator}
lastSuccessful={isLastSuccessful}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
/>
</TileErrorBoundary>
</li>,
<TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
<EventTile
as="li"
ref={this._collectEventNode.bind(this, eventId)}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
lastInSection={willWantDateSeparator}
lastSuccessful={isLastSuccessful}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
layout={this.props.layout}
enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts}
/>
</TileErrorBoundary>,
);
return ret;
@ -764,7 +777,7 @@ export default class MessagePanel extends React.Component {
}
_collectEventNode = (eventId, node) => {
this.eventNodes[eventId] = node;
this.eventNodes[eventId] = node?.ref?.current;
}
// once dynamic content in the events load, make the scrollPanel check the
@ -838,13 +851,6 @@ export default class MessagePanel extends React.Component {
const style = this.props.hidden ? { display: 'none' } : {};
const className = classNames(
this.props.className,
{
"mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps,
},
);
let whoIsTyping;
if (this.props.room && !this.props.tileShape && this.state.showTypingNotifications) {
whoIsTyping = (<WhoIsTypingTile
@ -868,8 +874,9 @@ export default class MessagePanel extends React.Component {
<ErrorBoundary>
<ScrollPanel
ref={this._scrollPanel}
className={className}
className={this.props.className}
onScroll={this.props.onScroll}
onUserScroll={this.props.onUserScroll}
onResize={this.onResize}
onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest}
@ -966,9 +973,9 @@ class CreationGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const panel = this.panel;
const ret = [];
const isGrouped = true;
const createEvent = this.createEvent;
const lastShownEvent = this.lastShownEvent;
@ -982,12 +989,12 @@ class CreationGrouper {
// If this m.room.create event should be shown (room upgrade) then show it before the summary
if (panel._shouldShowEvent(createEvent)) {
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
ret.push(...panel._getTilesForEvent(createEvent, createEvent, false));
ret.push(...panel._getTilesForEvent(createEvent, createEvent));
}
for (const ejected of this.ejectedEvents) {
ret.push(...panel._getTilesForEvent(
createEvent, ejected, createEvent === lastShownEvent,
createEvent, ejected, createEvent === lastShownEvent, isGrouped,
));
}
@ -996,7 +1003,7 @@ class CreationGrouper {
// of EventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted.
return panel._getTilesForEvent(e, e, e === lastShownEvent);
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
}).reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1];
@ -1014,13 +1021,13 @@ class CreationGrouper {
ret.push(
<EventListSummary
key="roomcreationsummary"
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={summaryText}
key="roomcreationsummary"
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={summaryText}
>
{ eventTiles }
{ eventTiles }
</EventListSummary>,
);
@ -1081,7 +1088,7 @@ class RedactionGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const isGrouped = true;
const panel = this.panel;
const ret = [];
const lastShownEvent = this.lastShownEvent;
@ -1101,7 +1108,8 @@ class RedactionGrouper {
let eventTiles = this.events.map((e, i) => {
senders.add(e.sender);
const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile);
return panel._getTilesForEvent(
prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile);
}).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
@ -1159,11 +1167,8 @@ class MemberGrouper {
add(ev) {
if (ev.getType() === 'm.room.member') {
// We'll just double check that it's worth our time to do so, through an
// ugly hack. If textForEvent returns something, we should group it for
// rendering but if it doesn't then we'll exclude it.
const renderText = textForEvent(ev);
if (!renderText || renderText.trim().length === 0) return; // quietly ignore
// We can ignore any events that don't actually have a message to display
if (!hasText(ev)) return;
}
this.readMarker = this.readMarker || this.panel._readMarkerForEvent(
ev.getId(),
@ -1180,7 +1185,7 @@ class MemberGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
const isGrouped = true;
const panel = this.panel;
const lastShownEvent = this.lastShownEvent;
const ret = [];
@ -1213,7 +1218,7 @@ class MemberGrouper {
// of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted.
return panel._getTilesForEvent(e, e, e === lastShownEvent);
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
}).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
@ -1222,11 +1227,11 @@ class MemberGrouper {
ret.push(
<MemberEventListSummary key={key}
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
startExpanded={highlightInMels}
events={this.events}
onToggle={panel._onHeightChanged} // Update scroll state
startExpanded={highlightInMels}
>
{ eventTiles }
{ eventTiles }
</MemberEventListSummary>,
);

View file

@ -25,6 +25,7 @@ import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import BetaCard from "../views/beta/BetaCard";
@replaceableComponent("structures.MyGroups")
export default class MyGroups extends React.Component {
@ -139,6 +140,7 @@ export default class MyGroups extends React.Component {
</div>
</div>*/}
</div>
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
<div className="mx_MyGroups_content">
{ contentHeader }
{ content }

View file

@ -1,7 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,29 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from "prop-types";
import React from "react";
import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseCard from "../views/right_panel/BaseCard";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
interface IProps {
onClose(): void;
}
/*
* Component which shows the global notification list using a TimelinePanel
*/
@replaceableComponent("structures.NotificationPanel")
class NotificationPanel extends React.Component {
static propTypes = {
onClose: PropTypes.func.isRequired,
};
export default class NotificationPanel extends React.PureComponent<IProps> {
render() {
// wrap a TimelinePanel with the jump-to-event bits turned off.
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const Loader = sdk.getComponent("elements.Spinner");
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t('Youre all caught up')}</h2>
<p>{_t('You have no visible notifications.')}</p>
@ -47,6 +41,7 @@ class NotificationPanel extends React.Component {
let content;
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
// wrap a TimelinePanel with the jump-to-event bits turned off.
content = (
<TimelinePanel
manageReadReceipts={false}
@ -55,11 +50,12 @@ class NotificationPanel extends React.Component {
showUrlPreview={false}
tileShape="notif"
empty={emptyState}
alwaysShowTimestamps={true}
/>
);
} else {
console.error("No notifTimelineSet available!");
content = <Loader />;
content = <Spinner />;
}
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
@ -67,5 +63,3 @@ class NotificationPanel extends React.Component {
</BaseCard>;
}
}
export default NotificationPanel;

View file

@ -1,6 +1,6 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015 - 2020 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,76 +16,101 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {Room} from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import { User } from "matrix-js-sdk/src/models/user";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import RateLimitedFunc from '../../ratelimitedfunc';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore';
import {
RightPanelPhases,
RIGHT_PANEL_PHASES_NO_ARGS,
RIGHT_PANEL_SPACE_PHASES,
RightPanelPhases,
} from "../../stores/RightPanelStorePhases";
import RightPanelStore from "../../stores/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import {Action} from "../../dispatcher/actions";
import { Action } from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SettingsStore from "../../settings/SettingsStore";
import { ActionPayload } from "../../dispatcher/payloads";
import MemberList from "../views/rooms/MemberList";
import GroupMemberList from "../views/groups/GroupMemberList";
import GroupRoomList from "../views/groups/GroupRoomList";
import GroupRoomInfo from "../views/groups/GroupRoomInfo";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
interface IProps {
room?: Room; // if showing panels for a given room, this is set
groupId?: string; // if showing panels for a given group, this is set
user?: User; // used if we know the user ahead of opening the panel
resizeNotifier: ResizeNotifier;
}
interface IState {
phase: RightPanelPhases;
isUserPrivilegedInGroup?: boolean;
member?: RoomMember;
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
space?: Room;
widgetId?: string;
groupRoomId?: string;
groupId?: string;
event: MatrixEvent;
}
@replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component {
static get propTypes() {
return {
room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set
groupId: PropTypes.string, // if showing panels for a given group, this is set
user: PropTypes.object, // used if we know the user ahead of opening the panel
};
}
export default class RightPanel extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private readonly delayedUpdate: RateLimitedFunc;
private dispatcherRef: string;
constructor(props, context) {
super(props, context);
this.state = {
...RightPanelStore.getSharedInstance().roomPanelPhaseParams,
phase: this._getPhaseFromProps(),
phase: this.getPhaseFromProps(),
isUserPrivilegedInGroup: null,
member: this._getUserForPanel(),
member: this.getUserForPanel(),
};
this.onAction = this.onAction.bind(this);
this.onRoomStateMember = this.onRoomStateMember.bind(this);
this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this);
this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this);
this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this);
this._delayedUpdate = new RateLimitedFunc(() => {
this.delayedUpdate = new RateLimitedFunc(() => {
this.forceUpdate();
}, 500);
}
// Helper function to split out the logic for _getPhaseFromProps() and the constructor
// Helper function to split out the logic for getPhaseFromProps() and the constructor
// as both are called at the same time in the constructor.
_getUserForPanel() {
private getUserForPanel() {
if (this.state && this.state.member) return this.state.member;
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
return this.props.user || lastParams['member'];
}
// gets the current phase from the props and also maybe the store
_getPhaseFromProps() {
private getPhaseFromProps() {
const rps = RightPanelStore.getSharedInstance();
const userForPanel = this._getUserForPanel();
const userForPanel = this.getUserForPanel();
if (this.props.groupId) {
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) {
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList});
return RightPanelPhases.GroupMemberList;
}
return rps.groupPanelPhase;
} else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) {
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
) {
return RightPanelPhases.SpaceMemberList;
} else if (userForPanel) {
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state
@ -115,7 +140,7 @@ export default class RightPanel extends React.Component {
this.dispatcherRef = dis.register(this.onAction);
const cli = this.context;
cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId);
this.initGroupStore(this.props.groupId);
}
componentWillUnmount() {
@ -123,61 +148,47 @@ export default class RightPanel extends React.Component {
if (this.context) {
this.context.removeListener("RoomState.members", this.onRoomStateMember);
}
this._unregisterGroupStore(this.props.groupId);
this.unregisterGroupStore();
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore(this.props.groupId);
this._initGroupStore(newProps.groupId);
this.unregisterGroupStore();
this.initGroupStore(newProps.groupId);
}
}
_initGroupStore(groupId) {
private initGroupStore(groupId: string) {
if (!groupId) return;
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
}
_unregisterGroupStore() {
private unregisterGroupStore() {
GroupStore.unregisterListener(this.onGroupStoreUpdated);
}
onGroupStoreUpdated() {
private onGroupStoreUpdated = () => {
this.setState({
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
});
}
};
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: RightPanelPhases.GroupMemberList,
});
});
}
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
}
onRoomStateMember(ev, state, member) {
private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
if (!this.props.room || member.roomId !== this.props.room.roomId) {
return;
}
// redraw the badge on the membership list
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) {
this._delayedUpdate();
this.delayedUpdate();
} else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId &&
member.userId === this.state.member.userId) {
// refresh the member info (e.g. new power level)
this._delayedUpdate();
this.delayedUpdate();
}
}
};
onAction(payload) {
private onAction = (payload: ActionPayload) => {
if (payload.action === Action.AfterRightPanelPhaseChange) {
this.setState({
phase: payload.phase,
@ -191,9 +202,9 @@ export default class RightPanel extends React.Component {
space: payload.space,
});
}
}
};
onClose = () => {
private onClose = () => {
// XXX: There are three different ways of 'closing' this panel depending on what state
// things are in... this knows far more than it should do about the state of the rest
// of the app and is generally a bit silly.
@ -221,16 +232,6 @@ export default class RightPanel extends React.Component {
};
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
const GroupMemberList = sdk.getComponent('groups.GroupMemberList');
const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
let panel = <div />;
const roomId = this.props.room ? this.props.room.roomId : undefined;
@ -282,6 +283,7 @@ export default class RightPanel extends React.Component {
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.userId}
phase={this.state.phase}
onClose={this.onClose} />;
break;
@ -296,6 +298,12 @@ export default class RightPanel extends React.Component {
panel = <NotificationPanel onClose={this.onClose} />;
break;
case RightPanelPhases.PinnedMessages:
if (SettingsStore.getValue("feature_pinning")) {
panel = <PinnedMessagesCard room={this.props.room} onClose={this.onClose} />;
}
break;
case RightPanelPhases.FilePanel:
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break;

View file

@ -1,7 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,39 +15,90 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index";
import React from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal";
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
import {ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { mediaFromMxc } from "../../customisations/Media";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseAvatar from "../views/avatars/BaseAvatar";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import QuestionDialog from "../views/dialogs/QuestionDialog";
import BaseDialog from "../views/dialogs/BaseDialog";
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
import NetworkDropdown from "../views/directory/NetworkDropdown";
import ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
function track(action) {
function track(action: string) {
Analytics.trackEvent('RoomDirectory', action);
}
interface IProps extends IDialogProps {
initialText?: string;
}
interface IState {
publicRooms: IRoom[];
loading: boolean;
protocolsLoading: boolean;
error?: string;
instanceId: string | symbol;
roomServer: string;
filterString: string;
selectedCommunityId?: string;
communityName?: string;
}
/* eslint-disable camelcase */
interface IRoom {
room_id: string;
name?: string;
avatar_url?: string;
topic?: string;
canonical_alias?: string;
aliases?: string[];
world_readable: boolean;
guest_can_join: boolean;
num_joined_members: number;
}
interface IPublicRoomsRequest {
limit?: number;
since?: string;
server?: string;
filter?: object;
include_all_networks?: boolean;
third_party_instance_id?: string;
}
/* eslint-enable camelcase */
@replaceableComponent("structures.RoomDirectory")
export default class RoomDirectory extends React.Component {
static propTypes = {
initialText: PropTypes.string,
onFinished: PropTypes.func.isRequired,
};
export default class RoomDirectory extends React.Component<IProps, IState> {
private readonly startTime: number;
private unmounted = false
private nextBatch: string = null;
private filterTimeout: NodeJS.Timeout;
private protocols: Protocols;
constructor(props) {
super(props);
@ -56,41 +106,21 @@ export default class RoomDirectory extends React.Component {
CountlyAnalytics.instance.trackRoomDirectoryBegin();
this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = {
publicRooms: [],
loading: true,
protocolsLoading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
communityName: null,
};
const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes")
? GroupFilterOrderStore.getSelectedTags()[0]
: null;
this._unmounted = false;
this.nextBatch = null;
this.filterTimeout = null;
this.scrollPanel = null;
this.protocols = null;
this.state.protocolsLoading = true;
let protocolsLoading = true;
if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page
this.state.protocolsLoading = false;
return;
}
if (!this.state.selectedCommunityId) {
protocolsLoading = false;
} else if (!selectedCommunityId) {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response;
this.setState({protocolsLoading: false});
this.setState({ protocolsLoading: false });
}, (err) => {
console.warn(`error loading third party protocols: ${err}`);
this.setState({protocolsLoading: false});
this.setState({ protocolsLoading: false });
if (MatrixClientPeg.get().isGuest()) {
// Guests currently aren't allowed to use this API, so
// ignore this as otherwise this error is literally the
@ -103,19 +133,31 @@ export default class RoomDirectory extends React.Component {
error: _t(
'%(brand)s failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.',
{brand},
{ brand },
),
});
});
} else {
// We don't use the protocols in the communities v2 prototype experience
this.state.protocolsLoading = false;
protocolsLoading = false;
// Grab the profile info async
FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => {
this.setState({communityName: profile.name});
this.setState({ communityName: profile.name });
});
}
this.state = {
publicRooms: [],
loading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId,
communityName: null,
protocolsLoading,
};
}
componentDidMount() {
@ -126,10 +168,10 @@ export default class RoomDirectory extends React.Component {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
this._unmounted = true;
this.unmounted = true;
}
refreshRoomList = () => {
private refreshRoomList = () => {
if (this.state.selectedCommunityId) {
this.setState({
publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => {
@ -165,7 +207,7 @@ export default class RoomDirectory extends React.Component {
this.getMoreRooms();
};
getMoreRooms() {
private getMoreRooms() {
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve();
@ -173,34 +215,34 @@ export default class RoomDirectory extends React.Component {
loading: true,
});
const my_filter_string = this.state.filterString;
const my_server = this.state.roomServer;
const filterString = this.state.filterString;
const roomServer = this.state.roomServer;
// remember the next batch token when we sent the request
// too. If it's changed, appending to the list will corrupt it.
const my_next_batch = this.nextBatch;
const opts = {limit: 20};
if (my_server != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server;
const nextBatch = this.nextBatch;
const opts: IPublicRoomsRequest = { limit: 20 };
if (roomServer != MatrixClientPeg.getHomeserverName()) {
opts.server = roomServer;
}
if (this.state.instanceId === ALL_ROOMS) {
opts.include_all_networks = true;
} else if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId;
opts.third_party_instance_id = this.state.instanceId as string;
}
if (this.nextBatch) opts.since = this.nextBatch;
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string };
if (filterString) opts.filter = { generic_search_term: filterString };
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
if (
my_filter_string != this.state.filterString ||
my_server != this.state.roomServer ||
my_next_batch != this.nextBatch) {
filterString != this.state.filterString ||
roomServer != this.state.roomServer ||
nextBatch != this.nextBatch) {
// if the filter or server has changed since this request was sent,
// throw away the result (don't even clear the busy flag
// since we must still have a request in flight)
return;
}
if (this._unmounted) {
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
}
@ -211,23 +253,23 @@ export default class RoomDirectory extends React.Component {
}
this.nextBatch = data.next_batch;
this.setState((s) => {
s.publicRooms.push(...(data.chunk || []));
s.loading = false;
return s;
});
this.setState((s) => ({
...s,
publicRooms: [...s.publicRooms, ...(data.chunk || [])],
loading: false,
}));
return Boolean(data.next_batch);
}, (err) => {
if (
my_filter_string != this.state.filterString ||
my_server != this.state.roomServer ||
my_next_batch != this.nextBatch) {
filterString != this.state.filterString ||
roomServer != this.state.roomServer ||
nextBatch != this.nextBatch) {
// as above: we don't care about errors for old
// requests either
return;
}
if (this._unmounted) {
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
}
@ -252,13 +294,10 @@ export default class RoomDirectory extends React.Component {
* HS admins to do this through the RoomSettings interface, but
* this needs SPEC-417.
*/
removeFromDirectory(room) {
const alias = get_display_alias_for_room(room);
private removeFromDirectory(room: IRoom) {
const alias = getDisplayAliasForRoom(room);
const name = room.name || alias || _t('Unnamed room');
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
let desc;
if (alias) {
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name});
@ -269,11 +308,10 @@ export default class RoomDirectory extends React.Component {
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
title: _t('Remove from Directory'),
description: desc,
onFinished: (should_delete) => {
if (!should_delete) return;
onFinished: (shouldDelete: boolean) => {
if (!shouldDelete) return;
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader);
const modal = Modal.createDialog(Spinner);
let step = _t('remove %(name)s from the directory.', {name: name});
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
@ -289,14 +327,16 @@ export default class RoomDirectory extends React.Component {
console.error("Failed to " + step + ": " + err);
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
title: _t('Error'),
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')),
description: (err && err.message)
? err.message
: _t('The server may be unavailable or overloaded'),
});
});
},
});
}
onRoomClicked = (room, ev) => {
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault();
this.removeFromDirectory(room);
@ -305,7 +345,7 @@ export default class RoomDirectory extends React.Component {
}
};
onOptionChange = (server, instanceId) => {
private onOptionChange = (server: string, instanceId?: string | symbol) => {
// clear next batch so we don't try to load more rooms
this.nextBatch = null;
this.setState({
@ -325,13 +365,13 @@ export default class RoomDirectory extends React.Component {
// Easiest to just blow away the state & re-fetch.
};
onFillRequest = (backwards) => {
private onFillRequest = (backwards: boolean) => {
if (backwards || !this.nextBatch) return Promise.resolve(false);
return this.getMoreRooms();
};
onFilterChange = (alias) => {
private onFilterChange = (alias: string) => {
this.setState({
filterString: alias || null,
});
@ -349,7 +389,7 @@ export default class RoomDirectory extends React.Component {
}, 700);
};
onFilterClear = () => {
private onFilterClear = () => {
// update immediately
this.setState({
filterString: null,
@ -360,7 +400,7 @@ export default class RoomDirectory extends React.Component {
}
};
onJoinFromSearchClick = (alias) => {
private onJoinFromSearchClick = (alias: string) => {
// If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
// If the user specified an alias without a domain, add on whichever server is selected
@ -373,9 +413,10 @@ export default class RoomDirectory extends React.Component {
// This is a 3rd party protocol. Let's see if we can join it
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
const fields = protocolName
? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance)
: null;
if (!fields) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const brand = SdkConfig.get().brand;
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
title: _t('Unable to join network'),
@ -387,14 +428,12 @@ export default class RoomDirectory extends React.Component {
if (resp.length > 0 && resp[0].alias) {
this.showRoomAlias(resp[0].alias, true);
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
title: _t('Room not found'),
description: _t('Couldn\'t find a matching Matrix room'),
});
}
}, (e) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
title: _t('Fetching third party location failed'),
description: _t('Unable to look up room ID from server'),
@ -403,36 +442,37 @@ export default class RoomDirectory extends React.Component {
}
};
onPreviewClick = (ev, room) => {
private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, false, true);
ev.stopPropagation();
};
onViewClick = (ev, room) => {
private onViewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room);
ev.stopPropagation();
};
onJoinClick = (ev, room) => {
private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, true);
ev.stopPropagation();
};
onCreateRoomClick = room => {
private onCreateRoomClick = () => {
this.onFinished();
dis.dispatch({
action: 'view_create_room',
public: true,
defaultName: this.state.filterString.trim(),
});
};
showRoomAlias(alias, autoJoin=false) {
private showRoomAlias(alias: string, autoJoin = false) {
this.showRoom(null, alias, autoJoin);
}
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) {
private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished();
const payload = {
const payload: ActionPayload = {
action: 'view_room',
auto_join: autoJoin,
should_peek: shouldPeek,
@ -449,15 +489,15 @@ export default class RoomDirectory extends React.Component {
}
}
if (!room_alias) {
room_alias = get_display_alias_for_room(room);
if (!roomAlias) {
roomAlias = getDisplayAliasForRoom(room);
}
payload.oob_data = {
avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which
// would normally decide what the name is.
name: room.name || room_alias || _t('Unnamed room'),
name: room.name || roomAlias || _t('Unnamed room'),
};
if (this.state.roomServer) {
@ -471,21 +511,19 @@ export default class RoomDirectory extends React.Component {
// which servers to start querying. However, there's no other way to join rooms in
// this list without aliases at present, so if roomAlias isn't set here we have no
// choice but to supply the ID.
if (room_alias) {
payload.room_alias = room_alias;
if (roomAlias) {
payload.room_alias = roomAlias;
} else {
payload.room_id = room.room_id;
}
dis.dispatch(payload);
}
createRoomCells(room) {
private createRoomCells(room: IRoom) {
const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
const isGuest = client.isGuest();
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let previewButton;
let joinOrViewButton;
@ -495,20 +533,26 @@ export default class RoomDirectory extends React.Component {
// it is readable, the preview appears as normal.
if (!hasJoinedRoom && (room.world_readable || isGuest)) {
previewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>
{ _t("Preview") }
</AccessibleButton>
);
}
if (hasJoinedRoom) {
joinOrViewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>
{ _t("View") }
</AccessibleButton>
);
} else if (!isGuest) {
joinOrViewButton = (
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>
{ _t("Join") }
</AccessibleButton>
);
}
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room');
let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
}
@ -531,9 +575,13 @@ export default class RoomDirectory extends React.Component {
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomAvatar"
>
<BaseAvatar width={32} height={32} resizeMethod='crop'
name={ name } idName={ name }
url={ avatarUrl }
<BaseAvatar
width={32}
height={32}
resizeMethod='crop'
name={name}
idName={name}
url={avatarUrl}
/>
</div>,
<div key={ `${room.room_id}_description` }
@ -547,7 +595,7 @@ export default class RoomDirectory extends React.Component {
onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }}
/>
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
<div className="mx_RoomDirectory_alias">{ getDisplayAliasForRoom(room) }</div>
</div>,
<div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)}
@ -576,20 +624,16 @@ export default class RoomDirectory extends React.Component {
];
}
collectScrollPanel = (element) => {
this.scrollPanel = element;
};
_stringLooksLikeId(s, field_type) {
private stringLooksLikeId(s: string, fieldType: IFieldType) {
let pat = /^#[^\s]+:[^\s]/;
if (field_type && field_type.regexp) {
pat = new RegExp(field_type.regexp);
if (fieldType && fieldType.regexp) {
pat = new RegExp(fieldType.regexp);
}
return pat.test(s);
}
_getFieldsForThirdPartyLocation(userInput, protocol, instance) {
private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) {
// make an object with the fields specified by that protocol. We
// require that the values of all but the last field come from the
// instance. The last is the user input.
@ -605,71 +649,73 @@ export default class RoomDirectory extends React.Component {
return fields;
}
/**
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
*/
handleScrollKey = ev => {
if (this.scrollPanel) {
this.scrollPanel.handleScrollKey(ev);
}
};
onFinished = () => {
private onFinished = () => {
CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
this.props.onFinished();
this.props.onFinished(false);
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let content;
if (this.state.error) {
content = this.state.error;
} else if (this.state.protocolsLoading) {
content = <Loader />;
content = <Spinner />;
} else {
const cells = (this.state.publicRooms || [])
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],);
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), []);
// we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one
let spinner;
if (this.state.loading) {
spinner = <Loader />;
spinner = <Spinner />;
}
let scrollpanel_content;
const createNewButton = <>
<hr />
<AccessibleButton kind="primary" onClick={this.onCreateRoomClick} className="mx_RoomDirectory_newRoom">
{ _t("Create new room") }
</AccessibleButton>
</>;
let scrollPanelContent;
let footer;
if (cells.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
footer = <>
<h5>{ _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }</h5>
<p>
{ _t("Try different words or check for typos. " +
"Some results may not be visible as they're private and you need an invite to join them.") }
</p>
{ createNewButton }
</>;
} else {
scrollpanel_content = <div className="mx_RoomDirectory_table">
scrollPanelContent = <div className="mx_RoomDirectory_table">
{ cells }
</div>;
if (!this.state.loading && !this.nextBatch) {
footer = createNewButton;
}
}
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
content = <ScrollPanel ref={this.collectScrollPanel}
content = <ScrollPanel
className="mx_RoomDirectory_tableWrapper"
onFillRequest={ this.onFillRequest }
onFillRequest={this.onFillRequest}
stickyBottom={false}
startAtBottom={false}
>
{ scrollpanel_content }
{ scrollPanelContent }
{ spinner }
{ footer && <div className="mx_RoomDirectory_footer">
{ footer }
</div> }
</ScrollPanel>;
}
let listHeader;
if (!this.state.protocolsLoading) {
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type;
let instanceExpectedFieldType;
if (
protocolName &&
this.protocols &&
@ -677,21 +723,27 @@ export default class RoomDirectory extends React.Component {
this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types
) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
const lastField = this.protocols[protocolName].location_fields.slice(-1)[0];
instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField];
}
let placeholder = _t('Find a room…');
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
exampleRoom: "#example:" + this.state.roomServer,
});
} else if (instanceExpectedFieldType) {
placeholder = instanceExpectedFieldType.placeholder;
}
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
if (this.getFieldsForThirdPartyLocation(
this.state.filterString,
this.protocols[protocolName],
instance,
) === null) {
showJoinButton = false;
}
}
@ -723,12 +775,11 @@ export default class RoomDirectory extends React.Component {
}
const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
{a: sub => {
return (<AccessibleButton
kind="secondary"
onClick={this.onCreateRoomClick}
>{sub}</AccessibleButton>);
}},
{a: sub => (
<AccessibleButton kind="secondary" onClick={this.onCreateRoomClick}>
{ sub }
</AccessibleButton>
)},
);
const title = this.state.selectedCommunityId
@ -756,6 +807,6 @@ export default class RoomDirectory extends React.Component {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
function get_display_alias_for_room(room) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
function getDisplayAliasForRoom(room: IRoom) {
return room.canonical_alias || room.aliases?.[0] || "";
}

View file

@ -17,6 +17,8 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import { ActionPayload } from "../../dispatcher/payloads";
@ -25,8 +27,8 @@ import { Action } from "../../dispatcher/actions";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import {replaceableComponent} from "../../utils/replaceableComponent";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
interface IProps {
isMinimized: boolean;
@ -40,6 +42,7 @@ interface IProps {
interface IState {
query: string;
focused: boolean;
inSpaces: boolean;
}
@replaceableComponent("structures.RoomSearch")
@ -54,11 +57,13 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
this.state = {
query: "",
focused: false,
inSpaces: false,
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
// clear filter when changing spaces, in future we may wish to maintain a filter per-space
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
@ -79,8 +84,15 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
}
private onSpaces = (spaces: Room[]) => {
this.setState({
inSpaces: spaces.length > 0,
});
};
private onAction = (payload: ActionPayload) => {
if (payload.action === 'view_room' && payload.clear_search) {
this.clearInput();
@ -152,6 +164,11 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
});
let placeholder = _t("Filter");
if (this.state.inSpaces) {
placeholder = _t("Filter all spaces");
}
let icon = (
<div className='mx_RoomSearch_icon' />
);
@ -165,7 +182,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={_t("Filter")}
placeholder={placeholder}
autoComplete="off"
/>
);

View file

@ -128,7 +128,11 @@ export default class RoomStatusBar extends React.Component {
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
if (room.roomId !== this.props.room.roomId) return;
this.setState({unsentMessages: getUnsentMessages(this.props.room)});
const messages = getUnsentMessages(this.props.room);
this.setState({
unsentMessages: messages,
isResending: messages.length > 0 && this.state.isResending,
});
};
// Check whether current size is greater than 0, if yes call props.onVisible
@ -196,20 +200,22 @@ export default class RoomStatusBar extends React.Component {
} else if (resourceLimitError) {
title = messageForResourceLimitError(
resourceLimitError.data.limit_type,
resourceLimitError.data.admin_contact, {
'monthly_active_user': _td(
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'hs_disabled': _td(
"Your message wasn't sent because this homeserver has been blocked by it's administrator. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'': _td(
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
});
resourceLimitError.data.admin_contact,
{
'monthly_active_user': _td(
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'hs_disabled': _td(
"Your message wasn't sent because this homeserver has been blocked by it's administrator. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'': _td(
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
},
);
} else {
title = _t('Some of your messages have not been sent');
}
@ -261,7 +267,7 @@ export default class RoomStatusBar extends React.Component {
<div role="alert">
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24"
height="24" title="/!\ " alt="/!\ " />
height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{_t('Connectivity to the server has been lost.')}

View file

@ -46,7 +46,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import {Layout} from "../../settings/Layout";
import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile";
@ -54,16 +54,13 @@ import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions";
import { SettingLevel } from "../../settings/SettingLevel";
import { IMatrixClientCreds } from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import ForwardMessage from "../views/rooms/ForwardMessage";
import SearchBar from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader";
import { XOR } from "../../@types/common";
@ -82,7 +79,9 @@ import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager';
import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { omit } from 'lodash';
import UIStore from "../../stores/UIStore";
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -136,7 +135,6 @@ export interface IState {
// Whether to highlight the event scrolled to
isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent;
forwardingEvent?: MatrixEvent;
numUnreadMessages: number;
draggingFile: boolean;
searching: boolean;
@ -155,8 +153,6 @@ export interface IState {
canPeek: boolean;
showApps: boolean;
isPeeking: boolean;
showingPinned: boolean;
showReadReceipts: boolean;
showRightPanel: boolean;
// error object, as from the matrix client/server API
// If we failed to load information about the room,
@ -175,6 +171,7 @@ export interface IState {
statusBarVisible: boolean;
// We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: {
version: string;
needsUpgrade: boolean;
@ -183,6 +180,12 @@ export interface IState {
canReact: boolean;
canReply: boolean;
layout: Layout;
lowBandwidth: boolean;
showReadReceipts: boolean;
showRedactions: boolean;
showJoinLeaves: boolean;
showAvatarChanges: boolean;
showDisplaynameChanges: boolean;
matrixClientIsReady: boolean;
showUrlPreview?: boolean;
e2eStatus?: E2EStatus;
@ -190,6 +193,9 @@ export interface IState {
rejectError?: Error;
hasPinnedWidgets?: boolean;
dragCounter: number;
// whether or not a spaces context switch brought us here,
// if it did we don't want the room to be marked as read as soon as it is loaded.
wasContextSwitch?: boolean;
}
@replaceableComponent("structures.RoomView")
@ -197,8 +203,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private readonly dispatcherRef: string;
private readonly roomStoreToken: EventSubscription;
private readonly rightPanelStoreToken: EventSubscription;
private readonly showReadReceiptsWatchRef: string;
private readonly layoutWatcherRef: string;
private settingWatchers: string[];
private unmounted = false;
private permalinkCreators: Record<string, RoomPermalinkCreator> = {};
@ -229,8 +234,6 @@ export default class RoomView extends React.Component<IProps, IState> {
canPeek: false,
showApps: false,
isPeeking: false,
showingPinned: false,
showReadReceipts: true,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
joining: false,
atEndOfLiveTimeline: true,
@ -240,6 +243,12 @@ export default class RoomView extends React.Component<IProps, IState> {
canReact: false,
canReply: false,
layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
showReadReceipts: true,
showRedactions: true,
showJoinLeaves: true,
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
dragCounter: 0,
};
@ -266,9 +275,14 @@ export default class RoomView extends React.Component<IProps, IState> {
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null,
this.onReadReceiptsChange);
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange);
this.settingWatchers = [
SettingsStore.watchSetting("layout", null, () =>
this.setState({ layout: SettingsStore.getValue("layout") }),
),
SettingsStore.watchSetting("lowBandwidth", null, () =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
),
];
}
private onWidgetStoreUpdate = () => {
@ -321,13 +335,45 @@ export default class RoomView extends React.Component<IProps, IState> {
initialEventId: RoomViewStore.getInitialEventId(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
replyToEvent: RoomViewStore.getQuotingEvent(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
// we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
showRedactions: SettingsStore.getValue("showRedactions", roomId),
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
wasContextSwitch: RoomViewStore.getWasContextSwitch(),
};
// Add watchers for each of the settings we just looked up
this.settingWatchers = this.settingWatchers.concat([
SettingsStore.watchSetting("showReadReceipts", null, () =>
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
}),
),
SettingsStore.watchSetting("showRedactions", null, () =>
this.setState({
showRedactions: SettingsStore.getValue("showRedactions", roomId),
}),
),
SettingsStore.watchSetting("showJoinLeaves", null, () =>
this.setState({
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
}),
),
SettingsStore.watchSetting("showAvatarChanges", null, () =>
this.setState({
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
}),
),
SettingsStore.watchSetting("showDisplaynameChanges", null, () =>
this.setState({
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
}),
),
]);
if (!initial && this.state.shouldPeek && !newState.shouldPeek) {
// Stop peeking because we have joined this room now
this.context.stopPeeking();
@ -524,7 +570,20 @@ export default class RoomView extends React.Component<IProps, IState> {
}
shouldComponentUpdate(nextProps, nextState) {
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState));
const hasPropsDiff = objectHasDiff(this.props, nextProps);
// React only shallow comparison and we only want to trigger
// a component re-render if a room requires an upgrade
const newUpgradeRecommendation = nextState.upgradeRecommendation || {}
const state = omit(this.state, ['upgradeRecommendation']);
const newState = omit(nextState, ['upgradeRecommendation'])
const hasStateDiff =
objectHasDiff(state, newState) ||
(newUpgradeRecommendation.needsUpgrade === true)
return hasPropsDiff || hasStateDiff;
}
componentDidUpdate() {
@ -623,10 +682,6 @@ export default class RoomView extends React.Component<IProps, IState> {
);
}
if (this.showReadReceiptsWatchRef) {
SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef);
}
// cancel any pending calls to the rate_limited_funcs
this.updateRoomMembers.cancelPendingCall();
@ -634,7 +689,20 @@ export default class RoomView extends React.Component<IProps, IState> {
// console.log("Tinter.tint from RoomView.unmount");
// Tinter.tint(); // reset colourscheme
SettingsStore.unwatchSetting(this.layoutWatcherRef);
for (const watcher of this.settingWatchers) {
SettingsStore.unwatchSetting(watcher);
}
}
private onUserScroll = () => {
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
event_id: this.state.initialEventId,
highlighted: false,
});
}
}
private onLayoutChange = () => {
@ -793,7 +861,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
// no change
} else if (!shouldHideEvent(ev)) {
} else if (!shouldHideEvent(ev, this.state)) {
this.setState((state, props) => {
return {numUnreadMessages: state.numUnreadMessages + 1};
});
@ -1110,7 +1178,8 @@ export default class RoomView extends React.Component<IProps, IState> {
Promise.resolve().then(() => {
const signUrl = this.props.threepidInvite?.signUrl;
dis.dispatch({
action: 'join_room',
action: Action.JoinRoom,
roomId: this.getRoomId(),
opts: { inviteSignUrl: signUrl },
_type: "unknown", // TODO: instrumentation
});
@ -1371,13 +1440,6 @@ export default class RoomView extends React.Component<IProps, IState> {
return ret;
}
private onPinnedClick = () => {
const nowShowingPinned = !this.state.showingPinned;
const roomId = this.state.room.roomId;
this.setState({showingPinned: nowShowingPinned, searching: false});
SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned);
};
private onCallPlaced = (type: PlaceCallType) => {
dis.dispatch({
action: 'place_call',
@ -1390,18 +1452,6 @@ export default class RoomView extends React.Component<IProps, IState> {
dis.dispatch({ action: "open_room_settings" });
};
private onCancelClick = () => {
console.log("updateTint from onCancelClick");
this.updateTint();
if (this.state.forwardingEvent) {
dis.dispatch({
action: 'forward_event',
event: null,
});
}
dis.fire(Action.FocusComposer);
};
private onAppsClick = () => {
dis.dispatch({
action: "appsDrawer",
@ -1494,7 +1544,6 @@ export default class RoomView extends React.Component<IProps, IState> {
private onSearchClick = () => {
this.setState({
searching: !this.state.searching,
showingPinned: false,
});
};
@ -1507,8 +1556,19 @@ export default class RoomView extends React.Component<IProps, IState> {
// jump down to the bottom of this room, where new events are arriving
private jumpToLiveTimeline = () => {
this.messagePanel.jumpToLiveTimeline();
dis.fire(Action.FocusComposer);
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
// If we were viewing a highlighted event, firing view_room without
// an event will take care of both clearing the URL fragment and
// jumping to the bottom
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
});
} else {
// Otherwise we have to jump manually
this.messagePanel.jumpToLiveTimeline();
dis.fire(Action.FocusComposer);
}
};
// jump up to wherever our read marker is
@ -1581,7 +1641,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// a maxHeight on the underlying remote video tag.
// header + footer + status + give us at least 120px of scrollback at all times.
let auxPanelMaxHeight = window.innerHeight -
let auxPanelMaxHeight = UIStore.instance.windowHeight -
(54 + // height of RoomHeader
36 + // height of the status area
51 + // minimum height of the message compmoser
@ -1594,33 +1654,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.setState({auxPanelMaxHeight: auxPanelMaxHeight});
};
private onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
}, true);
};
private onMuteAudioClick = () => {
const call = this.getCallForRoom();
if (!call) {
return;
}
const newState = !call.isMicrophoneMuted();
call.setMicrophoneMuted(newState);
this.forceUpdate(); // TODO: just update the voip buttons
};
private onMuteVideoClick = () => {
const call = this.getCallForRoom();
if (!call) {
return;
}
const newState = !call.isLocalVideoMuted();
call.setLocalVideoMuted(newState);
this.forceUpdate(); // TODO: just update the voip buttons
};
private onStatusBarVisible = () => {
if (this.unmounted) return;
this.setState({
@ -1746,7 +1779,10 @@ export default class RoomView extends React.Component<IProps, IState> {
}
const myMembership = this.state.room.getMyMembership();
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself
if (myMembership === "invite"
// SpaceRoomView handles invites itself
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
) {
if (this.state.joining || this.state.rejecting) {
return (
<ErrorBoundary>
@ -1849,11 +1885,7 @@ export default class RoomView extends React.Component<IProps, IState> {
let aux = null;
let previewBar;
let hideCancel = false;
if (this.state.forwardingEvent) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
hideCancel = true; // has own cancel
if (this.state.searching) {
aux = <SearchBar
searchInProgress={this.state.searchInProgress}
onCancelClick={this.onCancelSearchClick}
@ -1862,10 +1894,6 @@ export default class RoomView extends React.Component<IProps, IState> {
/>;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it.
@ -1874,7 +1902,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inviterName = this.props.oobData.inviterName;
}
const invitedEmail = this.props.threepidInvite?.toEmail;
hideCancel = true;
previewBar = (
<RoomPreviewBar
onJoinClick={this.onJoinButtonClicked}
@ -1888,7 +1915,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room}
/>
);
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) {
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
return (
<div className="mx_RoomView">
{ previewBar }
@ -1910,7 +1937,7 @@ export default class RoomView extends React.Component<IProps, IState> {
);
}
if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) {
if (this.state.room?.isSpaceRoom()) {
return <SpaceRoomView
space={this.state.room}
justCreatedOpts={this.props.justCreatedOpts}
@ -1992,11 +2019,8 @@ export default class RoomView extends React.Component<IProps, IState> {
hideMessagePanel = true;
}
const shouldHighlight = this.state.isInitialEventHighlighted;
let highlightedEventId = null;
if (this.state.forwardingEvent) {
highlightedEventId = this.state.forwardingEvent.getId();
} else if (shouldHighlight) {
if (this.state.isInitialEventHighlighted) {
highlightedEventId = this.state.initialEventId;
}
@ -2014,12 +2038,14 @@ export default class RoomView extends React.Component<IProps, IState> {
timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking}
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel}
highlightedEventId={highlightedEventId}
eventId={this.state.initialEventId}
eventPixelOffset={this.state.initialEventPixelOffset}
onScroll={this.onMessageListScroll}
onUserScroll={this.onUserScroll}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview}
className={messagePanelClassNames}
@ -2046,6 +2072,7 @@ export default class RoomView extends React.Component<IProps, IState> {
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>);
}
@ -2082,8 +2109,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}

View file

@ -133,6 +133,10 @@ export default class ScrollPanel extends React.Component {
*/
onScroll: PropTypes.func,
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll: PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: PropTypes.string,
@ -525,7 +529,7 @@ export default class ScrollPanel extends React.Component {
*/
scrollRelative = mult => {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
const delta = mult * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta);
this._saveScrollState();
};
@ -535,21 +539,29 @@ export default class ScrollPanel extends React.Component {
* @param {object} ev the keyboard event
*/
handleScrollKey = ev => {
let isScrolling = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
case RoomAction.ScrollUp:
this.scrollRelative(-1);
isScrolling = true;
break;
case RoomAction.RoomScrollDown:
this.scrollRelative(1);
isScrolling = true;
break;
case RoomAction.JumpToFirstMessage:
this.scrollToTop();
isScrolling = true;
break;
case RoomAction.JumpToLatestMessage:
this.scrollToBottom();
isScrolling = true;
break;
}
if (isScrolling && this.props.onUserScroll) {
this.props.onUserScroll(ev);
}
};
/* Scroll the panel to bring the DOM node with the scroll token
@ -884,16 +896,19 @@ export default class ScrollPanel extends React.Component {
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
// list-style-type: none; is no longer a list
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
return (
<AutoHideScrollbar
wrappedRef={this._collectScroll}
onScroll={this.onScroll}
onWheel={this.props.onUserScroll}
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 }
</ol>
</div>
</AutoHideScrollbar>
);
{ this.props.fixedChildren }
<div className="mx_RoomView_messageListWrapper">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
{ this.props.children }
</ol>
</div>
</AutoHideScrollbar>
);
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useMemo, useState} from "react";
import React, {ReactNode, useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
@ -24,7 +24,7 @@ import {sortBy} from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
@ -39,11 +39,15 @@ import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import {linkifyElement} from "../../HtmlUtils";
interface IHierarchyProps {
space: Room;
initialText?: string;
refreshToken?: any;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
@ -72,7 +76,7 @@ export interface ISpaceSummaryEvent {
order?: string;
suggested?: boolean;
auto_join?: boolean;
via?: string;
via?: string[];
};
}
/* eslint-enable camelcase */
@ -97,20 +101,26 @@ const Tile: React.FC<ITileProps> = ({
numChildRooms,
children,
}) => {
const name = room.name || room.canonical_alias || room.aliases?.[0]
const cli = MatrixClientPeg.get();
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership();
const onPreviewClick = () => onViewRoomClick(false);
const onJoinClick = () => onViewRoomClick(true);
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
}
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
}
let button;
if (myMembership === "join") {
if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
{ _t("View") }
</AccessibleButton>;
@ -134,17 +144,27 @@ const Tile: React.FC<ITileProps> = ({
}
}
let url: string;
if (room.avatar_url) {
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20);
let avatar;
if (joinedRoom) {
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms) {
if (numChildRooms !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
if (room.topic) {
description += " · " + room.topic;
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
}
let suggestedSection;
@ -155,13 +175,22 @@ const Tile: React.FC<ITileProps> = ({
}
const content = <React.Fragment>
<BaseAvatar name={name} idName={room.room_id} url={url} width={20} height={20} />
{ avatar }
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_info">
<div
className="mx_SpaceRoomDirectory_roomTile_info"
ref={e => e && linkifyElement(e)}
onClick={ev => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{ description }
</div>
<div className="mx_SpaceRoomDirectory_actions">
@ -254,7 +283,11 @@ export const HierarchyLevel = ({
const space = cli.getRoom(spaceId);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null);
const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;
if (!rooms.has(roomId)) return result;
@ -286,7 +319,7 @@ export const HierarchyLevel = ({
key={roomId}
room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length}
.filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
@ -331,9 +364,9 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
}
if (Array.isArray(ev.content["via"])) {
if (Array.isArray(ev.content.via)) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via));
ev.content.via.forEach(via => set.add(via));
}
});
@ -350,6 +383,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
initialText = "",
showRoom,
refreshToken,
additionalButtons,
children,
}) => {
const cli = MatrixClientPeg.get();
@ -403,7 +437,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
@ -415,78 +449,87 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let editSection;
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
let buttons;
if (selectedRelations.length) {
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = removing || saving;
const disabled = !selectedRelations.length || removing || saving;
buttons = <>
<AccessibleButton
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).get(childId).content = {};
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</AccessibleButton>
<AccessibleButton
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</AccessibleButton>
</>;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
editSection = <span>
{ buttons }
</span>;
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
@ -532,7 +575,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
{ editSection }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
@ -550,7 +596,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Search names and description") }
placeholder={ _t("Search names and descriptions") }
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}

View file

@ -51,6 +51,20 @@ import MemberAvatar from "../views/avatars/MemberAvatar";
import {useStateToggle} from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import {BetaPill} from "../views/beta/BetaCard";
import {USER_LABS_TAB} from "../views/dialogs/UserSettingsDialog";
import SettingsStore from "../../settings/SettingsStore";
import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig";
interface IProps {
space: Room;
@ -62,6 +76,7 @@ interface IProps {
interface IState {
phase: Phase;
createdRooms?: boolean; // internal state for the creation wizard
showRightPanel: boolean;
myMembership: string;
}
@ -76,6 +91,26 @@ enum Phase {
PrivateExistingRooms,
}
// XXX: Temporary for the Spaces Beta only
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
if (!SdkConfig.get().bug_report_endpoint_url) return null;
return <div className="mx_SpaceFeedbackPrompt">
<hr />
<div>
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
<AccessibleButton kind="link" onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
featureId: "feature_spaces",
});
}}>
{ _t("Feedback") }
</AccessibleButton>
</div>
</div>;
};
const RoomMemberCount = ({ room, children }) => {
const members = useRoomMembers(room);
const count = members.length;
@ -127,15 +162,39 @@ const SpaceInfo = ({ space }) => {
</div>
};
const onBetaClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: USER_LABS_TAB,
});
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
const [busy, setBusy] = useState(false);
const spacesEnabled = SettingsStore.getValue("feature_spaces");
let inviterSection;
let joinButtons;
if (myMembership === "invite") {
if (myMembership === "join") {
// XXX remove this when spaces leaves Beta
joinButtons = (
<AccessibleButton
kind="danger_outline"
onClick={() => {
dis.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave") }
</AccessibleButton>
);
} else if (myMembership === "invite") {
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
const inviter = inviteSender && space.getMember(inviteSender);
@ -171,6 +230,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true);
onJoinButtonClicked();
}}
disabled={!spacesEnabled}
>
{ _t("Accept") }
</AccessibleButton>
@ -183,10 +243,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true);
onJoinButtonClicked();
}}
disabled={!spacesEnabled}
>
{ _t("Join") }
</AccessibleButton>
)
);
}
if (busy) {
@ -194,6 +255,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
}
return <div className="mx_SpaceRoomView_preview">
<BetaPill onClick={onBetaClick} />
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">
@ -211,9 +273,84 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
<div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons }
</div>
{ !spacesEnabled && <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ myMembership === "join"
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
})
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
})
}
</div> }
</div>;
};
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
const cli = useContext(MatrixClientContext);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const rect = handle.current.getBoundingClientRect();
contextMenu = <IconizedContextMenu
left={rect.left + window.pageXOffset + 0}
top={rect.bottom + window.pageYOffset + 8}
chevronFace={ChevronFace.None}
onFinished={closeMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
if (await showCreateNewRoom(cli, space)) {
onNewRoomAdded();
}
}}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
const [added] = await showAddExistingRooms(cli, space);
if (added) {
onNewRoomAdded();
}
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return <>
<ContextMenuButton
kind="primary"
inputRef={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("Add")}
>
{ _t("Add") }
</ContextMenuButton>
{ contextMenu }
</>;
};
const SpaceLanding = ({ space }) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
@ -238,32 +375,20 @@ const SpaceLanding = ({ space }) => {
const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButtons;
let addRoomButton;
if (canAddRooms) {
addRoomButtons = <React.Fragment>
<AccessibleButton className="mx_SpaceRoomView_landing_addButton" onClick={async () => {
const [added] = await showAddExistingRooms(cli, space);
if (added) {
forceUpdate();
}
}}>
{ _t("Add existing rooms & spaces") }
</AccessibleButton>
<AccessibleButton className="mx_SpaceRoomView_landing_createButton" onClick={() => {
showCreateNewRoom(cli, space);
}}>
{ _t("Create a new room") }
</AccessibleButton>
</React.Fragment>;
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
}
let settingsButton;
if (shouldShowSpaceSettings(cli, space)) {
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => {
showSpaceSettings(cli, space);
}}>
{ _t("Settings") }
</AccessibleButton>;
settingsButton = <AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => {
showSpaceSettings(cli, space);
}}
title={_t("Settings")}
/>;
}
const onMembersClick = () => {
@ -290,17 +415,24 @@ const SpaceLanding = ({ space }) => {
<SpaceInfo space={space} />
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton }
</div>
<div className="mx_SpaceRoomView_landing_topic">
<RoomTopic room={space} />
</div>
<hr />
<div className="mx_SpaceRoomView_landing_adminButtons">
{ addRoomButtons }
{ settingsButton }
</div>
<RoomTopic room={space}>
{(topic, ref) => (
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
{ topic }
</div>
)}
</RoomTopic>
<SpaceFeedbackPrompt />
<hr />
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} />
<SpaceHierarchy
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
</div>;
};
@ -309,7 +441,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
const [error, setError] = useState("");
const numFields = 3;
const placeholders = [_t("General"), _t("Random"), _t("Support")];
// TODO vary default prefills for "Just Me" spaces
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
const fields = new Array(numFields).fill(0).map((_, i) => {
const name = "roomName" + i;
@ -322,14 +453,18 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
value={roomNames[i]}
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
disabled={busy}
/>;
});
const onNextClick = async () => {
const onNextClick = async (ev) => {
ev.preventDefault();
if (busy) return;
setError("");
setBusy(true);
try {
await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => {
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
await Promise.all(filteredRoomNames.map(name => {
return createRoom({
createOpts: {
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
@ -342,7 +477,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
parentSpace: space,
});
}));
onFinished();
onFinished(filteredRoomNames.length > 0);
} catch (e) {
console.error("Failed to create initial space rooms", e);
setError(_t("Failed to create initial space rooms"));
@ -350,11 +485,14 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setBusy(false);
};
let onClick = onFinished;
let onClick = (ev) => {
ev.preventDefault();
onFinished(false);
};
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue")
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue");
}
return <div>
@ -362,23 +500,55 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
<div className="mx_SpaceRoomView_description">{ description }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
{ fields }
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
{ fields }
</form>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
>
{ buttonLabel }
</AccessibleButton>
element="input"
type="submit"
form="mx_SpaceSetupFirstRooms"
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
const SpaceSetupPublicShare = ({ space, onFinished }) => {
const SpaceAddExistingRooms = ({ space, onFinished }) => {
return <div>
<h1>{ _t("What do you want to organise?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Pick rooms or conversations to add. This is just a space for you, " +
"no one will be informed. You can add more later.") }
</div>
<AddExistingToSpace
space={space}
emptySelectionButton={
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Skip for now") }
</AccessibleButton>
}
onFinished={onFinished}
/>
<div className="mx_SpaceRoomView_buttons">
</div>
<SpaceFeedbackPrompt />
</div>;
};
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1>
<h1>{ _t("Share %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("It's just you at the moment, it will be even better with others.") }
</div>
@ -387,17 +557,20 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Go to my first room") }
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
</div>
<SpaceFeedbackPrompt />
</div>;
};
const SpaceSetupPrivateScope = ({ space, onFinished }) => {
const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
return <div className="mx_SpaceRoomView_privateScope">
<h1>{ _t("Who are you working with?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access to %(name)s", { name: space.name }) }
{ _t("Make sure the right people have access to %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }
</div>
<AccessibleButton
@ -414,6 +587,11 @@ const SpaceSetupPrivateScope = ({ space, onFinished }) => {
<h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div>
</AccessibleButton>
<div className="mx_SpaceRoomView_betaWarning">
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -444,10 +622,13 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
ref={fieldRefs[i]}
onValidate={validateEmailRules}
autoFocus={i === 0}
disabled={busy}
/>;
});
const onNextClick = async () => {
const onNextClick = async (ev) => {
ev.preventDefault();
if (busy) return;
setError("");
for (let i = 0; i < fieldRefs.length; i++) {
const fieldRef = fieldRefs[i];
@ -481,7 +662,10 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
setBusy(false);
};
let onClick = onFinished;
let onClick = (ev) => {
ev.preventDefault();
onFinished();
};
let buttonLabel = _t("Skip for now");
if (emailAddresses.some(name => name.trim())) {
onClick = onNextClick;
@ -494,8 +678,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
{ _t("Make sure the right people have access. You can invite more later.") }
</div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill onClick={onBetaClick} />
{ _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>,
link: () => <a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
app.element.io
</a>,
}) }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
{ fields }
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
{ fields }
</form>
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
<AccessibleButton
@ -507,10 +704,17 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" disabled={busy} onClick={onClick}>
{ buttonLabel }
</AccessibleButton>
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
element="input"
type="submit"
form="mx_SpaceSetupPrivateInvite"
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -609,7 +813,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
let suggestedRooms = SpaceStore.instance.suggestedRooms;
if (SpaceStore.instance.activeSpace !== this.props.space) {
// the space store has the suggested rooms loaded for a different space, fetch the right ones
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms;
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
}
if (suggestedRooms.length) {
@ -617,9 +821,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
room_alias: room.canonical_alias || room.aliases?.[0],
via_servers: room.viaServers,
oobData: {
avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"),
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
},
});
return;
@ -631,7 +837,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
private renderBody() {
switch (this.state.phase) {
case Phase.Landing:
if (this.state.myMembership === "join") {
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
return <SpaceLanding space={this.props.space} />;
} else {
return <SpacePreview
@ -644,22 +850,28 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What are some things you want to discuss in %(spaceName)s?", {
spaceName: this.props.space.name,
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
})}
description={
_t("Let's create a room for each of them.") + "\n" +
_t("You can add more later too, including already existing ones.")
}
onFinished={() => this.setState({ phase: Phase.PublicShare })}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
/>;
case Phase.PublicShare:
return <SpaceSetupPublicShare space={this.props.space} onFinished={this.goToFirstRoom} />;
return <SpaceSetupPublicShare
justCreatedOpts={this.props.justCreatedOpts}
space={this.props.space}
onFinished={this.goToFirstRoom}
createdRooms={this.state.createdRooms}
/>;
case Phase.PrivateScope:
return <SpaceSetupPrivateScope
space={this.props.space}
justCreatedOpts={this.props.justCreatedOpts}
onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms });
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
}}
/>;
case Phase.PrivateInvite:
@ -673,6 +885,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
title={_t("What projects are you working on?")}
description={_t("We'll create rooms for each of them. " +
"You can add more later too, including already existing ones.")}
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
/>;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
}

View file

@ -26,6 +26,7 @@ import {EventTimeline} from "matrix-js-sdk/src/models/event-timeline";
import {TimelineWindow} from "matrix-js-sdk/src/timeline-window";
import { _t } from '../../languageHandler';
import {MatrixClientPeg} from "../../MatrixClientPeg";
import RoomContext from "../../contexts/RoomContext";
import UserActivity from "../../UserActivity";
import Modal from "../../Modal";
import dis from "../../dispatcher/dispatcher";
@ -36,8 +37,8 @@ import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import {haveTileForEvent} from "../views/rooms/EventTile";
import {UIFeature} from "../../settings/UIFeature";
import {objectHasDiff} from "../../utils/objects";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { arrayFastClone } from "../../utils/arrays";
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
@ -68,6 +69,7 @@ class TimelinePanel extends React.Component {
showReadReceipts: PropTypes.bool,
// Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts: PropTypes.bool,
sendReadReceiptOnLoad: PropTypes.bool,
manageReadMarkers: PropTypes.bool,
// true to give the component a 'display: none' style.
@ -92,6 +94,9 @@ class TimelinePanel extends React.Component {
// callback which is called when the panel is scrolled.
onScroll: PropTypes.func,
// callback which is called when the user interacts with the room timeline
onUserScroll: PropTypes.func,
// callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: PropTypes.func,
@ -116,8 +121,13 @@ class TimelinePanel extends React.Component {
// which layout to use
layout: LayoutPropType,
// whether to always show timestamps for an event
alwaysShowTimestamps: PropTypes.bool,
}
static contextType = RoomContext;
// a map from room id to read marker event timestamp
static roomReadMarkerTsMap = {};
@ -126,6 +136,7 @@ class TimelinePanel extends React.Component {
// event tile heights. (See _unpaginateEvents)
timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true,
};
constructor(props) {
@ -255,37 +266,15 @@ class TimelinePanel extends React.Component {
console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue");
}
if (newProps.eventId != this.props.eventId) {
const differentEventId = newProps.eventId != this.props.eventId;
const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId;
if (differentEventId || differentHighlightedEventId) {
console.log("TimelinePanel switching to eventId " + newProps.eventId +
" (was " + this.props.eventId + ")");
return this._initTimeline(newProps);
}
}
shouldComponentUpdate(nextProps, nextState) {
if (objectHasDiff(this.props, nextProps)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: props change");
console.log("props before:", this.props);
console.log("props after:", nextProps);
console.groupEnd();
}
return true;
}
if (objectHasDiff(this.state, nextState)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: state change");
console.log("state before:", this.state);
console.log("state after:", nextState);
console.groupEnd();
}
return true;
}
return false;
}
componentWillUnmount() {
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
@ -785,8 +774,10 @@ class TimelinePanel extends React.Component {
return;
}
const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
this._setReadMarker(lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs());
this._setReadMarker(
lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs(),
);
// the read-marker should become invisible, so that if the user scrolls
// down, they don't see it.
@ -872,7 +863,7 @@ class TimelinePanel extends React.Component {
// The messagepanel knows where the RM is, so we must have loaded
// the relevant event.
this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId,
0, 1/3);
0, 1/3);
return;
}
@ -1044,12 +1035,14 @@ class TimelinePanel extends React.Component {
}
if (eventId) {
this._messagePanel.current.scrollToEvent(eventId, pixelOffset,
offsetBase);
offsetBase);
} else {
this._messagePanel.current.scrollToBottom();
}
this.sendReadReceipt();
if (this.props.sendReadReceiptOnLoad) {
this.sendReadReceipt();
}
});
};
@ -1135,6 +1128,17 @@ class TimelinePanel extends React.Component {
// get the list of events from the timeline window and the pending event list
_getEvents() {
const events = this._timelineWindow.getEvents();
// `arrayFastClone` performs a shallow copy of the array
// we want the last event to be decrypted first but displayed last
// `reverse` is destructive and unfortunately mutates the "events" array
arrayFastClone(events)
.reverse()
.forEach(event => {
const client = MatrixClientPeg.get();
client.decryptEventIfNeeded(event);
});
const firstVisibleEventIndex = this._checkForPreJoinUISI(events);
// Hold onto the live events separately. The read receipt and read marker
@ -1287,7 +1291,7 @@ class TimelinePanel extends React.Component {
const shouldIgnore = !!ev.status || // local echo
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev);
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context);
if (isWithoutTile || !node) {
// don't start counting if the event should be ignored,
@ -1418,8 +1422,8 @@ class TimelinePanel extends React.Component {
['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState)
);
const events = this.state.firstVisibleEventIndex
? this.state.events.slice(this.state.firstVisibleEventIndex)
: this.state.events;
? this.state.events.slice(this.state.firstVisibleEventIndex)
: this.state.events;
return (
<MessagePanel
ref={this._messagePanel}
@ -1438,10 +1442,11 @@ class TimelinePanel extends React.Component {
ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll}
onUserScroll={this.props.onUserScroll}
onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps}
className={this.props.className}
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}

View file

@ -55,6 +55,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
const totalCount = this.state.toasts.length;
const isStacked = totalCount > 1;
let toast;
let containerClasses;
if (totalCount !== 0) {
const topToast = this.state.toasts[0];
const {title, icon, key, component, className, props} = topToast;
@ -79,16 +80,17 @@ export default class ToastContainer extends React.Component<{}, IState> {
</div>
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
</div>);
containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
});
}
const containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
});
return (
<div className={containerClasses} role="alert">
{toast}
</div>
);
return toast
? (
<div className={containerClasses} role="alert">
{toast}
</div>
)
: null;
}
}

View file

@ -57,7 +57,8 @@ import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import RoomName from "../views/elements/RoomName";
import {replaceableComponent} from "../../utils/replaceableComponent";
import InlineSpinner from "../views/elements/InlineSpinner";
import TooltipButton from "../views/elements/TooltipButton";
interface IProps {
isMinimized: boolean;
}
@ -68,6 +69,7 @@ interface IState {
contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean;
selectedSpace?: Room;
pendingRoomJoin: Set<string>;
}
@replaceableComponent("structures.UserMenu")
@ -84,6 +86,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.state = {
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
pendingRoomJoin: new Set<string>(),
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
@ -103,6 +106,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
MatrixClientPeg.get().on("Room", this.onRoom);
}
public componentWillUnmount() {
@ -114,6 +118,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_spaces")) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
MatrixClientPeg.get().removeListener("Room", this.onRoom);
}
private onRoom = (room: Room): void => {
this.removePendingJoinRoom(room.roomId);
}
private onTagStoreUpdate = () => {
@ -147,15 +156,39 @@ export default class UserMenu extends React.Component<IProps, IState> {
};
private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested
if (this.state.contextMenuPosition) {
this.setState({contextMenuPosition: null});
} else {
if (this.buttonRef.current) this.buttonRef.current.click();
switch (ev.action) {
case Action.ToggleUserMenu:
if (this.state.contextMenuPosition) {
this.setState({contextMenuPosition: null});
} else {
if (this.buttonRef.current) this.buttonRef.current.click();
}
break;
case Action.JoinRoom:
this.addPendingJoinRoom(ev.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
this.removePendingJoinRoom(ev.roomId);
break;
}
};
private addPendingJoinRoom(roomId: string): void {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin)
.add(roomId),
});
}
private removePendingJoinRoom(roomId: string): void {
if (this.state.pendingRoomJoin.delete(roomId)) {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin),
})
}
}
private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -333,9 +366,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const mxDomain = MatrixClientPeg.get().getDomain();
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
if (!hostSignupConfig.domains || validDomains.length > 0) {
topSection = <div onClick={this.onCloseMenu}>
<HostSignupAction />
</div>;
topSection = <HostSignupAction onClick={this.onCloseMenu} />;
}
}
}
@ -617,6 +648,14 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</span>
{name}
{this.state.pendingRoomJoin.size > 0 && (
<InlineSpinner>
<TooltipButton helpText={_t(
"Currently joining %(count)s rooms",
{ count: this.state.pendingRoomJoin.size },
)} />
</InlineSpinner>
)}
{dnd}
{buttons}
</div>

View file

@ -59,6 +59,7 @@ interface IProps {
fallbackHsUrl?: string;
defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string;
defaultUsername?: string;
// Called when the user has logged in. Params:
// - The object returned by the login API
@ -119,7 +120,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
flows: null,
username: "",
username: props.defaultUsername? props.defaultUsername: '',
phoneCountry: null,
phoneNumber: "",

View file

@ -61,7 +61,7 @@ interface IProps {
is_url?: string;
session_id: string;
/* eslint-enable camelcase */
}): void;
}): string;
// registration shouldn't know or care how login is done.
onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
@ -223,7 +223,8 @@ export default class Registration extends React.Component<IProps, IState> {
this.setState({
flows: e.data.flows,
});
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") {
} else if (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN") {
// Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN.
// At this point registration is pretty much disabled, but before we do that let's
// quickly check to see if the server supports SSO instead. If it does, we'll send
// the user off to the login page to figure their account out.
@ -467,7 +468,7 @@ export default class Registration extends React.Component<IProps, IState> {
let ssoSection;
if (this.state.ssoFlow) {
let continueWithSection;
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || [];
const providers = this.state.ssoFlow.identity_providers || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context

View file

@ -1,7 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
import classNames from 'classnames';
import { MatrixClient } from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -27,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { LocalisedPolicy, Policies } from '../../../Terms';
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -74,36 +73,72 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
* focus: set the input focus appropriately in the form.
*/
enum AuthType {
Password = "m.login.password",
Recaptcha = "m.login.recaptcha",
Terms = "m.login.terms",
Email = "m.login.email.identity",
Msisdn = "m.login.msisdn",
Sso = "m.login.sso",
SsoUnstable = "org.matrix.login.sso",
}
/* eslint-disable camelcase */
interface IAuthDict {
type?: AuthType;
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user?: string;
identifier?: any;
password?: string;
response?: string;
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds?: any;
threepidCreds?: any;
}
/* eslint-enable camelcase */
export const DEFAULT_PHASE = 0;
@replaceableComponent("views.auth.PasswordAuthEntry")
export class PasswordAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.password";
interface IAuthEntryProps {
matrixClient: MatrixClient;
loginType: string;
authSessionId: string;
errorText?: string;
// Is the auth logic currently waiting for something to happen?
busy?: boolean;
onPhaseChange: (phase: number) => void;
submitAuthDict: (auth: IAuthDict) => void;
}
static propTypes = {
matrixClient: PropTypes.object.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
// is the auth logic currently waiting for something to
// happen?
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
};
interface IPasswordAuthEntryState {
password: string;
}
@replaceableComponent("views.auth.PasswordAuthEntry")
export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswordAuthEntryState> {
static LOGIN_TYPE = AuthType.Password;
constructor(props) {
super(props);
this.state = {
password: "",
};
}
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
}
state = {
password: "",
};
_onSubmit = e => {
private onSubmit = (e: FormEvent) => {
e.preventDefault();
if (this.props.busy) return;
this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE,
type: AuthType.Password,
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user: this.props.matrixClient.credentials.userId,
@ -115,7 +150,7 @@ export class PasswordAuthEntry extends React.Component {
});
};
_onPasswordFieldChange = ev => {
private onPasswordFieldChange = (ev: ChangeEvent<HTMLInputElement>) => {
// enable the submit button iff the password is non-empty
this.setState({
password: ev.target.value,
@ -123,7 +158,7 @@ export class PasswordAuthEntry extends React.Component {
};
render() {
const passwordBoxClass = classnames({
const passwordBoxClass = classNames({
"error": this.props.errorText,
});
@ -155,7 +190,7 @@ export class PasswordAuthEntry extends React.Component {
return (
<div>
<p>{ _t("Confirm your identity by entering your account password below.") }</p>
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<form onSubmit={this.onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<Field
className={passwordBoxClass}
type="password"
@ -163,38 +198,38 @@ export class PasswordAuthEntry extends React.Component {
label={_t('Password')}
autoFocus={true}
value={this.state.password}
onChange={this._onPasswordFieldChange}
onChange={this.onPasswordFieldChange}
/>
<div className="mx_button_row">
{ submitButtonOrSpinner }
</div>
</form>
{ errorSection }
{ errorSection }
</div>
);
}
}
@replaceableComponent("views.auth.RecaptchaAuthEntry")
export class RecaptchaAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.recaptcha";
static propTypes = {
submitAuthDict: PropTypes.func.isRequired,
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
/* eslint-disable camelcase */
interface IRecaptchaAuthEntryProps extends IAuthEntryProps {
stageParams?: {
public_key?: string;
};
}
/* eslint-enable camelcase */
@replaceableComponent("views.auth.RecaptchaAuthEntry")
export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps> {
static LOGIN_TYPE = AuthType.Recaptcha;
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
}
_onCaptchaResponse = response => {
private onCaptchaResponse = (response: string) => {
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE,
type: AuthType.Recaptcha,
response: response,
});
};
@ -230,7 +265,7 @@ export class RecaptchaAuthEntry extends React.Component {
return (
<div>
<CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this._onCaptchaResponse}
onCaptchaResponse={this.onCaptchaResponse}
/>
{ errorSection }
</div>
@ -238,18 +273,28 @@ export class RecaptchaAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.TermsAuthEntry")
export class TermsAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.terms";
static propTypes = {
submitAuthDict: PropTypes.func.isRequired,
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
showContinue: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
interface ITermsAuthEntryProps extends IAuthEntryProps {
stageParams?: {
policies?: Policies;
};
showContinue: boolean;
}
interface LocalisedPolicyWithId extends LocalisedPolicy {
id: string;
}
interface ITermsAuthEntryState {
policies: LocalisedPolicyWithId[];
toggledPolicies: {
[policy: string]: boolean;
};
errorText?: string;
}
@replaceableComponent("views.auth.TermsAuthEntry")
export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITermsAuthEntryState> {
static LOGIN_TYPE = AuthType.Terms;
constructor(props) {
super(props);
@ -294,8 +339,11 @@ export class TermsAuthEntry extends React.Component {
initToggles[policyId] = false;
langPolicy.id = policyId;
pickedPolicies.push(langPolicy);
pickedPolicies.push({
id: policyId,
name: langPolicy.name,
url: langPolicy.url,
});
}
this.state = {
@ -311,11 +359,11 @@ export class TermsAuthEntry extends React.Component {
this.props.onPhaseChange(DEFAULT_PHASE);
}
tryContinue = () => {
this._trySubmit();
public tryContinue = () => {
this.trySubmit();
};
_togglePolicy(policyId) {
private togglePolicy(policyId: string) {
const newToggles = {};
for (const policy of this.state.policies) {
let checked = this.state.toggledPolicies[policy.id];
@ -326,7 +374,7 @@ export class TermsAuthEntry extends React.Component {
this.setState({"toggledPolicies": newToggles});
}
_trySubmit = () => {
private trySubmit = () => {
let allChecked = true;
for (const policy of this.state.policies) {
const checked = this.state.toggledPolicies[policy.id];
@ -334,7 +382,7 @@ export class TermsAuthEntry extends React.Component {
}
if (allChecked) {
this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE});
this.props.submitAuthDict({type: AuthType.Terms});
CountlyAnalytics.instance.track("onboarding_terms_complete");
} else {
this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
@ -356,7 +404,7 @@ export class TermsAuthEntry extends React.Component {
checkboxes.push(
// XXX: replace with StyledCheckbox
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} />
<input type="checkbox" onChange={() => this.togglePolicy(policy.id)} checked={checked} />
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
</label>,
);
@ -375,7 +423,7 @@ export class TermsAuthEntry extends React.Component {
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
onClick={this.trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
}
return (
@ -389,21 +437,18 @@ export class TermsAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.EmailIdentityAuthEntry")
export class EmailIdentityAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.email.identity";
static propTypes = {
matrixClient: PropTypes.object.isRequired,
submitAuthDict: PropTypes.func.isRequired,
authSessionId: PropTypes.string.isRequired,
clientSecret: PropTypes.string.isRequired,
inputs: PropTypes.object.isRequired,
stageState: PropTypes.object.isRequired,
fail: PropTypes.func.isRequired,
setEmailSid: PropTypes.func.isRequired,
onPhaseChange: PropTypes.func.isRequired,
interface IEmailIdentityAuthEntryProps extends IAuthEntryProps {
inputs?: {
emailAddress?: string;
};
stageState?: {
emailSid: string;
};
}
@replaceableComponent("views.auth.EmailIdentityAuthEntry")
export class EmailIdentityAuthEntry extends React.Component<IEmailIdentityAuthEntryProps> {
static LOGIN_TYPE = AuthType.Email;
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
@ -427,7 +472,7 @@ export class EmailIdentityAuthEntry extends React.Component {
return (
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
<p>{ _t("A confirmation email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> },
{ emailAddress: <b>{ this.props.inputs.emailAddress }</b> },
) }
</p>
<p>{ _t("Open the link in the email to continue registration.") }</p>
@ -437,37 +482,44 @@ export class EmailIdentityAuthEntry extends React.Component {
}
}
interface IMsisdnAuthEntryProps extends IAuthEntryProps {
inputs: {
phoneCountry: string;
phoneNumber: string;
};
clientSecret: string;
fail: (error: Error) => void;
}
interface IMsisdnAuthEntryState {
token: string;
requestingToken: boolean;
errorText: string;
}
@replaceableComponent("views.auth.MsisdnAuthEntry")
export class MsisdnAuthEntry extends React.Component {
static LOGIN_TYPE = "m.login.msisdn";
export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsisdnAuthEntryState> {
static LOGIN_TYPE = AuthType.Msisdn;
static propTypes = {
inputs: PropTypes.shape({
phoneCountry: PropTypes.string,
phoneNumber: PropTypes.string,
}),
fail: PropTypes.func,
clientSecret: PropTypes.func,
submitAuthDict: PropTypes.func.isRequired,
matrixClient: PropTypes.object,
onPhaseChange: PropTypes.func.isRequired,
};
private submitUrl: string;
private sid: string;
private msisdn: string;
state = {
token: '',
requestingToken: false,
};
constructor(props) {
super(props);
this.state = {
token: '',
requestingToken: false,
errorText: '',
};
}
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
this._submitUrl = null;
this._sid = null;
this._msisdn = null;
this._tokenBox = null;
this.setState({requestingToken: true});
this._requestMsisdnToken().catch((e) => {
this.requestMsisdnToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
@ -477,26 +529,26 @@ export class MsisdnAuthEntry extends React.Component {
/*
* Requests a verification token by SMS.
*/
_requestMsisdnToken() {
private requestMsisdnToken(): Promise<void> {
return this.props.matrixClient.requestRegisterMsisdnToken(
this.props.inputs.phoneCountry,
this.props.inputs.phoneNumber,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
).then((result) => {
this._submitUrl = result.submit_url;
this._sid = result.sid;
this._msisdn = result.msisdn;
this.submitUrl = result.submit_url;
this.sid = result.sid;
this.msisdn = result.msisdn;
});
}
_onTokenChange = e => {
private onTokenChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
token: e.target.value,
});
};
_onFormSubmit = async e => {
private onFormSubmit = async (e: FormEvent) => {
e.preventDefault();
if (this.state.token == '') return;
@ -506,20 +558,20 @@ export class MsisdnAuthEntry extends React.Component {
try {
let result;
if (this._submitUrl) {
if (this.submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
this.submitUrl, this.sid, this.props.clientSecret, this.state.token,
);
} else {
throw new Error("The registration with MSISDN flow is misconfigured");
}
if (result.success) {
const creds = {
sid: this._sid,
sid: this.sid,
client_secret: this.props.clientSecret,
};
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
type: AuthType.Msisdn,
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
@ -543,7 +595,7 @@ export class MsisdnAuthEntry extends React.Component {
return <Loader />;
} else {
const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({
const submitClasses = classNames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_GeneralButton: true,
});
@ -558,16 +610,16 @@ export class MsisdnAuthEntry extends React.Component {
return (
<div>
<p>{ _t("A text message has been sent to %(msisdn)s",
{ msisdn: <i>{ this._msisdn }</i> },
{ msisdn: <i>{ this.msisdn }</i> },
) }
</p>
<p>{ _t("Please enter the code it contains:") }</p>
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
<form onSubmit={this._onFormSubmit}>
<form onSubmit={this.onFormSubmit}>
<input type="text"
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token}
onChange={this._onTokenChange}
onChange={this.onTokenChange}
aria-label={ _t("Code")}
/>
<br />
@ -584,40 +636,40 @@ export class MsisdnAuthEntry extends React.Component {
}
}
@replaceableComponent("views.auth.SSOAuthEntry")
export class SSOAuthEntry extends React.Component {
static propTypes = {
matrixClient: PropTypes.object.isRequired,
authSessionId: PropTypes.string.isRequired,
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
continueText: PropTypes.string,
continueKind: PropTypes.string,
onCancel: PropTypes.func,
};
interface ISSOAuthEntryProps extends IAuthEntryProps {
continueText?: string;
continueKind?: string;
onCancel?: () => void;
}
static LOGIN_TYPE = "m.login.sso";
static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso";
interface ISSOAuthEntryState {
phase: number;
attemptFailed: boolean;
}
@replaceableComponent("views.auth.SSOAuthEntry")
export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEntryState> {
static LOGIN_TYPE = AuthType.Sso;
static UNSTABLE_LOGIN_TYPE = AuthType.SsoUnstable;
static PHASE_PREAUTH = 1; // button to start SSO
static PHASE_POSTAUTH = 2; // button to confirm SSO completed
_ssoUrl: string;
private ssoUrl: string;
private popupWindow: Window;
constructor(props) {
super(props);
// We actually send the user through fallback auth so we don't have to
// deal with a redirect back to us, losing application context.
this._ssoUrl = props.matrixClient.getFallbackAuthUrl(
this.ssoUrl = props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId,
);
this._popupWindow = null;
window.addEventListener("message", this._onReceiveMessage);
this.popupWindow = null;
window.addEventListener("message", this.onReceiveMessage);
this.state = {
phase: SSOAuthEntry.PHASE_PREAUTH,
@ -625,44 +677,44 @@ export class SSOAuthEntry extends React.Component {
};
}
componentDidMount(): void {
componentDidMount() {
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
}
componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage);
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
window.removeEventListener("message", this.onReceiveMessage);
if (this.popupWindow) {
this.popupWindow.close();
this.popupWindow = null;
}
}
attemptFailed = () => {
public attemptFailed = () => {
this.setState({
attemptFailed: true,
});
};
_onReceiveMessage = event => {
private onReceiveMessage = (event: MessageEvent) => {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
if (this._popupWindow) {
this._popupWindow.close();
this._popupWindow = null;
if (this.popupWindow) {
this.popupWindow.close();
this.popupWindow = null;
}
}
};
onStartAuthClick = () => {
private onStartAuthClick = () => {
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost
// certainly will need to open the thing in a new tab to avoid losing application
// context.
this._popupWindow = window.open(this._ssoUrl, "_blank");
this.popupWindow = window.open(this.ssoUrl, "_blank");
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
};
onConfirmClick = () => {
private onConfirmClick = () => {
this.props.submitAuthDict({});
};
@ -716,46 +768,37 @@ export class SSOAuthEntry extends React.Component {
}
@replaceableComponent("views.auth.FallbackAuthEntry")
export class FallbackAuthEntry extends React.Component {
static propTypes = {
matrixClient: PropTypes.object.isRequired,
authSessionId: PropTypes.string.isRequired,
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
};
export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
private popupWindow: Window;
private fallbackButton = createRef<HTMLAnchorElement>();
constructor(props) {
super(props);
// we have to make the user click a button, as browsers will block
// the popup if we open it immediately.
this._popupWindow = null;
window.addEventListener("message", this._onReceiveMessage);
this._fallbackButton = createRef();
this.popupWindow = null;
window.addEventListener("message", this.onReceiveMessage);
}
componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE);
}
componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage);
if (this._popupWindow) {
this._popupWindow.close();
window.removeEventListener("message", this.onReceiveMessage);
if (this.popupWindow) {
this.popupWindow.close();
}
}
focus = () => {
if (this._fallbackButton.current) {
this._fallbackButton.current.focus();
public focus = () => {
if (this.fallbackButton.current) {
this.fallbackButton.current.focus();
}
};
_onShowFallbackClick = e => {
private onShowFallbackClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -763,10 +806,10 @@ export class FallbackAuthEntry extends React.Component {
this.props.loginType,
this.props.authSessionId,
);
this._popupWindow = window.open(url, "_blank");
this.popupWindow = window.open(url, "_blank");
};
_onReceiveMessage = event => {
private onReceiveMessage = (event: MessageEvent) => {
if (
event.data === "authDone" &&
event.origin === this.props.matrixClient.getHomeserverUrl()
@ -786,27 +829,31 @@ export class FallbackAuthEntry extends React.Component {
}
return (
<div>
<a href="" ref={this._fallbackButton} onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
<a href="" ref={this.fallbackButton} onClick={this.onShowFallbackClick}>{
_t("Start authentication")
}</a>
{errorSection}
</div>
);
}
}
const AuthEntryComponents = [
PasswordAuthEntry,
RecaptchaAuthEntry,
EmailIdentityAuthEntry,
MsisdnAuthEntry,
TermsAuthEntry,
SSOAuthEntry,
];
export default function getEntryComponentForLoginType(loginType) {
for (const c of AuthEntryComponents) {
if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) {
return c;
}
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
switch (loginType) {
case AuthType.Password:
return PasswordAuthEntry;
case AuthType.Recaptcha:
return RecaptchaAuthEntry;
case AuthType.Email:
return EmailIdentityAuthEntry;
case AuthType.Msisdn:
return MsisdnAuthEntry;
case AuthType.Terms:
return TermsAuthEntry;
case AuthType.Sso:
case AuthType.SsoUnstable:
return SSOAuthEntry;
default:
return FallbackAuthEntry;
}
return FallbackAuthEntry;
}

View file

@ -22,6 +22,7 @@ import classNames from 'classnames';
import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
@ -44,12 +45,12 @@ interface IProps {
className?: string;
}
const calculateUrls = (url, urls) => {
const calculateUrls = (url, urls, lowBandwidth) => {
// work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, ...props.urls ]
let _urls = [];
if (!SettingsStore.getValue("lowBandwidth")) {
if (!lowBandwidth) {
_urls = urls || [];
if (url) {
@ -63,7 +64,13 @@ const calculateUrls = (url, urls) => {
};
const useImageUrl = ({url, urls}): [string, () => void] => {
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls));
// Since this is a hot code path and the settings store can be slow, we
// use the cached lowBandwidth value from the room context if it exists
const roomContext = useContext(RoomContext);
const lowBandwidth = roomContext ?
roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
const [urlsIndex, setIndex] = useState<number>(0);
const onError = useCallback(() => {
@ -71,7 +78,7 @@ const useImageUrl = ({url, urls}): [string, () => void] => {
}, []);
useEffect(() => {
setUrls(calculateUrls(url, urls));
setUrls(calculateUrls(url, urls, lowBandwidth));
setIndex(0);
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
@ -179,7 +186,7 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
title={title} alt={_t("Avatar")}
inputRef={inputRef}
{...otherProps} />
);

View file

@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { User } from "matrix-js-sdk/src/models/user";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { TagID } from '../../../stores/room-list/models';
import RoomAvatar from "./RoomAvatar";
import NotificationBadge from '../rooms/NotificationBadge';
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
@ -35,7 +34,6 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
room: Room;
avatarSize: number;
tag: TagID;
displayBadge?: boolean;
forceCount?: boolean;
oobData?: object;
@ -121,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

@ -0,0 +1,108 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import {_t} from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
import TextWithTooltip from "../elements/TextWithTooltip";
import Modal from "../../../Modal";
import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig";
interface IProps {
title?: string;
featureId: string;
}
export const BetaPill = ({ onClick }: { onClick?: () => void }) => {
if (onClick) {
return <TextWithTooltip
class={classNames("mx_BetaCard_betaPill", {
mx_BetaCard_betaPill_clickable: !!onClick,
})}
tooltip={<div>
<div className="mx_Tooltip_title">
{ _t("Spaces is a beta feature") }
</div>
<div className="mx_Tooltip_sub">
{ _t("Tap for more info") }
</div>
</div>}
onClick={onClick}
tooltipProps={{ yOffset: -10 }}
>
{ _t("Beta") }
</TextWithTooltip>;
}
return <span
className={classNames("mx_BetaCard_betaPill", {
mx_BetaCard_betaPill_clickable: !!onClick,
})}
onClick={onClick}
>
{ _t("Beta") }
</span>;
};
const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
const info = SettingsStore.getBetaInfo(featureId);
if (!info) return null; // Beta is invalid/disabled
const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info;
const value = SettingsStore.getValue(featureId);
let feedbackButton;
if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) {
feedbackButton = <AccessibleButton
onClick={() => {
Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId });
}}
kind="primary"
>
{ _t("Feedback") }
</AccessibleButton>;
}
return <div className="mx_BetaCard">
<div>
<h3 className="mx_BetaCard_title">
{ titleOverride || _t(title) }
<BetaPill />
</h3>
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
<div>
{ feedbackButton }
<AccessibleButton
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
kind={feedbackButton ? "primary_outline" : "primary"}
>
{ value ? _t("Leave the beta") : _t("Join the beta") }
</AccessibleButton>
</div>
{ disclaimer && <div className="mx_BetaCard_disclaimer">
{ disclaimer(value) }
</div> }
</div>
<img src={image} alt="" />
</div>;
};
export default BetaCard;

View file

@ -17,9 +17,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -28,9 +28,11 @@ import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
import {MenuItem} from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { MenuItem } from "../../structures/ContextMenu";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog";
export function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -78,9 +80,11 @@ export default class MessageContextMenu extends React.Component {
// We explicitly decline to show the redact option on ACL events as it has a potential
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
// Similarly for encryption events, since redacting them "breaks everything"
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
@ -90,7 +94,7 @@ export default class MessageContextMenu extends React.Component {
_isPinned() {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
@ -154,34 +158,32 @@ export default class MessageContextMenu extends React.Component {
};
onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog();
dis.dispatch({
action: 'forward_event',
Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
matrixClient: MatrixClientPeg.get(),
event: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
};
onPinClick = () => {
MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
.catch((e) => {
// Intercept the Event Not Found error and fall through the promise chain with no event.
if (e.errcode === "M_NOT_FOUND") return null;
throw e;
})
.then((event) => {
const eventIds = (event ? event.pinned : []) || [];
if (!eventIds.includes(this.props.mxEvent.getId())) {
// Not pinned - add
eventIds.push(this.props.mxEvent.getId());
} else {
// Pinned - remove
eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
}
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId();
const cli = MatrixClientPeg.get();
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || [];
if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [
...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids,
eventId,
],
});
}
cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
this.closeMenu();
};
@ -350,7 +352,7 @@ export default class MessageContextMenu extends React.Component {
>
{ _t('Source URL') }
</MenuItem>
);
);
}
if (this.props.collapseReplyThread) {

View file

@ -40,6 +40,8 @@ interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
showUnpin?: boolean;
// override delete handler
onDeleteClick?(): void;
// override edit handler
onEditClick?(): void;
}
const WidgetContextMenu: React.FC<IProps> = ({
@ -47,6 +49,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
app,
userWidget,
onDeleteClick,
onEditClick,
showUnpin,
...props
}) => {
@ -89,12 +92,16 @@ const WidgetContextMenu: React.FC<IProps> = ({
let editButton;
if (canModify && WidgetUtils.isManagedByManager(app)) {
const onEditClick = () => {
WidgetUtils.editWidget(room, app);
const _onEditClick = () => {
if (onEditClick) {
onEditClick();
} else {
WidgetUtils.editWidget(room, app);
}
onFinished();
};
editButton = <IconizedContextMenuOption onClick={onEditClick} label={_t("Edit")} />;
editButton = <IconizedContextMenuOption onClick={_onEditClick} label={_t("Edit")} />;
}
let snapshotButton;
@ -116,24 +123,29 @@ const WidgetContextMenu: React.FC<IProps> = ({
let deleteButton;
if (onDeleteClick || canModify) {
const onDeleteClickDefault = () => {
// Show delete confirmation dialog
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
title: _t("Delete Widget"),
description: _t(
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?"),
button: _t("Delete widget"),
onFinished: (confirmed) => {
if (!confirmed) return;
WidgetUtils.setRoomWidget(roomId, app.id);
},
});
const _onDeleteClick = () => {
if (onDeleteClick) {
onDeleteClick();
} else {
// Show delete confirmation dialog
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
title: _t("Delete Widget"),
description: _t(
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?"),
button: _t("Delete widget"),
onFinished: (confirmed) => {
if (!confirmed) return;
WidgetUtils.setRoomWidget(roomId, app.id);
},
});
}
onFinished();
};
deleteButton = <IconizedContextMenuOption
onClick={onDeleteClick || onDeleteClickDefault}
onClick={_onDeleteClick}
label={userWidget ? _t("Remove") : _t("Remove for everyone")}
/>;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import React, {ReactNode, useContext, useMemo, useState} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -29,10 +29,16 @@ import RoomAvatar from "../avatars/RoomAvatar";
import {getDisplayAliasForRoom} from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {allSettled} from "../../../utils/promise";
import {sleep} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
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;
@ -41,49 +47,241 @@ interface IProps extends IDialogProps {
}
const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpaceDialog_entry">
<RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpaceDialog_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
return <label className="mx_AddExistingToSpace_entry">
{ room?.isSpaceRoom()
? <RoomAvatar room={room} height={32} width={32} />
: <DecoratedRoomAvatar room={room} avatarSize={32} />
}
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
interface IAddExistingToSpaceProps {
space: Room;
footerPrompt?: ReactNode;
emptySelectionButton?: ReactNode;
onFinished(added: boolean): void;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space,
footerPrompt,
emptySelectionButton,
onFinished,
}) => {
const cli = useContext(MatrixClientContext);
const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]);
const [selectedSpace, setSelectedSpace] = useState(space);
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().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 = selectedSpace.getJoinRule();
const [spaces, rooms, dms] = cli.getVisibleRooms().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 && room !== selectedSpace && !existingSubspacesSet.has(room)) {
arr[0].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);
}
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);
}
}
} 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);
setProgress(0);
let error;
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
throw e;
});
setProgress(i => i + 1);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(error = e);
break;
}
}
return arr;
}, [[], [], []]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
if (!error) {
onFinished(true);
}
};
const busy = progress !== null;
let footer;
if (error) {
footer = <>
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="24"
width="24"
alt=""
/>
<span className="mx_AddExistingToSpaceDialog_error">
<div className="mx_AddExistingToSpaceDialog_errorHeading">{ _t("Not all selected were added") }</div>
<div className="mx_AddExistingToSpaceDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_AddExistingToSpaceDialog_retryButton" onClick={addRooms}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else if (busy) {
footer = <span>
<ProgressBar value={progress} max={selectedToAdd.size} />
<div className="mx_AddExistingToSpaceDialog_progressText">
{ _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: selectedToAdd.size,
progress,
}) }
</div>
</span>;
} else {
let button = emptySelectionButton;
if (!button || selectedToAdd.size > 0) {
button = <AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
{ _t("Add") }
</AccessibleButton>;
}
footer = <>
<span>
{ footerPrompt }
</span>
{ button }
</>;
}
const onChange = !busy && !error ? (checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
} : null;
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
<div className="mx_AddExistingToSpace_section_experimental">
<div>{ _t("Feeling experimental?") }</div>
<div>{ _t("You can add existing spaces to a space.") }</div>
</div>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
<div className="mx_AddExistingToSpace_footer">
{ footer }
</div>
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
let spaceOptionSection;
if (existingSubspacesSet.size > 0) {
if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
@ -123,115 +321,24 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
<MatrixClientContext.Provider value={cli}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</>}
/>
</MatrixClientContext.Provider>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpaceDialog_content" id="mx_AddExistingToSpaceDialog">
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : undefined }
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section mx_AddExistingToSpaceDialog_section_spaces">
<h3>{ _t("Spaces") }</h3>
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ dms.length > 0 ? (
<div className="mx_AddExistingToSpaceDialog_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={(checked) => {
if (checked) {
selectedToAdd.add(space);
} else {
selectedToAdd.delete(space);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
/>;
}) }
</div>
) : null }
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
<div className="mx_AddExistingToSpaceDialog_footer">
<span>
<div>{ _t("Don't want to add an existing room?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</span>
<AccessibleButton
kind="primary"
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
}}
>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
</div>
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
</BaseDialog>;
};

View file

@ -0,0 +1,106 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState} from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import {IDialogProps} from "./IDialogProps";
import SettingsStore from "../../../settings/SettingsStore";
import {submitFeedback} from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import {USER_LABS_TAB} from "./UserSettingsDialog";
interface IProps extends IDialogProps {
featureId: string;
}
const BetaFeedbackDialog: React.FC<IProps> = ({featureId, onFinished}) => {
const info = SettingsStore.getBetaInfo(featureId);
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact);
onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
title: _t("Beta feedback"),
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_BetaFeedbackDialog"
hasCancelButton={true}
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
description={<React.Fragment>
<div className="mx_BetaFeedbackDialog_subheading">
{ _t(info.feedbackSubheading) }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.")}
<AccessibleButton kind="link" onClick={() => {
onFinished(false);
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: USER_LABS_TAB,
});
}}>
{ _t("To leave the beta, visit your settings.") }
</AccessibleButton>
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
};
export default BetaFeedbackDialog;

View file

@ -184,7 +184,7 @@ export default class BugReportDialog extends React.Component {
return (
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
title={_t('Submit debug logs')}
title={_t('Submit debug logs')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>

View file

@ -95,7 +95,7 @@ export default class ChangelogDialog extends React.Component {
description={content}
button={_t("Update")}
onFinished={this.props.onFinished}
/>
/>
);
}
}

View file

@ -39,9 +39,12 @@ export default class ConfirmWipeDeviceDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_ConfirmWipeDeviceDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Clear all data in this session?")}>
<BaseDialog
className='mx_ConfirmWipeDeviceDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Clear all data in this session?")}
>
<div className='mx_ConfirmWipeDeviceDialog_content'>
<p>
{_t(

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

@ -1,5 +1,6 @@
/*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,14 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react';
import * as sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import {
PHASE_UNSENT,
@ -30,27 +30,33 @@ import {
PHASE_DONE,
PHASE_STARTED,
PHASE_CANCELLED,
VerificationRequest,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import WidgetStore from "../../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import {SETTINGS} from "../../../settings/Settings";
import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore";
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { SETTINGS } from "../../../settings/Settings";
import SettingsStore, { LEVEL_ORDER } from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SettingLevel } from '../../../settings/SettingLevel';
class GenericEditor extends React.PureComponent {
// static propTypes = {onBack: PropTypes.func.isRequired};
interface IGenericEditorProps {
onBack: () => void;
}
constructor(props) {
super(props);
this._onChange = this._onChange.bind(this);
this.onBack = this.onBack.bind(this);
}
interface IGenericEditorState {
message?: string;
[inputId: string]: boolean | string;
}
onBack() {
abstract class GenericEditor<
P extends IGenericEditorProps = IGenericEditorProps,
S extends IGenericEditorState = IGenericEditorState,
> extends React.PureComponent<P, S> {
protected onBack = () => {
if (this.state.message) {
this.setState({ message: null });
} else {
@ -58,39 +64,60 @@ class GenericEditor extends React.PureComponent {
}
}
_onChange(e) {
protected onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// @ts-ignore: Unsure how to convince TS this is okay when the state
// type can be extended.
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
}
_buttons() {
protected abstract send();
protected buttons(): React.ReactNode {
return <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
</div>;
}
textInput(id, label) {
return <Field id={id} label={label} size="42" autoFocus={true} type="text" autoComplete="on"
value={this.state[id]} onChange={this._onChange} />;
protected textInput(id: string, label: string): React.ReactNode {
return <Field
id={id}
label={label}
size={42}
autoFocus={true}
type="text"
autoComplete="on"
value={this.state[id] as string}
onChange={this.onChange}
/>;
}
}
export class SendCustomEvent extends GenericEditor {
static getLabel() { return _t('Send Custom Event'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
forceStateEvent: PropTypes.bool,
forceGeneralEvent: PropTypes.bool,
inputs: PropTypes.object,
interface ISendCustomEventProps extends IGenericEditorProps {
room: Room;
forceStateEvent?: boolean;
forceGeneralEvent?: boolean;
inputs?: {
eventType?: string;
stateKey?: string;
evContent?: string;
};
}
interface ISendCustomEventState extends IGenericEditorState {
isStateEvent: boolean;
eventType: string;
stateKey: string;
evContent: string;
}
export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendCustomEventState> {
static getLabel() { return _t('Send Custom Event'); }
static contextType = MatrixClientContext;
constructor(props) {
super(props);
this._send = this._send.bind(this);
const {eventType, stateKey, evContent} = Object.assign({
eventType: '',
@ -107,7 +134,7 @@ export class SendCustomEvent extends GenericEditor {
};
}
send(content) {
private doSend(content: object): Promise<void> {
const cli = this.context;
if (this.state.isStateEvent) {
return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey);
@ -116,7 +143,7 @@ export class SendCustomEvent extends GenericEditor {
}
}
async _send() {
protected send = async () => {
if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') });
return;
@ -125,7 +152,7 @@ export class SendCustomEvent extends GenericEditor {
let message;
try {
const content = JSON.parse(this.state.evContent);
await this.send(content);
await this.doSend(content);
message = _t('Event sent!');
} catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
@ -139,7 +166,7 @@ export class SendCustomEvent extends GenericEditor {
<div className="mx_Dialog_content">
{ this.state.message }
</div>
{ this._buttons() }
{ this.buttons() }
</div>;
}
@ -155,35 +182,51 @@ export class SendCustomEvent extends GenericEditor {
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ showTglFlip && <div style={{float: "right"}}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isStateEvent} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Event" data-tg-on="State Event" htmlFor="isStateEvent" />
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isStateEvent}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Event"
data-tg-on="State Event"
htmlFor="isStateEvent"
/>
</div> }
</div>
</div>;
}
}
class SendAccountData extends GenericEditor {
static getLabel() { return _t('Send Account Data'); }
static propTypes = {
room: PropTypes.instanceOf(Room).isRequired,
isRoomAccountData: PropTypes.bool,
forceMode: PropTypes.bool,
inputs: PropTypes.object,
interface ISendAccountDataProps extends IGenericEditorProps {
room: Room;
isRoomAccountData: boolean;
forceMode: boolean;
inputs?: {
eventType?: string;
evContent?: string;
};
}
interface ISendAccountDataState extends IGenericEditorState {
isRoomAccountData: boolean;
eventType: string;
evContent: string;
}
class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountDataState> {
static getLabel() { return _t('Send Account Data'); }
static contextType = MatrixClientContext;
constructor(props) {
super(props);
this._send = this._send.bind(this);
const {eventType, evContent} = Object.assign({
eventType: '',
@ -198,7 +241,7 @@ class SendAccountData extends GenericEditor {
};
}
send(content) {
private doSend(content: object): Promise<void> {
const cli = this.context;
if (this.state.isRoomAccountData) {
return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content);
@ -206,7 +249,7 @@ class SendAccountData extends GenericEditor {
return cli.setAccountData(this.state.eventType, content);
}
async _send() {
protected send = async () => {
if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') });
return;
@ -215,7 +258,7 @@ class SendAccountData extends GenericEditor {
let message;
try {
const content = JSON.parse(this.state.evContent);
await this.send(content);
await this.doSend(content);
message = _t('Event sent!');
} catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
@ -229,7 +272,7 @@ class SendAccountData extends GenericEditor {
<div className="mx_Dialog_content">
{ this.state.message }
</div>
{ this._buttons() }
{ this.buttons() }
</div>;
}
@ -239,14 +282,23 @@ class SendAccountData extends GenericEditor {
<br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
{ !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} disabled={this.props.forceMode} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
disabled={this.props.forceMode}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Account Data"
data-tg-on="Room Data"
htmlFor="isRoomAccountData"
/>
</div> }
</div>
</div>;
@ -256,17 +308,22 @@ class SendAccountData extends GenericEditor {
const INITIAL_LOAD_TILES = 20;
const LOAD_TILES_STEP_SIZE = 50;
class FilteredList extends React.PureComponent {
static propTypes = {
children: PropTypes.any,
query: PropTypes.string,
onChange: PropTypes.func,
};
interface IFilteredListProps {
children: React.ReactElement[];
query: string;
onChange: (value: string) => void;
}
static filterChildren(children, query) {
interface IFilteredListState {
filteredChildren: React.ReactElement[];
truncateAt: number;
}
class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredListState> {
static filterChildren(children: React.ReactElement[], query: string): React.ReactElement[] {
if (!query) return children;
const lcQuery = query.toLowerCase();
return children.filter((child) => child.key.toLowerCase().includes(lcQuery));
return children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery));
}
constructor(props) {
@ -287,27 +344,27 @@ class FilteredList extends React.PureComponent {
});
}
showAll = () => {
private showAll = () => {
this.setState({
truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE,
});
};
createOverflowElement = (overflowCount: number, totalCount: number) => {
private createOverflowElement = (overflowCount: number, totalCount: number) => {
return <button className="mx_DevTools_RoomStateExplorer_button" onClick={this.showAll}>
{ _t("and %(count)s others...", { count: overflowCount }) }
</button>;
};
onQuery = (ev) => {
private onQuery = (ev: ChangeEvent<HTMLInputElement>) => {
if (this.props.onChange) this.props.onChange(ev.target.value);
};
getChildren = (start: number, end: number) => {
private getChildren = (start: number, end: number): React.ReactElement[] => {
return this.state.filteredChildren.slice(start, end);
};
getChildCount = (): number => {
private getChildCount = (): number => {
return this.state.filteredChildren.length;
};
@ -315,41 +372,44 @@ class FilteredList extends React.PureComponent {
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return <div>
<Field label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt}
createOverflowElement={this.createOverflowElement} />
getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt}
createOverflowElement={this.createOverflowElement} />
</div>;
}
}
class RoomStateExplorer extends React.PureComponent {
static getLabel() { return _t('Explore Room State'); }
interface IExplorerProps {
room: Room;
onBack: () => void;
}
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
};
interface IRoomStateExplorerState {
eventType?: string;
event?: MatrixEvent;
editing: boolean;
queryEventType: string;
queryStateKey: string;
}
class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateExplorerState> {
static getLabel() { return _t('Explore Room State'); }
static contextType = MatrixClientContext;
roomStateEvents: Map<string, Map<string, MatrixEvent>>;
private roomStateEvents: Map<string, Map<string, MatrixEvent>>;
constructor(props) {
super(props);
this.roomStateEvents = this.props.room.currentState.events;
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.onQueryStateKey = this.onQueryStateKey.bind(this);
this.state = {
eventType: null,
event: null,
@ -360,19 +420,19 @@ class RoomStateExplorer extends React.PureComponent {
};
}
browseEventType(eventType) {
private browseEventType(eventType: string) {
return () => {
this.setState({ eventType });
};
}
onViewSourceClick(event) {
private onViewSourceClick(event: MatrixEvent) {
return () => {
this.setState({ event });
};
}
onBack() {
private onBack = () => {
if (this.state.editing) {
this.setState({ editing: false });
} else if (this.state.event) {
@ -384,15 +444,15 @@ class RoomStateExplorer extends React.PureComponent {
}
}
editEv() {
private editEv = () => {
this.setState({ editing: true });
}
onQueryEventType(filterEventType) {
private onQueryEventType = (filterEventType: string) => {
this.setState({ queryEventType: filterEventType });
}
onQueryStateKey(filterStateKey) {
private onQueryStateKey = (filterStateKey: string) => {
this.setState({ queryStateKey: filterStateKey });
}
@ -464,24 +524,22 @@ class RoomStateExplorer extends React.PureComponent {
}
}
class AccountDataExplorer extends React.PureComponent {
static getLabel() { return _t('Explore Account Data'); }
interface IAccountDataExplorerState {
isRoomAccountData: boolean;
event?: MatrixEvent;
editing: boolean;
queryEventType: string;
[inputId: string]: boolean | string;
}
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
};
class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDataExplorerState> {
static getLabel() { return _t('Explore Account Data'); }
static contextType = MatrixClientContext;
constructor(props) {
super(props);
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this._onChange = this._onChange.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.state = {
isRoomAccountData: false,
event: null,
@ -491,20 +549,20 @@ class AccountDataExplorer extends React.PureComponent {
};
}
getData() {
private getData(): Record<string, MatrixEvent> {
if (this.state.isRoomAccountData) {
return this.props.room.accountData;
}
return this.context.store.accountData;
}
onViewSourceClick(event) {
private onViewSourceClick(event: MatrixEvent) {
return () => {
this.setState({ event });
};
}
onBack() {
private onBack = () => {
if (this.state.editing) {
this.setState({ editing: false });
} else if (this.state.event) {
@ -514,15 +572,15 @@ class AccountDataExplorer extends React.PureComponent {
}
}
_onChange(e) {
private onChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
}
editEv() {
private editEv = () => {
this.setState({ editing: true });
}
onQueryEventType(queryEventType) {
private onQueryEventType = (queryEventType: string) => {
this.setState({ queryEventType });
}
@ -572,30 +630,39 @@ class AccountDataExplorer extends React.PureComponent {
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} />
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
</div> }
<div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
type="checkbox"
checked={this.state.isRoomAccountData}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Account Data"
data-tg-on="Room Data"
htmlFor="isRoomAccountData"
/>
</div>
</div>
</div>;
}
}
class ServersInRoomList extends React.PureComponent {
interface IServersInRoomListState {
query: string;
}
class ServersInRoomList extends React.PureComponent<IExplorerProps, IServersInRoomListState> {
static getLabel() { return _t('View Servers in Room'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
};
static contextType = MatrixClientContext;
private servers: React.ReactElement[];
constructor(props) {
super(props);
const room = this.props.room;
const servers = new Set();
const servers = new Set<string>();
room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1]));
this.servers = Array.from(servers).map(s =>
<button key={s} className="mx_DevTools_ServersInRoomList_button">
@ -607,7 +674,7 @@ class ServersInRoomList extends React.PureComponent {
};
}
onQuery = (query) => {
private onQuery = (query: string) => {
this.setState({ query });
}
@ -634,7 +701,10 @@ const PHASE_MAP = {
[PHASE_CANCELLED]: "cancelled",
};
function VerificationRequest({txnId, request}) {
const VerificationRequestExplorer: React.FC<{
txnId: string;
request: VerificationRequest;
}> = ({txnId, request}) => {
const [, updateState] = useState();
const [timeout, setRequestTimeout] = useState(request.timeout);
@ -647,7 +717,7 @@ function VerificationRequest({txnId, request}) {
/* Note that request.timeout is a getter, so its value changes */
const id = setInterval(() => {
setRequestTimeout(request.timeout);
setRequestTimeout(request.timeout);
}, 500);
return () => { clearInterval(id); };
@ -671,7 +741,7 @@ function VerificationRequest({txnId, request}) {
</div>);
}
class VerificationExplorer extends React.Component {
class VerificationExplorer extends React.PureComponent<IExplorerProps> {
static getLabel() {
return _t("Verification Requests");
}
@ -679,7 +749,7 @@ class VerificationExplorer extends React.Component {
/* Ensure this.context is the cli */
static contextType = MatrixClientContext;
onNewRequest = () => {
private onNewRequest = () => {
this.forceUpdate();
}
@ -696,13 +766,13 @@ class VerificationExplorer extends React.Component {
render() {
const cli = this.context;
const room = this.props.room;
const inRoomChannel = cli._crypto._inRoomVerificationRequests;
const inRoomChannel = cli.crypto._inRoomVerificationRequests;
const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map();
return (<div>
<div className="mx_Dialog_content">
{Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) =>
<VerificationRequest txnId={txnId} request={request} key={txnId} />,
<VerificationRequestExplorer txnId={txnId} request={request} key={txnId} />,
)}
</div>
<div className="mx_Dialog_buttons">
@ -712,7 +782,12 @@ class VerificationExplorer extends React.Component {
}
}
class WidgetExplorer extends React.Component {
interface IWidgetExplorerState {
query: string;
editWidget?: IApp;
}
class WidgetExplorer extends React.Component<IExplorerProps, IWidgetExplorerState> {
static getLabel() {
return _t("Active Widgets");
}
@ -726,19 +801,19 @@ class WidgetExplorer extends React.Component {
};
}
onWidgetStoreUpdate = () => {
private onWidgetStoreUpdate = () => {
this.forceUpdate();
};
onQueryChange = (query) => {
private onQueryChange = (query: string) => {
this.setState({query});
};
onEditWidget = (widget) => {
private onEditWidget = (widget: IApp) => {
this.setState({editWidget: widget});
};
onBack = () => {
private onBack = () => {
const widgets = WidgetStore.instance.getApps(this.props.room.roomId);
if (this.state.editWidget && widgets.includes(this.state.editWidget)) {
this.setState({editWidget: null});
@ -761,8 +836,11 @@ class WidgetExplorer extends React.Component {
const editWidget = this.state.editWidget;
const widgets = WidgetStore.instance.getApps(room.roomId);
if (editWidget && widgets.includes(editWidget)) {
const allState = Array.from(Array.from(room.currentState.events.values()).map(e => e.values()))
.reduce((p, c) => {p.push(...c); return p;}, []);
const allState = Array.from(
Array.from(room.currentState.events.values()).map((e: Map<string, MatrixEvent>) => {
return e.values();
}),
).reduce((p, c) => { p.push(...c); return p; }, []);
const stateEv = allState.find(ev => ev.getId() === editWidget.eventId);
if (!stateEv) { // "should never happen"
return <div>
@ -803,7 +881,15 @@ class WidgetExplorer extends React.Component {
}
}
class SettingsExplorer extends React.Component {
interface ISettingsExplorerState {
query: string;
editSetting?: string;
viewSetting?: string;
explicitValues?: string;
explicitRoomValues?: string;
}
class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExplorerState> {
static getLabel() {
return _t("Settings Explorer");
}
@ -821,19 +907,19 @@ class SettingsExplorer extends React.Component {
};
}
onQueryChange = (ev) => {
private onQueryChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({query: ev.target.value});
};
onExplValuesEdit = (ev) => {
private onExplValuesEdit = (ev: ChangeEvent<HTMLTextAreaElement>) => {
this.setState({explicitValues: ev.target.value});
};
onExplRoomValuesEdit = (ev) => {
private onExplRoomValuesEdit = (ev: ChangeEvent<HTMLTextAreaElement>) => {
this.setState({explicitRoomValues: ev.target.value});
};
onBack = () => {
private onBack = () => {
if (this.state.editSetting) {
this.setState({editSetting: null});
} else if (this.state.viewSetting) {
@ -843,12 +929,12 @@ class SettingsExplorer extends React.Component {
}
};
onViewClick = (ev, settingId) => {
private onViewClick = (ev: MouseEvent, settingId: string) => {
ev.preventDefault();
this.setState({viewSetting: settingId});
};
onEditClick = (ev, settingId) => {
private onEditClick = (ev: MouseEvent, settingId: string) => {
ev.preventDefault();
this.setState({
editSetting: settingId,
@ -857,7 +943,7 @@ class SettingsExplorer extends React.Component {
});
};
onSaveClick = async () => {
private onSaveClick = async () => {
try {
const settingId = this.state.editSetting;
const parsedExplicit = JSON.parse(this.state.explicitValues);
@ -866,7 +952,7 @@ class SettingsExplorer extends React.Component {
console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`);
try {
const val = parsedExplicit[level];
await SettingsStore.setValue(settingId, null, level, val);
await SettingsStore.setValue(settingId, null, level as SettingLevel, val);
} catch (e) {
console.warn(e);
}
@ -876,7 +962,7 @@ class SettingsExplorer extends React.Component {
console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`);
try {
const val = parsedExplicitRoom[level];
await SettingsStore.setValue(settingId, roomId, level, val);
await SettingsStore.setValue(settingId, roomId, level as SettingLevel, val);
} catch (e) {
console.warn(e);
}
@ -893,7 +979,7 @@ class SettingsExplorer extends React.Component {
}
};
renderSettingValue(val) {
private renderSettingValue(val: any): string {
// Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us
const toStringTypes = ['boolean', 'number'];
if (toStringTypes.includes(typeof(val))) {
@ -903,7 +989,7 @@ class SettingsExplorer extends React.Component {
}
}
renderExplicitSettingValues(setting, roomId) {
private renderExplicitSettingValues(setting: string, roomId: string): string {
const vals = {};
for (const level of LEVEL_ORDER) {
try {
@ -918,7 +1004,7 @@ class SettingsExplorer extends React.Component {
return JSON.stringify(vals, null, 4);
}
renderCanEditLevel(roomId, level) {
private renderCanEditLevel(roomId: string, level: SettingLevel): React.ReactNode {
const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level);
const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable';
return <td className={className}><code>{canEdit.toString()}</code></td>;
@ -941,35 +1027,35 @@ class SettingsExplorer extends React.Component {
/>
<table>
<thead>
<tr>
<th>{_t("Setting ID")}</th>
<th>{_t("Value")}</th>
<th>{_t("Value in this room")}</th>
</tr>
<tr>
<th>{_t("Setting ID")}</th>
<th>{_t("Value")}</th>
<th>{_t("Value in this room")}</th>
</tr>
</thead>
<tbody>
{allSettings.map(i => (
<tr key={i}>
<td>
<a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{i}</code>
</a>
<a href="" onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit'
>
{allSettings.map(i => (
<tr key={i}>
<td>
<a href="" onClick={(e) => this.onViewClick(e, i)}>
<code>{i}</code>
</a>
<a href="" onClick={(e) => this.onEditClick(e, i)}
className='mx_DevTools_SettingsExplorer_edit'
>
</a>
</td>
<td>
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
</td>
<td>
<code>
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
</code>
</td>
</tr>
))}
</a>
</td>
<td>
<code>{this.renderSettingValue(SettingsStore.getValue(i))}</code>
</td>
<td>
<code>
{this.renderSettingValue(SettingsStore.getValue(i, room.roomId))}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
@ -998,11 +1084,11 @@ class SettingsExplorer extends React.Component {
<div>
<table>
<thead>
<tr>
<th>{_t("Level")}</th>
<th>{_t("Settable at global")}</th>
<th>{_t("Settable at room")}</th>
</tr>
<tr>
<th>{_t("Level")}</th>
<th>{_t("Settable at global")}</th>
<th>{_t("Settable at room")}</th>
</tr>
</thead>
<tbody>
{LEVEL_ORDER.map(lvl => (
@ -1054,27 +1140,37 @@ class SettingsExplorer extends React.Component {
<div>
{_t("Value:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting))}</code>
<code>{this.renderSettingValue(
SettingsStore.getValue(this.state.viewSetting),
)}</code>
</div>
<div>
{_t("Value in this room:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting, room.roomId))}</code>
<code>{this.renderSettingValue(
SettingsStore.getValue(this.state.viewSetting, room.roomId),
)}</code>
</div>
<div>
{_t("Values at explicit levels:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, null)}</code></pre>
<pre><code>{this.renderExplicitSettingValues(
this.state.viewSetting, null,
)}</code></pre>
</div>
<div>
{_t("Values at explicit levels in this room:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, room.roomId)}</code></pre>
<pre><code>{this.renderExplicitSettingValues(
this.state.viewSetting, room.roomId,
)}</code></pre>
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{_t("Edit Values")}</button>
<button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{
_t("Edit Values")
}</button>
<button onClick={this.onBack}>{_t("Back")}</button>
</div>
</div>
@ -1083,7 +1179,11 @@ class SettingsExplorer extends React.Component {
}
}
const Entries = [
type DevtoolsDialogEntry = React.JSXElementConstructor<any> & {
getLabel: () => string;
};
const Entries: DevtoolsDialogEntry[] = [
SendCustomEvent,
RoomStateExplorer,
SendAccountData,
@ -1094,43 +1194,36 @@ const Entries = [
SettingsExplorer,
];
@replaceableComponent("views.dialogs.DevtoolsDialog")
export default class DevtoolsDialog extends React.PureComponent {
static propTypes = {
roomId: PropTypes.string.isRequired,
onFinished: PropTypes.func.isRequired,
};
interface IProps {
roomId: string;
onFinished: (finished: boolean) => void;
}
interface IState {
mode?: DevtoolsDialogEntry;
}
@replaceableComponent("views.dialogs.DevtoolsDialog")
export default class DevtoolsDialog extends React.PureComponent<IProps, IState> {
constructor(props) {
super(props);
this.onBack = this.onBack.bind(this);
this.onCancel = this.onCancel.bind(this);
this.state = {
mode: null,
};
}
componentWillUnmount() {
this._unmounted = true;
}
_setMode(mode) {
private setMode(mode: DevtoolsDialogEntry) {
return () => {
this.setState({ mode });
};
}
onBack() {
if (this.prevMode) {
this.setState({ mode: this.prevMode });
this.prevMode = null;
} else {
this.setState({ mode: null });
}
private onBack = () => {
this.setState({ mode: null });
}
onCancel() {
private onCancel = () => {
this.props.onFinished(false);
}
@ -1157,7 +1250,7 @@ export default class DevtoolsDialog extends React.PureComponent {
<div className="mx_Dialog_content">
{ Entries.map((Entry) => {
const label = Entry.getLabel();
const onClick = this._setMode(Entry);
const onClick = this.setMode(Entry);
return <button className={classes} key={label} onClick={onClick}>{ label }</button>;
}) }
</div>

View file

@ -0,0 +1,247 @@
/*
Copyright 2021 Robin Townsend <robin@robin.town>
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, {useMemo, useState, useEffect} from "react";
import classnames from "classnames";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import {useSettingValue, useFeatureEnabled} from "../../../hooks/useSettings";
import {UIFeature} from "../../../settings/UIFeature";
import {Layout} from "../../../settings/Layout";
import {IDialogProps} from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import {avatarUrlForUser} from "../../../Avatar";
import EventTile from "../rooms/EventTile";
import SearchBox from "../../structures/SearchBox";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import {Alignment} from '../elements/Tooltip';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "../rooms/NotificationBadge";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
const AVATAR_SIZE = 30;
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
// The event to forward
event: MatrixEvent;
// We need a permalink creator for the source room to pass through to EventTile
// in case the event is a reply (even though the user can't get at the link)
permalinkCreator: RoomPermalinkCreator;
}
interface IEntryProps {
room: Room;
event: MatrixEvent;
matrixClient: MatrixClient;
onFinished(success: boolean): void;
}
enum SendState {
CanSend,
Sending,
Sent,
Failed,
}
const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinished }) => {
const [sendState, setSendState] = useState<SendState>(SendState.CanSend);
const jumpToRoom = () => {
dis.dispatch({
action: "view_room",
room_id: room.roomId,
});
onFinished(true);
};
const send = async () => {
setSendState(SendState.Sending);
try {
await cli.sendEvent(room.roomId, event.getType(), event.getContent());
setSendState(SendState.Sent);
} catch (e) {
setSendState(SendState.Failed);
}
};
let className;
let disabled = false;
let title;
let icon;
if (sendState === SendState.CanSend) {
className = "mx_ForwardList_canSend";
if (room.maySendMessage()) {
title = _t("Send");
} else {
disabled = true;
title = _t("You don't have permission to do this");
}
} else if (sendState === SendState.Sending) {
className = "mx_ForwardList_sending";
disabled = true;
title = _t("Sending");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
} else if (sendState === SendState.Sent) {
className = "mx_ForwardList_sent";
disabled = true;
title = _t("Sent");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title}></div>;
} else {
className = "mx_ForwardList_sendFailed";
disabled = true;
title = _t("Failed to send");
icon = <NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>;
}
return <div className="mx_ForwardList_entry">
<AccessibleTooltipButton
className="mx_ForwardList_roomButton"
onClick={jumpToRoom}
title={_t("Open link")}
yOffset={-20}
alignment={Alignment.Top}
>
<DecoratedRoomAvatar room={room} avatarSize={32} />
<span className="mx_ForwardList_entry_name">{ room.name }</span>
</AccessibleTooltipButton>
<AccessibleTooltipButton
kind={sendState === SendState.Failed ? "danger_outline" : "primary_outline"}
className={`mx_ForwardList_sendButton ${className}`}
onClick={send}
disabled={disabled}
title={title}
yOffset={-20}
alignment={Alignment.Top}
>
<div className="mx_ForwardList_sendLabel">{ _t("Send") }</div>
{ icon }
</AccessibleTooltipButton>
</div>;
};
const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCreator, onFinished }) => {
const userId = cli.getUserId();
const [profileInfo, setProfileInfo] = useState<any>({});
useEffect(() => {
cli.getProfileInfo(userId).then(info => setProfileInfo(info));
}, [cli, userId]);
// For the message preview we fake the sender as ourselves
const mockEvent = new MatrixEvent({
type: "m.room.message",
sender: userId,
content: event.getContent(),
unsigned: {
age: 97,
},
event_id: "$9999999999999999999999999999999999999999999",
room_id: event.getRoomId(),
});
mockEvent.sender = {
name: profileInfo.displayname || userId,
userId,
getAvatarUrl: (..._) => {
return avatarUrlForUser(
{ avatarUrl: profileInfo.avatar_url },
AVATAR_SIZE, AVATAR_SIZE, "crop",
);
},
getMxcAvatarUrl: () => profileInfo.avatar_url,
};
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const spacesEnabled = useFeatureEnabled("feature_spaces");
const flairEnabled = useFeatureEnabled(UIFeature.Flair);
const previewLayout = useSettingValue<Layout>("layout");
let rooms = useMemo(() => sortRooms(
cli.getVisibleRooms().filter(
room => room.getMyMembership() === "join" &&
!(spacesEnabled && room.isSpaceRoom()),
),
), [cli, spacesEnabled]);
if (lcQuery) {
rooms = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
}).match(lcQuery);
}
return <BaseDialog
title={_t("Forward message")}
className="mx_ForwardDialog"
contentId="mx_ForwardList"
onFinished={onFinished}
fixedWidth={false}
>
<h3>{ _t("Message preview") }</h3>
<div className={classnames("mx_ForwardDialog_preview", {
"mx_IRCLayout": previewLayout == Layout.IRC,
"mx_GroupLayout": previewLayout == Layout.Group,
})}>
<EventTile
mxEvent={mockEvent}
layout={previewLayout}
enableFlair={flairEnabled}
permalinkCreator={permalinkCreator}
as="div"
/>
</div>
<hr />
<div className="mx_ForwardList" id="mx_ForwardList">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search for rooms or people")}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ForwardList_content">
{ rooms.length > 0 ? (
<div className="mx_ForwardList_results">
{ rooms.map(room =>
<Entry
key={room.roomId}
room={room}
event={event}
matrixClient={cli}
onFinished={onFinished}
/>,
) }
</div>
) : <span className="mx_ForwardList_noResults">
{ _t("No results") }
</span> }
</AutoHideScrollbar>
</div>
</BaseDialog>;
};
export default ForwardDialog;

View file

@ -15,5 +15,5 @@ limitations under the License.
*/
export interface IDialogProps {
onFinished: (bool) => void;
onFinished(...args: any): void;
}

View file

@ -42,9 +42,12 @@ export default class IntegrationsDisabledDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_IntegrationsDisabledDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Integrations are disabled")}>
<BaseDialog
className='mx_IntegrationsDisabledDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Integrations are disabled")}
>
<div className='mx_IntegrationsDisabledDialog_content'>
<p>{_t("Enable 'Manage Integrations' in Settings to do this.")}</p>
</div>

View file

@ -37,9 +37,12 @@ export default class IntegrationsImpossibleDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_IntegrationsImpossibleDialog' hasCancel={false}
onFinished={this.props.onFinished}
title={_t("Integrations not allowed")}>
<BaseDialog
className='mx_IntegrationsImpossibleDialog'
hasCancel={false}
onFinished={this.props.onFinished}
title={_t("Integrations not allowed")}
>
<div className='mx_IntegrationsImpossibleDialog_content'>
<p>
{_t(

View file

@ -47,10 +47,19 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings';
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
interface IRecentUser {
userId: string,
user: RoomMember,
lastActive: number,
}
export const KIND_DM = "dm";
export const KIND_INVITE = "invite";
export const KIND_CALL_TRANSFER = "call_transfer";
@ -61,43 +70,41 @@ const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is c
// This is the interface that is expected by various components in this file. It is a bit
// awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses.
//
// XXX: We should use TypeScript interfaces instead of this weird "abstract" class.
class Member {
abstract class Member {
/**
* The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email).
*/
get name(): string { throw new Error("Member class not implemented"); }
public abstract get name(): string;
/**
* The ID of this Member. For users this should be their user ID. For 3PIDs this should
* be the 3PID address (email).
*/
get userId(): string { throw new Error("Member class not implemented"); }
public abstract get userId(): string;
/**
* Gets the MXC URL of this Member's avatar. For users this should be their profile's
* avatar MXC URL or null if none set. For 3PIDs this should always be null.
*/
getMxcAvatarUrl(): string { throw new Error("Member class not implemented"); }
public abstract getMxcAvatarUrl(): string;
}
class DirectoryMember extends Member {
_userId: string;
_displayName: string;
_avatarUrl: string;
private readonly _userId: string;
private readonly displayName: string;
private readonly avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
super();
this._userId = userDirResult.user_id;
this._displayName = userDirResult.display_name;
this._avatarUrl = userDirResult.avatar_url;
this.displayName = userDirResult.display_name;
this.avatarUrl = userDirResult.avatar_url;
}
// These next class members are for the Member interface
get name(): string {
return this._displayName || this._userId;
return this.displayName || this._userId;
}
get userId(): string {
@ -105,32 +112,32 @@ class DirectoryMember extends Member {
}
getMxcAvatarUrl(): string {
return this._avatarUrl;
return this.avatarUrl;
}
}
class ThreepidMember extends Member {
_id: string;
private readonly id: string;
constructor(id: string) {
super();
this._id = id;
this.id = id;
}
// This is a getter that would be falsey on all other implementations. Until we have
// better type support in the react-sdk we can use this trick to determine the kind
// of 3PID we're dealing with, if any.
get isEmail(): boolean {
return this._id.includes('@');
return this.id.includes('@');
}
// These next class members are for the Member interface
get name(): string {
return this._id;
return this.id;
}
get userId(): string {
return this._id;
return this.id;
}
getMxcAvatarUrl(): string {
@ -140,11 +147,11 @@ class ThreepidMember extends Member {
interface IDMUserTileProps {
member: RoomMember;
onRemove: (RoomMember) => any;
onRemove(member: RoomMember): void;
}
class DMUserTile extends React.PureComponent<IDMUserTileProps> {
_onRemove = (e) => {
private onRemove = (e) => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
@ -153,9 +160,6 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
};
render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const avatarSize = 20;
const avatar = this.props.member.isEmail
? <img
@ -177,7 +181,7 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
closeButton = (
<AccessibleButton
className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove}
onClick={this.onRemove}
>
<img src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')} width={8} height={8}
@ -201,13 +205,13 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
interface IDMRoomTileProps {
member: RoomMember;
lastActiveTs: number;
onToggle: (RoomMember) => any;
onToggle(member: RoomMember): void;
highlightWord: string;
isSelected: boolean;
}
class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
_onClick = (e) => {
private onClick = (e) => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
@ -215,7 +219,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
this.props.onToggle(this.props.member);
};
_highlightName(str: string) {
private highlightName(str: string) {
if (!this.props.highlightWord) return str;
// We convert things to lowercase for index searching, but pull substrings from
@ -252,8 +256,6 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
}
render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
let timestamp = null;
if (this.props.lastActiveTs) {
const humanTs = humanizeTime(this.props.lastActiveTs);
@ -291,13 +293,13 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
const caption = this.props.member.isEmail
? _t("Invite by email")
: this._highlightName(this.props.member.userId);
: this.highlightName(this.props.member.userId);
return (
<div className='mx_InviteDialog_roomTile' onClick={this._onClick}>
<div className='mx_InviteDialog_roomTile' onClick={this.onClick}>
{stackedAvatar}
<span className="mx_InviteDialog_roomTile_nameStack">
<div className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</div>
<div className='mx_InviteDialog_roomTile_name'>{this.highlightName(this.props.member.name)}</div>
<div className='mx_InviteDialog_roomTile_userId'>{caption}</div>
</span>
{timestamp}
@ -308,7 +310,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
interface IInviteDialogProps {
// Takes an array of user IDs/emails to invite.
onFinished: (toInvite?: string[]) => any;
onFinished: (toInvite?: string[]) => void;
// The kind of invite being performed. Assumed to be KIND_DM if
// not provided.
@ -349,8 +351,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
initialText: "",
};
_debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
_editorRef: any = null;
private debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
private editorRef = createRef<HTMLInputElement>();
private unmounted = false;
constructor(props) {
super(props);
@ -378,7 +381,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
filterText: this.props.initialText,
recents: InviteDialog.buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(alreadyInvited),
suggestions: this.buildSuggestions(alreadyInvited),
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [],
threepidResultsMixin: [],
@ -390,21 +393,23 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
busy: false,
errorText: null,
};
this._editorRef = createRef();
}
componentDidMount() {
if (this.props.initialText) {
this._updateSuggestions(this.props.initialText);
this.updateSuggestions(this.props.initialText);
}
}
componentWillUnmount() {
this.unmounted = true;
}
private onConsultFirstChange = (ev) => {
this.setState({consultFirst: ev.target.checked});
}
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] {
public static buildRecents(excludedTargetIds: Set<string>): IRecentUser[] {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
@ -467,7 +472,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return recents;
}
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
private buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
const maxConsideredMembers = 200;
const joinedRooms = MatrixClientPeg.get().getRooms()
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
@ -574,7 +579,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
members.sort((a, b) => {
if (a.score === b.score) {
if (a.numRooms === b.numRooms) {
return a.member.userId.localeCompare(b.member.userId);
return compare(a.member.userId, b.member.userId);
}
return b.numRooms - a.numRooms;
@ -585,7 +590,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return members.map(m => ({userId: m.member.userId, user: m.member}));
}
_shouldAbortAfterInviteError(result): boolean {
private shouldAbortAfterInviteError(result): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error');
if (failedUsers.length > 0) {
console.log("Failed to invite users: ", result);
@ -600,7 +605,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return false;
}
_convertFilter(): Member[] {
private convertFilter(): Member[] {
// Check to see if there's anything to convert first
if (!this.state.filterText || !this.state.filterText.includes('@')) return this.state.targets || [];
@ -617,10 +622,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return newTargets;
}
_startDm = async () => {
private startDm = async () => {
this.setState({busy: true});
const client = MatrixClientPeg.get();
const targets = this._convertFilter();
const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId);
// Check if there is already a DM with these people and reuse it if possible.
@ -694,11 +699,11 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
};
_inviteUsers = async () => {
private inviteUsers = async () => {
const startTime = CountlyAnalytics.getTimestamp();
this.setState({busy: true});
this._convertFilter();
const targets = this._convertFilter();
this.convertFilter();
const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId);
const cli = MatrixClientPeg.get();
@ -715,7 +720,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too
if (!this.shouldAbortAfterInviteError(result)) { // handles setting error message too
this.props.onFinished();
}
@ -749,9 +754,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
};
_transferCall = async () => {
this._convertFilter();
const targets = this._convertFilter();
private transferCall = async () => {
this.convertFilter();
const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId);
if (targetIds.length > 1) {
this.setState({
@ -790,26 +795,26 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
};
_onKeyDown = (e) => {
private onKeyDown = (e) => {
if (this.state.busy) return;
const value = e.target.value.trim();
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
// when the field is empty and the user hits backspace remove the right-most target
e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]);
this.removeMember(this.state.targets[this.state.targets.length - 1]);
} else if (value && e.key === Key.ENTER && !hasModifiers) {
// when the user hits enter with something in their field try to convert it
e.preventDefault();
this._convertFilter();
this.convertFilter();
} else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) {
// when the user hits space and their input looks like an e-mail/MXID then try to convert it
e.preventDefault();
this._convertFilter();
this.convertFilter();
}
};
_updateSuggestions = async (term) => {
private updateSuggestions = async (term) => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make
@ -918,30 +923,30 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
};
_updateFilter = (e) => {
private updateFilter = (e) => {
const term = e.target.value;
this.setState({filterText: term});
// Debounce server lookups to reduce spam. We don't clear the existing server
// results because they might still be vaguely accurate, likewise for races which
// could happen here.
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this._debounceTimer = setTimeout(() => {
this._updateSuggestions(term);
this.debounceTimer = setTimeout(() => {
this.updateSuggestions(term);
}, 150); // 150ms debounce (human reaction time + some)
};
_showMoreRecents = () => {
private showMoreRecents = () => {
this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN});
};
_showMoreSuggestions = () => {
private showMoreSuggestions = () => {
this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN});
};
_toggleMember = (member: Member) => {
private toggleMember = (member: Member) => {
if (!this.state.busy) {
let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation
@ -954,13 +959,13 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
this.setState({targets, filterText});
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
if (this.editorRef && this.editorRef.current) {
this.editorRef.current.focus();
}
}
};
_removeMember = (member: Member) => {
private removeMember = (member: Member) => {
const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member);
if (idx >= 0) {
@ -968,12 +973,12 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({targets});
}
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
if (this.editorRef && this.editorRef.current) {
this.editorRef.current.focus();
}
};
_onPaste = async (e) => {
private onPaste = async (e) => {
if (this.state.filterText) {
// if the user has already typed something, just let them
// paste normally.
@ -1027,6 +1032,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
failed.push(address);
}
}
if (this.unmounted) return;
if (failed.length > 0) {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
@ -1043,17 +1049,17 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({targets: [...this.state.targets, ...toAdd]});
};
_onClickInputArea = (e) => {
private onClickInputArea = (e) => {
// Stop the browser from highlighting text
e.preventDefault();
e.stopPropagation();
if (this._editorRef && this._editorRef.current) {
this._editorRef.current.focus();
if (this.editorRef && this.editorRef.current) {
this.editorRef.current.focus();
}
};
_onUseDefaultIdentityServerClick = (e) => {
private onUseDefaultIdentityServerClick = (e) => {
e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms.
@ -1062,21 +1068,21 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({canUseIdentityServer: true, tryingIdentityServer: false});
};
_onManageSettingsClick = (e) => {
private onManageSettingsClick = (e) => {
e.preventDefault();
dis.fire(Action.ViewUserSettings);
this.props.onFinished();
};
_onCommunityInviteClick = (e) => {
private onCommunityInviteClick = (e) => {
this.props.onFinished();
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
};
_renderSection(kind: "recents"|"suggestions") {
private renderSection(kind: "recents"|"suggestions") {
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this);
const showMoreFn = kind === 'recents' ? this.showMoreRecents.bind(this) : this.showMoreSuggestions.bind(this);
const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
let sectionSubname = null;
@ -1156,7 +1162,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
member={r.user}
lastActiveTs={lastActive(r)}
key={r.userId}
onToggle={this._toggleMember}
onToggle={this.toggleMember}
highlightWord={this.state.filterText}
isSelected={this.state.targets.some(t => t.userId === r.userId)}
/>
@ -1171,32 +1177,32 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
);
}
_renderEditor() {
private renderEditor() {
const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} />
<DMUserTile member={t} onRemove={!this.state.busy && this.removeMember} key={t.userId} />
));
const input = (
<input
type="text"
onKeyDown={this._onKeyDown}
onChange={this._updateFilter}
onKeyDown={this.onKeyDown}
onChange={this.updateFilter}
value={this.state.filterText}
ref={this._editorRef}
onPaste={this._onPaste}
ref={this.editorRef}
onPaste={this.onPaste}
autoFocus={true}
disabled={this.state.busy}
autoComplete="off"
/>
);
return (
<div className='mx_InviteDialog_editor' onClick={this._onClickInputArea}>
<div className='mx_InviteDialog_editor' onClick={this.onClickInputArea}>
{targets}
{input}
</div>
);
}
_renderIdentityServerWarning() {
private renderIdentityServerWarning() {
if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer ||
!SettingsStore.getValue(UIFeature.IdentityServer)
) {
@ -1214,8 +1220,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
},
{
default: sub => <a href="#" onClick={this._onUseDefaultIdentityServerClick}>{sub}</a>,
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>,
default: sub => <a href="#" onClick={this.onUseDefaultIdentityServerClick}>{sub}</a>,
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
},
)}</div>
);
@ -1225,7 +1231,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
"Use an identity server to invite by email. " +
"Manage in <settings>Settings</settings>.",
{}, {
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>,
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
},
)}</div>
);
@ -1298,7 +1304,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return (
<AccessibleButton
kind="link"
onClick={this._onCommunityInviteClick}
onClick={this.onCommunityInviteClick}
>{sub}</AccessibleButton>
);
},
@ -1309,10 +1315,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</React.Fragment>;
}
buttonText = _t("Go");
goButtonFn = this._startDm;
goButtonFn = this.startDm;
} else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = room?.isSpaceRoom();
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
title = isSpace
? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"),
@ -1348,7 +1354,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
});
buttonText = _t("Invite");
goButtonFn = this._inviteUsers;
goButtonFn = this.inviteUsers;
if (cli.isRoomEncrypted(this.props.roomId)) {
const room = cli.getRoom(this.props.roomId);
@ -1370,7 +1376,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} else if (this.props.kind === KIND_CALL_TRANSFER) {
title = _t("Transfer");
buttonText = _t("Transfer");
goButtonFn = this._transferCall;
goButtonFn = this.transferCall;
consultSection = <div>
<label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
@ -1393,7 +1399,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<div className='mx_InviteDialog_content'>
<p className='mx_InviteDialog_helpText'>{helpText}</p>
<div className='mx_InviteDialog_addressBar'>
{this._renderEditor()}
{this.renderEditor()}
<div className='mx_InviteDialog_buttonAndSpinner'>
<AccessibleButton
kind="primary"
@ -1407,11 +1413,11 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</div>
</div>
{keySharingWarning}
{this._renderIdentityServerWarning()}
{this.renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div>
<div className='mx_InviteDialog_userSections'>
{this._renderSection('recents')}
{this._renderSection('suggestions')}
{this.renderSection('recents')}
{this.renderSection('suggestions')}
</div>
{consultSection}
</div>

View file

@ -24,7 +24,7 @@ export default function KeySignatureUploadFailedDialog({
source,
continuation,
onFinished,
}) {
}) {
const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -84,10 +84,10 @@ export default function KeySignatureUploadFailedDialog({
} else {
body = (<div>
{success ?
<span>{_t("Upload completed")}</span> :
cancelled ?
<span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unable to upload")}</span>}
<span>{_t("Upload completed")}</span> :
cancelled ?
<span>{_t("Cancelled signature upload")}</span> :
<span>{_t("Unable to upload")}</span>}
<DialogButtons
primaryButton={_t("OK")}
hasCancel={false}

View file

@ -159,13 +159,17 @@ export default class MessageEditHistoryDialog extends React.PureComponent {
stickyBottom={false}
startAtBottom={false}
>
<ul className="mx_MessageEditHistoryDialog_edits mx_MessagePanel_alwaysShowTimestamps">{this._renderEdits()}</ul>
<ul className="mx_MessageEditHistoryDialog_edits">{this._renderEdits()}</ul>
</ScrollPanel>);
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_MessageEditHistoryDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Message edits")}>
<BaseDialog
className='mx_MessageEditHistoryDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Message edits")}
>
{content}
</BaseDialog>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,7 +16,7 @@ limitations under the License.
import * as React from 'react';
import BaseDialog from './BaseDialog';
import { _t } from '../../../languageHandler';
import { _t, getUserLanguage } from '../../../languageHandler';
import AccessibleButton from "../elements/AccessibleButton";
import {
ClientWidgetApi,
@ -39,6 +39,8 @@ import {OwnProfileStore} from "../../../stores/OwnProfileStore";
import { arrayFastClone } from "../../../utils/arrays";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {ELEMENT_CLIENT_ID} from "../../../identifiers";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
@ -129,6 +131,9 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
currentUserId: MatrixClientPeg.get().getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
clientId: ELEMENT_CLIENT_ID,
clientTheme: SettingsStore.getValue("theme"),
clientLanguage: getUserLanguage(),
});
const parsed = new URL(templated);

View file

@ -116,8 +116,12 @@ export default class RoomSettingsDialog extends React.Component {
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
return (
<BaseDialog className='mx_RoomSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Room Settings - %(roomName)s", {roomName})}>
<BaseDialog
className='mx_RoomSettingsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Room Settings - %(roomName)s", {roomName})}
>
<div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} />
</div>

View file

@ -217,6 +217,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
value={this.state.otherHomeserver}
validateOnChange={false}
validateOnFocus={false}
id="mx_homeserverInput"
/>
</StyledRadioButton>
<p>

View file

@ -98,7 +98,7 @@ export default class SessionRestoreErrorDialog extends React.Component {
"may be incompatible with this version. Close this window and return " +
"to the more recent version.",
{ brand },
) }</p>
) }</p>
<p>{ _t(
"Clearing your browser's storage may fix the problem, but will sign you " +

View file

@ -30,8 +30,8 @@ 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";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -73,9 +73,13 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
const promises = [];
if (avatarChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
if (newAvatar) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
}
if (nameChanged) {
@ -90,7 +94,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) {
@ -111,15 +115,17 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
avatarDisabled={!canSetAvatar}
avatarDisabled={busy || !canSetAvatar}
setAvatar={setNewAvatar}
name={name}
nameDisabled={!canSetName}
nameDisabled={busy || !canSetName}
setName={setName}
topic={topic}
topicDisabled={!canSetTopic}
topicDisabled={busy || !canSetTopic}
setTopic={setTopic}
/>

View file

@ -45,10 +45,12 @@ export default class StorageEvictedDialog extends React.Component {
let logRequest;
if (SdkConfig.get().bug_report_endpoint_url) {
logRequest = _t(
"To help us prevent this in future, please <a>send us logs</a>.", {},
{
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
});
"To help us prevent this in future, please <a>send us logs</a>.",
{},
{
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
},
);
}
return (

View file

@ -0,0 +1,73 @@
/*
Copyright 2019, 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.
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 { User } from "matrix-js-sdk/src/models/user";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon";
import AccessibleButton from "../elements/AccessibleButton";
import BaseDialog from "./BaseDialog";
import { IDialogProps } from "./IDialogProps";
import { IDevice } from "../right_panel/UserInfo";
interface IProps extends IDialogProps {
user: User;
device: IDevice;
}
const UntrustedDeviceDialog: React.FC<IProps> = ({device, user, onFinished}) => {
let askToVerifyText;
let newSessionText;
if (MatrixClientPeg.get().getUserId() === user.userId) {
newSessionText = _t("You signed in to a new session without verifying it:");
askToVerifyText = _t("Verify your other session using one of the options below.");
} else {
newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:",
{name: user.displayName, userId: user.userId});
askToVerifyText = _t("Ask this user to verify their session, or manually verify it below.");
}
return <BaseDialog
onFinished={onFinished}
className="mx_UntrustedDeviceDialog"
title={<>
<E2EIcon status="warning" size={24} hideTooltip={true} />
{ _t("Not Trusted")}
</>}
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>{newSessionText}</p>
<p>{device.getDisplayName()} ({device.deviceId})</p>
<p>{askToVerifyText}</p>
</div>
<div className='mx_Dialog_buttons'>
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("legacy")}>
{ _t("Manually Verify by Text") }
</AccessibleButton>
<AccessibleButton element="button" kind="secondary" onClick={() => onFinished("sas")}>
{ _t("Interactively verify by Emoji") }
</AccessibleButton>
<AccessibleButton kind="primary" onClick={() => onFinished(false)}>
{ _t("Done") }
</AccessibleButton>
</div>
</BaseDialog>;
};
export default UntrustedDeviceDialog;

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@ -16,20 +16,23 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import filesize from "filesize";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getBlobSafeMimeType } from '../../../utils/blobs';
interface IProps {
file: File;
currentIndex: number;
totalFiles?: number;
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
}
@replaceableComponent("views.dialogs.UploadConfirmDialog")
export default class UploadConfirmDialog extends React.Component {
static propTypes = {
file: PropTypes.object.isRequired,
currentIndex: PropTypes.number,
totalFiles: PropTypes.number,
onFinished: PropTypes.func.isRequired,
}
export default class UploadConfirmDialog extends React.Component<IProps> {
private objectUrl: string;
private mimeType: string;
static defaultProps = {
totalFiles: 1,
@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component {
constructor(props) {
super(props);
this._objectUrl = URL.createObjectURL(props.file);
// Create a fresh `Blob` for previewing (even though `File` already is
// one) so we can adjust the MIME type if needed.
this.mimeType = getBlobSafeMimeType(props.file.type);
const blob = new Blob([props.file], { type:
this.mimeType,
});
this.objectUrl = URL.createObjectURL(blob);
}
componentWillUnmount() {
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
}
_onCancelClick = () => {
private onCancelClick = () => {
this.props.onFinished(false);
}
_onUploadClick = () => {
private onUploadClick = () => {
this.props.onFinished(true);
}
_onUploadAllClick = () => {
private onUploadAllClick = () => {
this.props.onFinished(true, true);
}
@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component {
}
let preview;
if (this.props.file.type.startsWith('image/')) {
if (this.mimeType.startsWith('image/')) {
preview = <div className="mx_UploadConfirmDialog_previewOuter">
<div className="mx_UploadConfirmDialog_previewInner">
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} /></div>
<div>{this.props.file.name} ({filesize(this.props.file.size)})</div>
</div>
</div>;
@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component {
let uploadAllButton;
if (this.props.currentIndex + 1 < this.props.totalFiles) {
uploadAllButton = <button onClick={this._onUploadAllClick}>
uploadAllButton = <button onClick={this.onUploadAllClick}>
{_t("Upload all")}
</button>;
}
@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component {
return (
<BaseDialog className='mx_UploadConfirmDialog'
fixedWidth={false}
onFinished={this._onCancelClick}
onFinished={this.onCancelClick}
title={title}
contentId='mx_Dialog_content'
>
@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component {
<DialogButtons primaryButton={_t('Upload')}
hasCancel={false}
onPrimaryButtonClick={this._onUploadClick}
onPrimaryButtonClick={this.onUploadClick}
focus={true}
>
{uploadAllButton}

View file

@ -125,7 +125,10 @@ export default class UserSettingsDialog extends React.Component {
"mx_UserSettingsDialog_securityIcon",
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
if (SdkConfig.get()['showLabsSettings']) {
// Show the Labs tab if enabled or if there are any active betas
if (SdkConfig.get()['showLabsSettings']
|| SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k))
) {
tabs.push(new Tab(
USER_LABS_TAB,
_td("Labs"),
@ -155,8 +158,12 @@ export default class UserSettingsDialog extends React.Component {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Settings")}>
<BaseDialog
className='mx_UserSettingsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Settings")}
>
<div className='mx_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
</div>

View file

@ -52,11 +52,13 @@ export default class VerificationRequestDialog extends React.Component {
const title = request && request.isSelfVerification ?
_t("Verify other login") : _t("Verification Request");
return <BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
contentId="mx_Dialog_content"
title={title}
hasCancel={true}
>
return <BaseDialog
className="mx_InfoDialog"
onFinished={this.props.onFinished}
contentId="mx_Dialog_content"
title={title}
hasCancel={true}
>
<EncryptionPanel
layout="dialog"
verificationRequest={this.props.verificationRequest}

View file

@ -70,9 +70,12 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Allow this widget to verify your identity")}>
<BaseDialog
className='mx_WidgetOpenIDPermissionsDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Allow this widget to verify your identity")}
>
<div className='mx_WidgetOpenIDPermissionsDialog_content'>
<p>
{_t("The widget will verify your user ID, but won't be able to perform actions for you:")}

View file

@ -345,6 +345,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this.onPassPhraseNext}>
<input
type="password"
id="mx_passPhraseInput"
className="mx_AccessSecretStorageDialog_passPhraseInput"
onChange={this.onPassPhraseChange}
value={this.state.passPhrase}

View file

@ -40,10 +40,11 @@ export default class ConfirmDestroyCrossSigningDialog extends React.Component {
return (
<BaseDialog
className='mx_ConfirmDestroyCrossSigningDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Destroy cross-signing keys?")}>
className='mx_ConfirmDestroyCrossSigningDialog'
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Destroy cross-signing keys?")}
>
<div className='mx_ConfirmDestroyCrossSigningDialog_content'>
<p>
{_t(

View file

@ -373,21 +373,24 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
{_t(
"If you've forgotten your Security Phrase you can "+
"<button1>use your Security Key</button1> or " +
"<button2>set up new recovery options</button2>"
, {}, {
button1: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
button2: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
"<button2>set up new recovery options</button2>",
{},
{
button1: s => <AccessibleButton
className="mx_linkButton"
element="span"
onClick={this._onUseRecoveryKeyClick}
>
{s}
</AccessibleButton>,
button2: s => <AccessibleButton
className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
</div>;
} else {
title = _t("Enter Security Key");
@ -435,15 +438,17 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
</div>
{_t(
"If you've forgotten your Security Key you can "+
"<button>set up new recovery options</button>"
, {}, {
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
})}
"<button>set up new recovery options</button>",
{},
{
button: s => <AccessibleButton className="mx_linkButton"
element="span"
onClick={this._onResetRecoveryClick}
>
{s}
</AccessibleButton>,
},
)}
</div>;
}
@ -452,9 +457,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
onFinished={this.props.onFinished}
title={title}
>
<div className='mx_RestoreKeyBackupDialog_content'>
{content}
</div>
<div className='mx_RestoreKeyBackupDialog_content'>
{content}
</div>
</BaseDialog>
);
}

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,56 @@ 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";
import { compare } from "../../../utils/strings";
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
await MatrixClientPeg.get().publicRooms({
limit: 1,
server: value,
});
return {};
} catch (error) {
return { error };
}
},
rules: [
{
key: "required",
@ -57,34 +73,58 @@ const validServer = withValidation({
}, {
key: "available",
final: true,
test: async ({ value }) => {
try {
const opts = {
limit: 1,
server: value,
};
// check if we can successfully load this server's room directory
await MatrixClientPeg.get().publicRooms(opts);
return true;
} catch (e) {
return false;
}
},
test: async (_, { error }) => !error,
valid: () => _t("Looks good"),
invalid: () => _t("Can't find this server or its room list"),
invalid: ({ error }) => error.errcode === "M_FORBIDDEN"
? _t("You are not allowed to view this server's rooms list")
: _t("Can't find this server or its room list"),
},
],
});
/* 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) => {
@ -96,7 +136,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(() => {
@ -110,7 +150,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));
@ -134,15 +174,21 @@ 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: "",
});
}
protocolsList.forEach(({instances=[]}) => {
[...instances].sort((b, a) => {
return a.desc.localeCompare(b.desc);
return compare(a.desc, b.desc);
}).forEach(({desc, instance_id: instanceId}) => {
entries.push(
<MenuItemRadio
@ -170,7 +216,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>", {
@ -189,7 +234,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);
}
};
@ -221,7 +266,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."),
@ -282,9 +326,4 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
</div>;
};
NetworkDropdown.propTypes = {
onOptionChange: PropTypes.func.isRequired,
protocols: PropTypes.object,
};
export default NetworkDropdown;

View file

@ -19,7 +19,7 @@ import React from 'react';
import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton";
import Tooltip from './Tooltip';
import Tooltip, {Alignment} from './Tooltip';
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
@ -28,6 +28,7 @@ interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
tooltipClassName?: string;
forceHide?: boolean;
yOffset?: number;
alignment?: Alignment;
}
interface IState {
@ -66,14 +67,15 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {title, tooltip, children, tooltipClassName, forceHide, yOffset, ...props} = this.props;
const {title, tooltip, children, tooltipClassName, forceHide, yOffset, alignment, ...props} = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title}
yOffset={yOffset}
/> : <div />;
alignment={alignment}
/> : null;
return (
<AccessibleButton
{...props}

View file

@ -32,6 +32,7 @@ export default class ActionButton extends React.Component {
label: PropTypes.string.isRequired,
iconPath: PropTypes.string,
className: PropTypes.string,
children: PropTypes.node,
};
static defaultProps = {
@ -70,8 +71,8 @@ export default class ActionButton extends React.Component {
}
const icon = this.props.iconPath ?
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined;
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined;
const classNames = ["mx_RoleButton"];
if (this.props.className) {
@ -79,7 +80,8 @@ export default class ActionButton extends React.Component {
}
return (
<AccessibleButton className={classNames.join(" ")}
<AccessibleButton
className={classNames.join(" ")}
onClick={this._onClick}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
@ -87,6 +89,7 @@ export default class ActionButton extends React.Component {
>
{ icon }
{ tooltip }
{ this.props.children }
</AccessibleButton>
);
}

View file

@ -47,9 +47,14 @@ export default class AppTile extends React.Component {
// The key used for PersistedElement
this._persistKey = getPersistKey(this.props.app.id);
this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
try {
this._sgWidget = new StopGapWidget(this.props);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
} catch (e) {
console.log("Failed to construct widget", e);
this._sgWidget = null;
}
this.iframe = null; // ref to the iframe (callback style)
this.state = this._getNewState(props);
@ -97,7 +102,7 @@ export default class AppTile extends React.Component {
// Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop();
if (this._sgWidget) this._sgWidget.stop();
}
this.setState({ hasPermissionToLoad });
@ -109,7 +114,7 @@ export default class AppTile extends React.Component {
const childContentProtocol = u.protocol;
if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') {
console.warn("Refusing to load mixed-content app:",
parentContentProtocol, childContentProtocol, window.location, this.props.app.url);
parentContentProtocol, childContentProtocol, window.location, this.props.app.url);
return true;
}
return false;
@ -117,7 +122,7 @@ export default class AppTile extends React.Component {
componentDidMount() {
// Only fetch IM token on mount if we're showing and have permission to load
if (this.state.hasPermissionToLoad) {
if (this._sgWidget && this.state.hasPermissionToLoad) {
this._startWidget();
}
@ -146,10 +151,15 @@ export default class AppTile extends React.Component {
if (this._sgWidget) {
this._sgWidget.stop();
}
this._sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this._startWidget();
try {
this._sgWidget = new StopGapWidget(newProps);
this._sgWidget.on("preparing", this._onWidgetPrepared);
this._sgWidget.on("ready", this._onWidgetReady);
this._startWidget();
} catch (e) {
console.log("Failed to construct widget", e);
this._sgWidget = null;
}
}
_startWidget() {
@ -161,7 +171,7 @@ export default class AppTile extends React.Component {
_iframeRefChange = (ref) => {
this.iframe = ref;
if (ref) {
this._sgWidget.start(ref);
if (this._sgWidget) this._sgWidget.start(ref);
} else {
this._resetWidget(this.props);
}
@ -209,7 +219,7 @@ export default class AppTile extends React.Component {
// Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop({forceDestroy: true});
if (this._sgWidget) this._sgWidget.stop({forceDestroy: true});
}
_onWidgetPrepared = () => {
@ -340,7 +350,13 @@ export default class AppTile extends React.Component {
<Spinner message={_t("Loading...")} />
</div>
);
if (!this.state.hasPermissionToLoad) {
if (this._sgWidget === null) {
appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg={_t("Error loading Widget")} />
</div>
);
} else if (!this.state.hasPermissionToLoad) {
// only possible for room widgets, can assert this.props.room here
const isEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = (
@ -364,7 +380,7 @@ export default class AppTile extends React.Component {
if (this.isMixedContent()) {
appTileBody = (
<div className={appTileBodyClass} style={appTileBodyStyles}>
<AppWarning errorMsg="Error - Mixed content" />
<AppWarning errorMsg={_t("Error - Mixed content")} />
</div>
);
} else {
@ -417,6 +433,8 @@ export default class AppTile extends React.Component {
onFinished={this._closeContextMenu}
showUnpin={!this.props.userWidget}
userWidget={this.props.userWidget}
onEditClick={this.props.onEditClick}
onDeleteClick={this.props.onDeleteClick}
/>
);
}

View file

@ -65,12 +65,18 @@ export class EditableItem extends React.Component {
<span className="mx_EditableItem_promptText">
{_t("Are you sure?")}
</span>
<AccessibleButton onClick={this._onActuallyRemove} kind="primary_sm"
className="mx_EditableItem_confirmBtn">
<AccessibleButton
onClick={this._onActuallyRemove}
kind="primary_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("Yes")}
</AccessibleButton>
<AccessibleButton onClick={this._onDontRemove} kind="danger_sm"
className="mx_EditableItem_confirmBtn">
<AccessibleButton
onClick={this._onDontRemove}
kind="danger_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("No")}
</AccessibleButton>
</div>
@ -121,11 +127,15 @@ export default class EditableItemList extends React.Component {
_renderNewItemField() {
return (
<form onSubmit={this._onItemAdded} autoComplete="off"
noValidate={true} className="mx_EditableItemList_newItem">
<form
onSubmit={this._onItemAdded}
autoComplete="off"
noValidate={true}
className="mx_EditableItemList_newItem"
>
<Field label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
{_t("Add")}
</AccessibleButton>

View file

@ -221,13 +221,15 @@ export default class EditableText extends React.Component {
</div>;
} else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editableEl = <div ref={this._editable_div}
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur} />;
editableEl = <div
ref={this._editable_div}
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>;
}
return editableEl;

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;
@ -37,7 +38,7 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
effect = new Effect(options);
effectsRef.current[name] = effect;
} catch (err) {
console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err);
console.warn(`Unable to load effect module at '../../../effects/${name}.`, err);
}
}
return effect;
@ -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

@ -63,9 +63,9 @@ const EventListSummary: React.FC<IProps> = ({
// If we are only given few events then just pass them through
if (events.length < threshold) {
return (
<div className="mx_EventListSummary" data-scroll-tokens={eventIds}>
<li className="mx_EventListSummary" data-scroll-tokens={eventIds}>
{ children }
</div>
</li>
);
}

View file

@ -116,7 +116,7 @@ export default class Flair extends React.Component {
render() {
if (this.state.profiles.length === 0) {
return <span className="mx_Flair" />;
return null;
}
const avatars = this.state.profiles.map((profile, index) => {
return <FlairAvatar key={index} groupProfile={profile} />;

View file

@ -19,20 +19,20 @@ limitations under the License.
import React, { createRef } from 'react';
import { _t } from '../../../languageHandler';
import AccessibleTooltipButton from "./AccessibleTooltipButton";
import {Key} from "../../../Keyboard";
import { Key } from "../../../Keyboard";
import FocusLock from "react-focus-lock";
import MemberAvatar from "../avatars/MemberAvatar";
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import {aboveLeftOf, ContextMenu} from '../../structures/ContextMenu';
import { aboveLeftOf, ContextMenu } from '../../structures/ContextMenu';
import MessageTimestamp from "../messages/MessageTimestamp";
import SettingsStore from "../../../settings/SettingsStore";
import {formatFullDate} from "../../../DateUtils";
import { formatFullDate } from "../../../DateUtils";
import dis from '../../../dispatcher/dispatcher';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {normalizeWheelEvent} from "../../../utils/Mouse";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { normalizeWheelEvent } from "../../../utils/Mouse";
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
@ -95,8 +95,6 @@ export default class ImageView extends React.Component<IProps, IState> {
private initX = 0;
private initY = 0;
private lastX = 0;
private lastY = 0;
private previousX = 0;
private previousY = 0;
@ -105,23 +103,32 @@ export default class ImageView extends React.Component<IProps, IState> {
// needs to be passive in order to work with Chromium
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
// We want to recalculate zoom whenever the window's size changes
window.addEventListener("resize", this.calculateZoom);
window.addEventListener("resize", this.recalculateZoom);
// After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.calculateZoom);
// Try to precalculate the zoom from width and height props
this.calculateZoom();
this.image.current.addEventListener("load", this.recalculateZoom);
}
componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel);
window.removeEventListener("resize", this.recalculateZoom);
this.image.current.removeEventListener("load", this.recalculateZoom);
}
private calculateZoom = () => {
private recalculateZoom = () => {
this.setZoomAndRotation();
}
private setZoomAndRotation = (inputRotation?: number) => {
const image = this.image.current;
const imageWrapper = this.imageWrapper.current;
const width = this.props.width || image.naturalWidth;
const height = this.props.height || image.naturalHeight;
const rotation = inputRotation || this.state.rotation;
const imageIsNotFlipped = rotation % 180 === 0;
// If the image is rotated take it into account
const width = imageIsNotFlipped ? image.naturalWidth : image.naturalHeight;
const height = imageIsNotFlipped ? image.naturalHeight : image.naturalWidth;
const zoomX = imageWrapper.clientWidth / width;
const zoomY = imageWrapper.clientHeight / height;
@ -133,6 +140,7 @@ export default class ImageView extends React.Component<IProps, IState> {
zoom: 1,
minZoom: 1,
maxZoom: 1,
rotation: rotation,
});
return;
}
@ -141,10 +149,14 @@ export default class ImageView extends React.Component<IProps, IState> {
// image by default
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom});
// If zoom is smaller than minZoom don't go below that value
const zoom = (this.state.zoom <= this.state.minZoom) ? minZoom : this.state.zoom;
this.setState({
minZoom: minZoom,
maxZoom: 1,
rotation: rotation,
zoom: zoom,
});
}
@ -160,7 +172,7 @@ export default class ImageView extends React.Component<IProps, IState> {
return;
}
if (newZoom >= this.state.maxZoom) {
this.setState({zoom: this.state.maxZoom});
this.setState({ zoom: this.state.maxZoom });
return;
}
@ -173,7 +185,7 @@ export default class ImageView extends React.Component<IProps, IState> {
ev.stopPropagation();
ev.preventDefault();
const {deltaY} = normalizeWheelEvent(ev);
const { deltaY } = normalizeWheelEvent(ev);
this.zoom(-(deltaY * ZOOM_COEFFICIENT));
};
@ -195,14 +207,12 @@ export default class ImageView extends React.Component<IProps, IState> {
private onRotateCounterClockwiseClick = () => {
const cur = this.state.rotation;
const rotationDegrees = cur - 90;
this.setState({ rotation: rotationDegrees });
this.setZoomAndRotation(cur - 90);
};
private onRotateClockwiseClick = () => {
const cur = this.state.rotation;
const rotationDegrees = cur + 90;
this.setState({ rotation: rotationDegrees });
this.setZoomAndRotation(cur + 90);
};
private onDownloadClick = () => {
@ -210,6 +220,7 @@ export default class ImageView extends React.Component<IProps, IState> {
a.href = this.props.src;
a.download = this.props.name;
a.target = "_blank";
a.rel = "noreferrer noopener";
a.click();
};
@ -248,15 +259,15 @@ export default class ImageView extends React.Component<IProps, IState> {
// Zoom in if we are completely zoomed out
if (this.state.zoom === this.state.minZoom) {
this.setState({zoom: this.state.maxZoom});
this.setState({ zoom: this.state.maxZoom });
return;
}
this.setState({moving: true});
this.setState({ moving: true });
this.previousX = this.state.translationX;
this.previousY = this.state.translationY;
this.initX = ev.pageX - this.lastX;
this.initY = ev.pageY - this.lastY;
this.initX = ev.pageX - this.state.translationX;
this.initY = ev.pageY - this.state.translationY;
};
private onMoving = (ev: React.MouseEvent) => {
@ -265,11 +276,9 @@ export default class ImageView extends React.Component<IProps, IState> {
if (!this.state.moving) return;
this.lastX = ev.pageX - this.initX;
this.lastY = ev.pageY - this.initY;
this.setState({
translationX: this.lastX,
translationY: this.lastY,
translationX: ev.pageX - this.initX,
translationY: ev.pageY - this.initY,
});
};
@ -285,8 +294,10 @@ export default class ImageView extends React.Component<IProps, IState> {
translationX: 0,
translationY: 0,
});
this.initX = 0;
this.initY = 0;
}
this.setState({moving: false});
this.setState({ moving: false });
};
private renderContextMenu() {
@ -357,7 +368,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const senderName = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
const sender = (
<div className="mx_ImageView_info_sender">
{senderName}
{ senderName }
</div>
);
const messageTimestamp = (
@ -384,10 +395,10 @@ export default class ImageView extends React.Component<IProps, IState> {
info = (
<div className="mx_ImageView_info_wrapper">
{avatar}
{ avatar }
<div className="mx_ImageView_info">
{sender}
{messageTimestamp}
{ sender }
{ messageTimestamp }
</div>
</div>
);
@ -427,7 +438,7 @@ export default class ImageView extends React.Component<IProps, IState> {
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")}
onClick={ this.onZoomInClick }>
onClick={this.onZoomInClick}>
</AccessibleTooltipButton>
);
}
@ -443,37 +454,42 @@ export default class ImageView extends React.Component<IProps, IState> {
ref={this.focusLock}
>
<div className="mx_ImageView_panel">
{info}
{ info }
<div className="mx_ImageView_toolbar">
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_rotateCW"
title={_t("Rotate Right")}
onClick={this.onRotateClockwiseClick}>
</AccessibleTooltipButton>
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_rotateCCW"
title={_t("Rotate Left")}
onClick={ this.onRotateCounterClockwiseClick }>
</AccessibleTooltipButton>
{zoomOutButton}
{zoomInButton}
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_rotateCW"
title={_t("Rotate Right")}
onClick={this.onRotateClockwiseClick}>
</AccessibleTooltipButton>
{ zoomOutButton }
{ zoomInButton }
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_download"
title={_t("Download")}
onClick={ this.onDownloadClick }>
</AccessibleTooltipButton>
{contextMenuButton}
{ contextMenuButton }
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_close"
title={_t("Close")}
onClick={ this.props.onFinished }>
</AccessibleTooltipButton>
{this.renderContextMenu()}
{ this.renderContextMenu() }
</div>
</div>
<div
className="mx_ImageView_image_wrapper"
ref={this.imageWrapper}>
ref={this.imageWrapper}
onMouseDown={this.props.onFinished}
onMouseMove={this.onMoving}
onMouseUp={this.onEndMoving}
onMouseLeave={this.onEndMoving}
>
<img
src={this.props.src}
title={this.props.name}
@ -482,9 +498,6 @@ export default class ImageView extends React.Component<IProps, IState> {
className="mx_ImageView_image"
draggable={true}
onMouseDown={this.onStartMoving}
onMouseMove={this.onMoving}
onMouseUp={this.onEndMoving}
onMouseLeave={this.onEndMoving}
/>
</div>
</FocusLock>

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

@ -46,8 +46,12 @@ export default class LabelledToggleSwitch extends React.Component {
// This is a minimal version of a SettingsFlag
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
let secondPart = <ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
onChange={this.props.onChange} aria-label={this.props.label} />;
let secondPart = <ToggleSwitch
checked={this.props.value}
disabled={this.props.disabled}
onChange={this.props.onChange}
aria-label={this.props.label}
/>;
if (this.props.toggleInFront) {
const temp = firstPart;

View file

@ -58,13 +58,8 @@ export default class LanguageDropdown extends React.Component {
// If no value is given, we start with the first
// country selected, but our parent component
// doesn't know this, therefore we do this.
const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
if (language) {
this.props.onOptionChange(language);
} else {
const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser());
this.props.onOptionChange(language);
}
const language = languageHandler.getUserLanguage();
this.props.onOptionChange(language);
}
}

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

@ -225,19 +225,19 @@ class Pill extends React.Component {
}
break;
case Pill.TYPE_USER_MENTION: {
// If this user is not a member of this room, default to the empty member
const member = this.state.member;
if (member) {
userId = member.userId;
member.rawDisplayName = member.rawDisplayName || '';
linkText = member.rawDisplayName;
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
}
pillClass = 'mx_UserPill';
href = null;
onClick = this.onUserPillClicked;
// If this user is not a member of this room, default to the empty member
const member = this.state.member;
if (member) {
userId = member.userId;
member.rawDisplayName = member.rawDisplayName || '';
linkText = member.rawDisplayName;
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" />;
}
pillClass = 'mx_UserPill';
href = null;
onClick = this.onUserPillClicked;
}
}
break;
case Pill.TYPE_ROOM_MENTION: {

View file

@ -135,9 +135,13 @@ export default class PowerSelector extends React.Component {
if (this.state.custom) {
picker = (
<Field type="number"
label={label} max={this.props.maxValue}
onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} onChange={this.onCustomChange}
value={String(this.state.customValue)} disabled={this.props.disabled} />
label={label} max={this.props.maxValue}
onBlur={this.onCustomBlur}
onKeyDown={this.onCustomKeyDown}
onChange={this.onCustomChange}
value={String(this.state.customValue)}
disabled={this.props.disabled}
/>
);
} else {
// Each level must have a definition in this.state.levelRoleMap
@ -154,8 +158,9 @@ export default class PowerSelector extends React.Component {
picker = (
<Field element="select"
label={label} onChange={this.onSelectChange}
value={String(this.state.selectValue)} disabled={this.props.disabled}>
label={label} onChange={this.onSelectChange}
value={String(this.state.selectValue)} disabled={this.props.disabled}
>
{options}
</Field>
);

View file

@ -46,6 +46,8 @@ export default class ReplyThread extends React.Component {
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use.
layout: LayoutPropType,
// Whether to always show a timestamp
alwaysShowTimestamps: PropTypes.bool,
};
static contextType = MatrixClientContext;
@ -212,9 +214,9 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, alwaysShowTimestamps) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div className="mx_ReplyThread_wrapper_empty" />;
return null;
}
return <ReplyThread
parentEv={parentEv}
@ -222,6 +224,7 @@ export default class ReplyThread extends React.Component {
ref={ref}
permalinkCreator={permalinkCreator}
layout={layout}
alwaysShowTimestamps={alwaysShowTimestamps}
/>;
}
@ -269,36 +272,27 @@ export default class ReplyThread extends React.Component {
const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
if (this.unmounted) return;
if (ev) {
const loadedEv = await this.getNextEvent(ev);
this.setState({
events: [ev],
}, this.loadNextEvent);
loadedEv,
loading: false,
});
} else {
this.setState({err: true});
}
}
async loadNextEvent() {
if (this.unmounted) return;
const ev = this.state.events[0];
const inReplyToEventId = ReplyThread.getParentEventId(ev);
if (!inReplyToEventId) {
this.setState({
loading: false,
});
return;
}
const loadedEv = await this.getEvent(inReplyToEventId);
if (this.unmounted) return;
if (loadedEv) {
this.setState({loadedEv});
} else {
this.setState({err: true});
async getNextEvent(ev) {
try {
const inReplyToEventId = ReplyThread.getParentEventId(ev);
return await this.getEvent(inReplyToEventId);
} catch (e) {
return null;
}
}
@ -326,13 +320,18 @@ export default class ReplyThread extends React.Component {
this.initialize();
}
onQuoteClick() {
async onQuoteClick() {
const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
if (events.length > 0) {
loadedEv = await this.getNextEvent(events[0]);
}
this.setState({
loadedEv: null,
loadedEv,
events,
}, this.loadNextEvent);
});
dis.fire(Action.FocusComposer);
}
@ -390,6 +389,7 @@ export default class ReplyThread extends React.Component {
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
/>

View file

@ -46,17 +46,18 @@ export default class RoomAliasField extends React.PureComponent {
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
return (
<Field
label={_t("Room address")}
className="mx_RoomAliasField"
prefixComponent={poundSign}
postfixComponent={domain}
ref={ref => this._fieldRef = ref}
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength} />
<Field
label={_t("Room address")}
className="mx_RoomAliasField"
prefixComponent={poundSign}
postfixComponent={domain}
ref={ref => this._fieldRef = ref}
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength}
/>
);
}

View file

@ -112,7 +112,7 @@ interface IProps {
const MAX_PER_ROW = 6;
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
const providers = flow["org.matrix.msc2858.identity_providers"] || [];
const providers = flow.identity_providers || [];
if (providers.length < 2) {
return <div className="mx_SSOButtons">
<SSOButton

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

@ -59,13 +59,13 @@ class TintableSvg extends React.Component {
render() {
return (
<object className={"mx_TintableSvg " + (this.props.className ? this.props.className : "")}
type="image/svg+xml"
data={this.props.src}
width={this.props.width}
height={this.props.height}
onLoad={this.onLoad}
tabIndex="-1"
/>
type="image/svg+xml"
data={this.props.src}
width={this.props.width}
height={this.props.height}
onLoad={this.onLoad}
tabIndex="-1"
/>
);
}
}

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;
@ -69,7 +70,10 @@ export default class Tooltip extends React.Component<IProps> {
this.tooltipContainer = document.createElement("div");
this.tooltipContainer.className = "mx_Tooltip_wrapper";
document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this.renderTooltip, true);
window.addEventListener('scroll', this.renderTooltip, {
passive: true,
capture: true,
});
this.parent = ReactDOM.findDOMNode(this).parentNode as Element;
@ -84,7 +88,9 @@ export default class Tooltip extends React.Component<IProps> {
public componentWillUnmount() {
ReactDOM.unmountComponentAtNode(this.tooltipContainer);
document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this.renderTooltip, true);
window.removeEventListener('scroll', this.renderTooltip, {
capture: true,
});
}
private updatePosition(style: CSSProperties) {
@ -97,15 +103,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

@ -178,9 +178,15 @@ export default class GroupMemberList extends React.Component {
}
const inputBox = (
<input className="mx_GroupMemberList_query mx_textinput" id="mx_GroupMemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community members')} autoComplete="off" />
<input
className="mx_GroupMemberList_query mx_textinput"
id="mx_GroupMemberList_query"
type="text"
onChange={this.onSearchQueryChanged}
value={this.state.searchQuery}
placeholder={_t('Filter community members')}
autoComplete="off"
/>
);
const joined = this.state.members ? <div className="mx_MemberList_joined">

View file

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

View file

@ -141,9 +141,14 @@ export default class GroupRoomList extends React.Component {
);
}
const inputBox = (
<input className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community rooms')} autoComplete="off" />
<input
className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query"
type="text"
onChange={this.onSearchQueryChanged}
value={this.state.searchQuery}
placeholder={_t('Filter community rooms')}
autoComplete="off"
/>
);
const TruncatedList = sdk.getComponent("elements.TruncatedList");
@ -152,7 +157,7 @@ export default class GroupRoomList extends React.Component {
{ inviteButton }
<AutoHideScrollbar className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
createOverflowElement={this._createOverflowTile}>
{ this.makeGroupRoomTiles(this.state.searchQuery) }
</TruncatedList>
</AutoHideScrollbar>

View file

@ -125,9 +125,9 @@ export default class MImageBody extends React.Component {
_isGif() {
const content = this.props.mxEvent.getContent();
return (
content &&
content.info &&
content.info.mimetype === "image/gif"
content &&
content.info &&
content.info.mimetype === "image/gif"
);
}
@ -346,9 +346,9 @@ export default class MImageBody extends React.Component {
} else {
imageElement = (
<img style={{display: 'none'}} src={thumbUrl} ref={this._image}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
/>
);
}
@ -384,12 +384,12 @@ export default class MImageBody extends React.Component {
// mx_MImageBody_thumbnail resizes img to exactly container size
img = (
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image}
style={{ maxWidth: maxWidth + "px" }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
style={{ maxWidth: maxWidth + "px" }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
);
}
@ -467,9 +467,9 @@ export default class MImageBody extends React.Component {
const contentUrl = this._getContentUrl();
let thumbUrl;
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
thumbUrl = contentUrl;
thumbUrl = contentUrl;
} else {
thumbUrl = this._getThumbUrl();
thumbUrl = this._getThumbUrl();
}
const thumbnail = this._messageContent(contentUrl, thumbUrl, content);

View file

@ -82,9 +82,7 @@ export default class MKeyVerificationConclusion extends React.Component {
}
// User isn't actually verified
if (!MatrixClientPeg.get()
.checkUserTrust(request.otherUserId)
.isCrossSigningVerified()) {
if (!MatrixClientPeg.get().checkUserTrust(request.otherUserId).isCrossSigningVerified()) {
return false;
}

View file

@ -0,0 +1,110 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {Playback} from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
import {_t} from "../../../languageHandler";
import {mediaFromContent} from "../../../customisations/Media";
import {decryptFile} from "../../../utils/DecryptFile";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MVoiceMessageBody")
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {};
}
public async componentDidMount() {
let buffer: ArrayBuffer;
const content = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try {
const blob = await decryptFile(content.file);
buffer = await blob.arrayBuffer();
this.setState({decryptedBlob: blob});
} catch (e) {
this.setState({error: e});
console.warn("Unable to decrypt voice message", e);
return; // stop processing the audio file
}
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) {
this.setState({error: e});
console.warn("Unable to download voice message", e);
return; // stop processing the audio file
}
}
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
// We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer, waveform);
this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
}
public componentWillUnmount() {
this.state.playback?.destroy();
}
public render() {
if (this.state.error) {
// TODO: @@TR: Verify error state
return (
<span className="mx_MVoiceMessageBody">
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
{ _t("Error processing voice message") }
</span>
);
}
if (!this.state.playback) {
// TODO: @@TR: Verify loading/decrypting state
return (
<span className="mx_MVoiceMessageBody">
<InlineSpinner />
</span>
);
}
// At this point we should have a playable state
return (
<span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
)
}
}

View file

@ -0,0 +1,39 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import MAudioBody from "./MAudioBody";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
public render() {
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
if (isVoiceMessage && voiceMessagesEnabled) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;
}
}
}

View file

@ -31,6 +31,7 @@ import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessi
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {canCancel} from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -122,6 +123,10 @@ export default class MessageActionBar extends React.PureComponent {
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on("Event.status", this.onSent);
}
const client = MatrixClientPeg.get();
client.decryptEventIfNeeded(this.props.mxEvent);
if (this.props.mxEvent.isBeingDecrypted()) {
this.props.mxEvent.once("Event.decrypted", this.onDecrypted);
}

View file

@ -72,12 +72,8 @@ export default class MessageEvent extends React.Component {
'm.emote': sdk.getComponent('messages.TextualBody'),
'm.image': sdk.getComponent('messages.MImageBody'),
'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MAudioBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'),
// TODO: @@ TravisR: Use labs flag determination.
// MSC: https://github.com/matrix-org/matrix-doc/pull/2516
'org.matrix.msc2516.voice': sdk.getComponent('messages.MAudioBody'),
};
const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'),

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,35 +14,72 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React from "react";
import classNames from "classnames";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { isContentActionable } from '../../../utils/EventUtils';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { aboveLeftOf, ContextMenu, useContextMenu } from "../../structures/ContextMenu";
import ReactionPicker from "../emojipicker/ReactionPicker";
import ReactionsRowButton from "./ReactionsRowButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
// The maximum number of reactions to initially show on a message.
const MAX_ITEMS_WHEN_LIMITED = 8;
@replaceableComponent("views.messages.ReactionsRow")
export default class ReactionsRow extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: PropTypes.object,
const ReactButton = ({ mxEvent, reactions }: IProps) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
</ContextMenu>;
}
constructor(props) {
super(props);
return <React.Fragment>
<ContextMenuTooltipButton
className={classNames("mx_ReactionsRow_addReactionButton", {
mx_ReactionsRow_addReactionButton_active: menuDisplayed,
})}
title={_t("Add reaction")}
onClick={openMenu}
onContextMenu={e => {
e.preventDefault();
openMenu();
}}
isExpanded={menuDisplayed}
inputRef={button}
/>
if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange);
}
{ contextMenu }
</React.Fragment>;
};
interface IProps {
// The event we're displaying reactions for
mxEvent: MatrixEvent;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: Relations;
}
interface IState {
myReactions: MatrixEvent[];
showAll: boolean;
}
@replaceableComponent("views.messages.ReactionsRow")
export default class ReactionsRow extends React.PureComponent<IProps, IState> {
static contextType = MatrixClientContext;
constructor(props, context) {
super(props, context);
this.state = {
myReactions: this.getMyReactions(),
@ -50,7 +87,33 @@ export default class ReactionsRow extends React.PureComponent {
};
}
componentDidUpdate(prevProps) {
componentDidMount() {
const { mxEvent, reactions } = this.props;
if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) {
mxEvent.once("Event.decrypted", this.onDecrypted);
}
if (reactions) {
reactions.on("Relations.add", this.onReactionsChange);
reactions.on("Relations.remove", this.onReactionsChange);
reactions.on("Relations.redaction", this.onReactionsChange);
}
}
componentWillUnmount() {
const { mxEvent, reactions } = this.props;
mxEvent.off("Event.decrypted", this.onDecrypted);
if (reactions) {
reactions.off("Relations.add", this.onReactionsChange);
reactions.off("Relations.remove", this.onReactionsChange);
reactions.off("Relations.redaction", this.onReactionsChange);
}
}
componentDidUpdate(prevProps: IProps) {
if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange);
@ -59,24 +122,12 @@ export default class ReactionsRow extends React.PureComponent {
}
}
componentWillUnmount() {
if (this.props.reactions) {
this.props.reactions.removeListener(
"Relations.add",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
}
private onDecrypted = () => {
// Decryption changes whether the event is actionable
this.forceUpdate();
}
onReactionsChange = () => {
private onReactionsChange = () => {
// TODO: Call `onHeightChanged` as needed
this.setState({
myReactions: this.getMyReactions(),
@ -87,12 +138,12 @@ export default class ReactionsRow extends React.PureComponent {
this.forceUpdate();
}
getMyReactions() {
private getMyReactions() {
const reactions = this.props.reactions;
if (!reactions) {
return null;
}
const userId = MatrixClientPeg.get().getUserId();
const userId = this.context.getUserId();
const myReactions = reactions.getAnnotationsBySender()[userId];
if (!myReactions) {
return null;
@ -100,7 +151,7 @@ export default class ReactionsRow extends React.PureComponent {
return [...myReactions.values()];
}
onShowAllClick = () => {
private onShowAllClick = () => {
this.setState({
showAll: true,
});
@ -114,7 +165,6 @@ export default class ReactionsRow extends React.PureComponent {
return null;
}
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
let items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
const count = events.size;
if (!count) {
@ -136,6 +186,8 @@ export default class ReactionsRow extends React.PureComponent {
/>;
}).filter(item => !!item);
if (!items.length) return null;
// Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items.
// The "+ 1" ensure that the "show all" reveals something that takes up
// more space than the button itself.
@ -151,13 +203,22 @@ export default class ReactionsRow extends React.PureComponent {
</a>;
}
const cli = this.context;
let addReactionButton;
const room = cli.getRoom(mxEvent.getRoomId());
if (room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, cli.getUserId())) {
addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />;
}
return <div
className="mx_ReactionsRow"
role="toolbar"
aria-label={_t("Reactions")}
>
{items}
{showAllButton}
{ items }
{ showAllButton }
{ addReactionButton }
</div>;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,49 +14,54 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import React from "react";
import classNames from "classnames";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import dis from "../../../dispatcher/dispatcher";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps {
// The event we're displaying reactions for
mxEvent: MatrixEvent;
// The reaction content / key / emoji
content: string;
// The count of votes for this key
count: number;
// A Set of Matrix reaction events for this key
reactionEvents: Set<MatrixEvent>;
// A possible Matrix event if the current user has voted for this type
myReactionEvent?: MatrixEvent;
}
interface IState {
tooltipRendered: boolean;
tooltipVisible: boolean;
}
@replaceableComponent("views.messages.ReactionsRowButton")
export default class ReactionsRowButton extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
// The reaction content / key / emoji
content: PropTypes.string.isRequired,
// The count of votes for this key
count: PropTypes.number.isRequired,
// A Set of Martix reaction events for this key
reactionEvents: PropTypes.object.isRequired,
// A possible Matrix event if the current user has voted for this type
myReactionEvent: PropTypes.object,
}
export default class ReactionsRowButton extends React.PureComponent<IProps, IState> {
static contextType = MatrixClientContext;
constructor(props) {
super(props);
state = {
tooltipRendered: false,
tooltipVisible: false,
};
this.state = {
tooltipVisible: false,
};
}
onClick = (ev) => {
onClick = () => {
const { mxEvent, myReactionEvent, content } = this.props;
if (myReactionEvent) {
MatrixClientPeg.get().redactEvent(
this.context.redactEvent(
mxEvent.getRoomId(),
myReactionEvent.getId(),
);
} else {
MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", {
this.context.sendEvent(mxEvent.getRoomId(), "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": mxEvent.getId(),
@ -83,8 +88,6 @@ export default class ReactionsRowButton extends React.PureComponent {
}
render() {
const ReactionsRowButtonTooltip =
sdk.getComponent('messages.ReactionsRowButtonTooltip');
const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props;
const classes = classNames({
@ -102,7 +105,7 @@ export default class ReactionsRowButton extends React.PureComponent {
/>;
}
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
const room = this.context.getRoom(mxEvent.getRoomId());
let label;
if (room) {
const senders = [];
@ -129,12 +132,12 @@ export default class ReactionsRowButton extends React.PureComponent {
},
);
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const isPeeking = room.getMyMembership() !== "join";
return <AccessibleButton
className={classes}
aria-label={label}
onClick={this.onClick}
disabled={isPeeking}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,33 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { unicodeToShortcode } from '../../../HtmlUtils';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps {
// The event we're displaying reactions for
mxEvent: MatrixEvent;
// The reaction content / key / emoji
content: string;
// A Set of Matrix reaction events for this key
reactionEvents: Set<MatrixEvent>;
visible: boolean;
}
@replaceableComponent("views.messages.ReactionsRowButtonTooltip")
export default class ReactionsRowButtonTooltip extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
// The reaction content / key / emoji
content: PropTypes.string.isRequired,
// A Set of Martix reaction events for this key
reactionEvents: PropTypes.object.isRequired,
visible: PropTypes.bool.isRequired,
}
export default class ReactionsRowButtonTooltip extends React.PureComponent<IProps> {
static contextType = MatrixClientContext;
render() {
const Tooltip = sdk.getComponent('elements.Tooltip');
const { content, reactionEvents, mxEvent, visible } = this.props;
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
const room = this.context.getRoom(mxEvent.getRoomId());
let tooltipLabel;
if (room) {
const senders = [];

View file

@ -31,21 +31,23 @@ export default class SenderProfile extends React.Component {
static contextType = MatrixClientContext;
state = {
userGroups: null,
relatedGroups: [],
};
constructor(props) {
super(props);
const senderId = this.props.mxEvent.getSender();
this.state = {
userGroups: FlairStore.cachedPublicisedGroups(senderId) || [],
relatedGroups: [],
};
}
componentDidMount() {
this.unmounted = false;
this._updateRelatedGroups();
FlairStore.getPublicisedGroupsCached(
this.context, this.props.mxEvent.getSender(),
).then((userGroups) => {
if (this.unmounted) return;
this.setState({userGroups});
});
if (this.state.userGroups.length === 0) {
this.getPublicisedGroups();
}
this.context.on('RoomState.events', this.onRoomStateEvents);
}
@ -55,6 +57,15 @@ export default class SenderProfile extends React.Component {
this.context.removeListener('RoomState.events', this.onRoomStateEvents);
}
async getPublicisedGroups() {
if (!this.unmounted) {
const userGroups = await FlairStore.getPublicisedGroupsCached(
this.context, this.props.mxEvent.getSender(),
);
this.setState({userGroups});
}
}
onRoomStateEvents = event => {
if (event.getType() === 'm.room.related_groups' &&
event.getRoomId() === this.props.mxEvent.getRoomId()
@ -93,10 +104,10 @@ export default class SenderProfile extends React.Component {
const {msgtype} = mxEvent.getContent();
if (msgtype === 'm.emote') {
return <span />; // emote message must include the name so don't duplicate it
return null; // emote message must include the name so don't duplicate it
}
let flair = <div />;
let flair = null;
if (this.props.enableFlair) {
const displayedGroups = this._getDisplayedGroups(
this.state.userGroups, this.state.relatedGroups,
@ -110,19 +121,12 @@ export default class SenderProfile extends React.Component {
const nameElem = name || '';
// Name + flair
const nameFlair = <span>
<span className={`mx_SenderProfile_name ${colorClass}`}>
{ nameElem }
</span>
{ flair }
</span>;
return (
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}>
<div className="mx_SenderProfile_hover">
{ nameFlair }
</div>
<div className="mx_SenderProfile mx_SenderProfile_hover" dir="auto" onClick={this.props.onClick}>
<span className={`mx_SenderProfile_name ${colorClass}`}>
{ nameElem }
</span>
{ flair }
</div>
);
}

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");
@ -277,15 +278,15 @@ export default class TextualBody extends React.Component {
// pass only the first child which is the event tile otherwise this recurses on edited events
let links = this.findLinks([this._content.current]);
if (links.length) {
// de-dup the links (but preserve ordering)
const seen = new Set();
links = links.filter((link) => {
if (seen.has(link)) return false;
seen.add(link);
return true;
});
// de-duplicate the links after stripping hashes as they don't affect the preview
// using a set here maintains the order
links = Array.from(new Set(links.map(link => {
const url = new URL(link);
url.hash = "";
return url.toString();
})));
this.setState({ links: links });
this.setState({ links });
// lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) {
@ -521,11 +522,12 @@ export default class TextualBody extends React.Component {
const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget
key={link}
link={link}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged} />;
key={link}
link={link}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged}
/>;
});
}

View file

@ -18,6 +18,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@replaceableComponent("views.messages.ViewSourceEvent")
export default class ViewSourceEvent extends React.PureComponent {
@ -36,6 +37,10 @@ export default class ViewSourceEvent extends React.PureComponent {
componentDidMount() {
const {mxEvent} = this.props;
const client = MatrixClientPeg.get();
client.decryptEventIfNeeded(mxEvent);
if (mxEvent.isBeingDecrypted()) {
mxEvent.once("Event.decrypted", () => this.forceUpdate());
}

Some files were not shown because too many files have changed in this diff Show more