Merge remote-tracking branch 'upstream/develop' into fix/end-of-line-emoji

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-09-11 10:21:56 +02:00
commit fd022310b3
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
1196 changed files with 72417 additions and 34899 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,71 @@
/*
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, { HTMLAttributes, WheelEvent } from "react";
interface IProps extends Omit<HTMLAttributes<HTMLDivElement>, "onScroll"> {
className?: string;
onScroll?: (event: Event) => void;
onWheel?: (event: WheelEvent) => 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() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props;
return (<div
{...otherProps}
ref={this.containerRef}
style={style}
className={["mx_AutoHideScrollbar", className].join(" ")}
onWheel={onWheel}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex={tabIndex ?? -1}
>
{ children }
</div>);
}
}

View file

@ -0,0 +1,44 @@
/*
Copyright 2021 New Vector Ltd
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, { CSSProperties } from "react";
interface IProps {
backgroundImage?: string;
blurMultiplier?: number;
}
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
if (!backgroundImage) return null;
const styles: CSSProperties = {};
if (blurMultiplier) {
const rootStyle = getComputedStyle(document.documentElement);
const blurValue = rootStyle.getPropertyValue('--lp-background-blur');
const pixelsValue = blurValue.replace('px', '');
const parsed = parseInt(pixelsValue, 10);
if (!isNaN(parsed)) {
styles.filter = `blur(${parsed * blurMultiplier}px)`;
}
}
return <div className="mx_BackdropPanel">
<img
style={styles}
className="mx_BackdropPanel--image"
src={backgroundImage} />
</div>;
};
export default BackdropPanel;

View file

@ -0,0 +1,186 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { EventEmitter } from 'events';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
export enum CallEventGrouperEvent {
StateChanged = "state_changed",
SilencedChanged = "silenced_changed",
LengthChanged = "length_changed",
}
const CONNECTING_STATES = [
CallState.Connecting,
CallState.WaitLocalMedia,
CallState.CreateOffer,
CallState.CreateAnswer,
];
const SUPPORTED_STATES = [
CallState.Connected,
CallState.Ringing,
];
export enum CustomCallState {
Missed = "missed",
}
export default class CallEventGrouper extends EventEmitter {
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
private call: MatrixCall;
public state: CallState | CustomCallState;
constructor() {
super();
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
}
private get invite(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallInvite);
}
private get hangup(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallHangup);
}
private get reject(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallReject);
}
private get selectAnswer(): MatrixEvent {
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
}
public get isVoice(): boolean {
const invite = this.invite;
if (!invite) return;
// FIXME: Find a better way to determine this from the event?
if (invite.getContent()?.offer?.sdp?.indexOf('m=video') !== -1) return false;
return true;
}
public get hangupReason(): string | null {
return this.hangup?.getContent()?.reason;
}
public get rejectParty(): string {
return this.reject?.getSender();
}
public get gotRejected(): boolean {
return Boolean(this.reject);
}
public get duration(): Date {
if (!this.hangup || !this.selectAnswer) return;
return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
}
/**
* Returns true if there are only events from the other side - we missed the call
*/
private get callWasMissed(): boolean {
return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId());
}
private get callId(): string | undefined {
return [...this.events][0]?.getContent()?.call_id;
}
private get roomId(): string | undefined {
return [...this.events][0]?.getRoomId();
}
private onSilencedCallsChanged = () => {
const newState = CallHandler.sharedInstance().isCallSilenced(this.callId);
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
};
private onLengthChanged = (length: number): void => {
this.emit(CallEventGrouperEvent.LengthChanged, length);
};
public answerCall = () => {
defaultDispatcher.dispatch({
action: 'answer',
room_id: this.roomId,
});
};
public rejectCall = () => {
defaultDispatcher.dispatch({
action: 'reject',
room_id: this.roomId,
});
};
public callBack = () => {
defaultDispatcher.dispatch({
action: 'place_call',
type: this.isVoice ? CallType.Voice : CallType.Video,
room_id: this.roomId,
});
};
public toggleSilenced = () => {
const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId);
silenced ?
CallHandler.sharedInstance().unSilenceCall(this.callId) :
CallHandler.sharedInstance().silenceCall(this.callId);
};
private setCallListeners() {
if (!this.call) return;
this.call.addListener(CallEvent.State, this.setState);
this.call.addListener(CallEvent.LengthChanged, this.onLengthChanged);
}
private setState = () => {
if (CONNECTING_STATES.includes(this.call?.state)) {
this.state = CallState.Connecting;
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
this.state = this.call.state;
} else {
if (this.callWasMissed) this.state = CustomCallState.Missed;
else if (this.reject) this.state = CallState.Ended;
else if (this.hangup) this.state = CallState.Ended;
else if (this.invite && this.call) this.state = CallState.Connecting;
}
this.emit(CallEventGrouperEvent.StateChanged, this.state);
};
private setCall = () => {
if (this.call) return;
this.call = CallHandler.sharedInstance().getCallById(this.callId);
this.setCallListeners();
this.setState();
};
public add(event: MatrixEvent) {
this.events.add(event);
this.setCall();
}
}

View file

@ -16,13 +16,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {CSSProperties, RefObject, useRef, useState} from "react";
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import {Key} from "../../Keyboard";
import {Writeable} from "../../@types/common";
import {replaceableComponent} from "../../utils/replaceableComponent";
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
@ -79,6 +80,10 @@ export interface IProps extends IPosition {
managed?: boolean;
wrapperClassName?: string;
// If true, this context menu will be mounted as a child to the parent container. Otherwise
// it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean;
// Function to be called on menu close
onFinished();
// on resize callback
@ -222,10 +227,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 +265,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
if (handled) {
// consume all other keys in context menu
ev.stopPropagation();
ev.preventDefault();
}
};
@ -369,7 +375,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
return (
<div
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
style={{...position, ...wrapperStyle}}
style={{ ...position, ...wrapperStyle }}
onKeyDown={this.onKeyDown}
onContextMenu={this.onContextMenuPreventBubbling}
>
@ -388,7 +394,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
}
render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
if (this.props.mountAsChild) {
// Render as a child of the current parent
return this.renderMenu();
} else {
// Render as a child of a container at the root of the DOM
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
}
@ -397,7 +409,7 @@ export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return {left, top, chevronOffset};
return { left, top, chevronOffset };
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
@ -409,12 +421,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 +441,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 +462,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;
};
@ -459,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
const open = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(true);
};
const close = () => {
const close = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(false);
};
@ -496,15 +512,15 @@ export function createMenu(ElementClass, props) {
ReactDOM.render(menu, getOrCreateContainer());
return {close: onFinished};
return { close: onFinished };
}
// re-export the semantic helper components for simplicity
export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
export {ContextMenuTooltipButton} from "../../accessibility/context_menu/ContextMenuTooltipButton";
export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
export {MenuItem} from "../../accessibility/context_menu/MenuItem";
export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";
export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton";
export { MenuGroup } from "../../accessibility/context_menu/MenuGroup";
export { MenuItem } from "../../accessibility/context_menu/MenuItem";
export { MenuItemCheckbox } from "../../accessibility/context_menu/MenuItemCheckbox";
export { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
export { StyledMenuItemCheckbox } from "../../accessibility/context_menu/StyledMenuItemCheckbox";
export { StyledMenuItemRadio } from "../../accessibility/context_menu/StyledMenuItemRadio";

View file

@ -21,7 +21,7 @@ import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import classNames from 'classnames';
import * as FormattingUtils from '../../utils/FormattingUtils';
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.CustomRoomTagPanel")
class CustomRoomTagPanel extends React.Component {
@ -34,7 +34,7 @@ class CustomRoomTagPanel extends React.Component {
componentDidMount() {
this._tagStoreToken = CustomRoomTagStore.addListener(() => {
this.setState({tags: CustomRoomTagStore.getSortedTags()});
this.setState({ tags: CustomRoomTagStore.getSortedTags() });
});
}
@ -56,7 +56,7 @@ class CustomRoomTagPanel extends React.Component {
return (<div className={classes}>
<div className="mx_CustomRoomTagPanel_divider" />
<AutoHideScrollbar className="mx_CustomRoomTagPanel_scroller">
{tags}
{ tags }
</AutoHideScrollbar>
</div>);
}
@ -64,7 +64,7 @@ class CustomRoomTagPanel extends React.Component {
class CustomRoomTagTile extends React.Component {
onClick = () => {
dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name});
dis.dispatch({ action: 'select_custom_room_tag', tag: this.props.tag.name });
};
render() {
@ -84,7 +84,7 @@ class CustomRoomTagTile extends React.Component {
"mx_TagTile_badge": true,
"mx_TagTile_badgeHighlight": badgeNotifState.hasMentions,
});
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badgeNotifState.count)}</div>);
badgeElement = (<div className={badgeClasses}>{ FormattingUtils.formatCount(badgeNotifState.count) }</div>);
}
return (

View file

@ -22,7 +22,7 @@ import request from 'browser-request';
import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html';
import dis from '../../dispatcher/dispatcher';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import classnames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
@ -120,16 +120,15 @@ export default class EmbeddedPage extends React.PureComponent {
const content = <div className={`${className}_body`}
dangerouslySetInnerHTML={{ __html: this.state.page }}
>
</div>;
/>;
if (this.props.scrollbar) {
return <AutoHideScrollbar className={classes}>
{content}
{ content }
</AutoHideScrollbar>;
} else {
return <div className={classes}>
{content}
{ content }
</div>;
}
}

View file

@ -16,40 +16,59 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {Filter} from 'matrix-js-sdk/src/filter';
import * as sdk from '../../index';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import { Filter } from 'matrix-js-sdk/src/filter';
import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from 'matrix-js-sdk/src/models/room';
import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import EventIndexPeg from "../../indexing/EventIndexPeg";
import { _t } from '../../languageHandler';
import BaseCard from "../views/right_panel/BaseCard";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import DesktopBuildsNotice, {WarningKind} from "../views/elements/DesktopBuildsNotice";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuildsNotice";
import { replaceableComponent } from "../../utils/replaceableComponent";
import ResizeNotifier from '../../utils/ResizeNotifier';
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout";
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
}
interface IState {
timelineSet: EventTimelineSet;
}
/*
* Component which shows the filtered file using a TimelinePanel
*/
@replaceableComponent("structures.FilePanel")
class FilePanel extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
class FilePanel extends React.Component<IProps, IState> {
// This is used to track if a decrypted event was a live event and should be
// added to the timeline.
decryptingEvents = new Set();
private decryptingEvents = new Set<string>();
public noRoom: boolean;
state = {
timelineSet: null,
};
onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: true, removed: true, data: any): void => {
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 {
@ -57,7 +76,7 @@ class FilePanel extends React.Component {
}
};
onEventDecrypted = (ev, err) => {
private onEventDecrypted = (ev: MatrixEvent, err?: any): void => {
if (ev.getRoomId() !== this.props.roomId) return;
const eventId = ev.getId();
@ -67,7 +86,7 @@ class FilePanel extends React.Component {
this.addEncryptedLiveEvent(ev);
};
addEncryptedLiveEvent(ev, toStartOfTimeline) {
public addEncryptedLiveEvent(ev: MatrixEvent): void {
if (!this.state.timelineSet) return;
const timeline = this.state.timelineSet.getLiveTimeline();
@ -81,7 +100,7 @@ class FilePanel extends React.Component {
}
}
async componentDidMount() {
public async componentDidMount(): Promise<void> {
const client = MatrixClientPeg.get();
await this.updateTimelineSet(this.props.roomId);
@ -102,7 +121,7 @@ class FilePanel extends React.Component {
}
}
componentWillUnmount() {
public componentWillUnmount(): void {
const client = MatrixClientPeg.get();
if (client === null) return;
@ -114,7 +133,7 @@ class FilePanel extends React.Component {
}
}
async fetchFileEventsServer(room) {
public async fetchFileEventsServer(room: Room): Promise<EventTimelineSet> {
const client = MatrixClientPeg.get();
const filter = new Filter(client.credentials.userId);
@ -138,7 +157,11 @@ class FilePanel extends React.Component {
return timelineSet;
}
onPaginationRequest = (timelineWindow, direction, limit) => {
private onPaginationRequest = (
timelineWindow: TimelineWindow,
direction: Direction,
limit: number,
): Promise<boolean> => {
const client = MatrixClientPeg.get();
const eventIndex = EventIndexPeg.get();
const roomId = this.props.roomId;
@ -156,7 +179,7 @@ class FilePanel extends React.Component {
}
};
async updateTimelineSet(roomId: string) {
public async updateTimelineSet(roomId: string): Promise<void> {
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
const eventIndex = EventIndexPeg.get();
@ -192,7 +215,7 @@ class FilePanel extends React.Component {
}
}
render() {
public render() {
if (MatrixClientPeg.get().isGuest()) {
return <BaseCard
className="mx_FilePanel mx_RoomView_messageListWrapper"
@ -200,10 +223,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) {
@ -217,12 +240,10 @@ class FilePanel extends React.Component {
}
// 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_FilePanel_empty">
<h2>{_t('No files visible in this room')}</h2>
<p>{_t('Attach files from chat or just drag and drop them anywhere in a room.')}</p>
<h2>{ _t('No files visible in this room') }</h2>
<p>{ _t('Attach files from chat or just drag and drop them anywhere in a room.') }</p>
</div>);
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
@ -242,11 +263,12 @@ class FilePanel extends React.Component {
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
showUrlPreview={false}
onPaginationRequest={this.onPaginationRequest}
tileShape="file_grid"
tileShape={TileShape.FileGrid}
resizeNotifier={this.props.resizeNotifier}
empty={emptyState}
layout={Layout.Group}
/>
</BaseCard>
);
@ -257,7 +279,7 @@ class FilePanel extends React.Component {
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<Loader />
<Spinner />
</BaseCard>
);
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.GenericErrorPage")
export default class GenericErrorPage extends React.PureComponent {
@ -28,8 +28,8 @@ export default class GenericErrorPage extends React.PureComponent {
render() {
return <div className='mx_GenericErrorPage'>
<div className='mx_GenericErrorPage_box'>
<h1>{this.props.title}</h1>
<p>{this.props.message}</p>
<h1>{ this.props.title }</h1>
<p>{ this.props.message }</p>
</div>
</div>;
}

View file

@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { EventSubscription } from "fbemitter";
import React from 'react';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
@ -24,29 +25,49 @@ import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
interface IGroupFilterPanelProps {
}
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
type OrderedTagsTemporaryType = Array<{}>;
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
type SelectedTagsTemporaryType = Array<{}>;
interface IGroupFilterPanelState {
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
orderedTags: OrderedTagsTemporaryType;
// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript
selectedTags: SelectedTagsTemporaryType;
}
@replaceableComponent("structures.GroupFilterPanel")
class GroupFilterPanel extends React.Component {
static contextType = MatrixClientContext;
class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFilterPanelState> {
public static contextType = MatrixClientContext;
state = {
public state = {
orderedTags: [],
selectedTags: [],
};
componentDidMount() {
this.unmounted = false;
this.context.on("Group.myMembership", this._onGroupMyMembership);
this.context.on("sync", this._onClientSync);
private ref = React.createRef<HTMLDivElement>();
private unmounted = false;
private groupFilterOrderStoreToken?: EventSubscription;
this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
public componentDidMount() {
this.unmounted = false;
this.context.on("Group.myMembership", this.onGroupMyMembership);
this.context.on("sync", this.onClientSync);
this.groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => {
if (this.unmounted) {
return;
}
@ -57,23 +78,25 @@ class GroupFilterPanel extends React.Component {
});
// This could be done by anything with a matrix client
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
UIStore.instance.trackElementDimensions("GroupPanel", this.ref.current);
}
componentWillUnmount() {
public componentWillUnmount() {
this.unmounted = true;
this.context.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.removeListener("sync", this._onClientSync);
if (this._groupFilterOrderStoreToken) {
this._groupFilterOrderStoreToken.remove();
this.context.removeListener("Group.myMembership", this.onGroupMyMembership);
this.context.removeListener("sync", this.onClientSync);
if (this.groupFilterOrderStoreToken) {
this.groupFilterOrderStoreToken.remove();
}
UIStore.instance.stopTrackingElementDimensions("GroupPanel");
}
_onGroupMyMembership = () => {
private onGroupMyMembership = () => {
if (this.unmounted) return;
dis.dispatch(GroupActions.fetchJoinedGroups(this.context));
};
_onClientSync = (syncState, prevState) => {
private onClientSync = (syncState, prevState) => {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
@ -83,18 +106,18 @@ class GroupFilterPanel extends React.Component {
}
};
onMouseDown = e => {
private onClick = e => {
// only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) {
dis.dispatch({action: 'deselect_tags'});
dis.dispatch({ action: 'deselect_tags' });
}
};
onClearFilterClick = ev => {
dis.dispatch({action: 'deselect_tags'});
private onClearFilterClick = ev => {
dis.dispatch({ action: 'deselect_tags' });
};
renderGlobalIcon() {
private renderGlobalIcon() {
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
return (
@ -105,7 +128,7 @@ class GroupFilterPanel extends React.Component {
);
}
render() {
public render() {
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const ActionButton = sdk.getComponent('elements.ActionButton');
@ -123,12 +146,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")) {
@ -141,31 +171,18 @@ class GroupFilterPanel extends React.Component {
);
}
return <div className={classes} onClick={this.onClearFilterClick}>
return <div className={classes} onClick={this.onClearFilterClick} ref={this.ref}>
<AutoHideScrollbar
className="mx_GroupFilterPanel_scroller"
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6253
onMouseDown={this.onMouseDown}
onClick={this.onClick}
>
<Droppable
droppableId="tag-panel-droppable"
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div
className="mx_GroupFilterPanel_tagTileContainer"
ref={provided.innerRef}
>
{ this.renderGlobalIcon() }
{ tags }
<div>
{createButton}
</div>
{ provided.placeholder }
</div>
) }
</Droppable>
<div className="mx_GroupFilterPanel_tagTileContainer">
{ this.renderGlobalIcon() }
{ tags }
<div>
{ createButton }
</div>
</div>
</AutoHideScrollbar>
</div>;
}

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import { getHostingLink } from '../../utils/HostingLink';
@ -34,16 +34,19 @@ import classnames from 'classnames';
import GroupStore from '../../stores/GroupStore';
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 { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks";
import { Group } from "matrix-js-sdk/src/models/group";
import { sleep } from "matrix-js-sdk/src/utils";
import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {mediaFromMxc} from "../../customisations/Media";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { mediaFromMxc } from "../../customisations/Media";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { createSpaceFromCommunity } from "../../utils/space";
import { Action } from "../../dispatcher/actions";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
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 +102,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,26 +113,27 @@ 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);
};
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const addButton = this.props.editing ?
(<AccessibleButton className="mx_GroupView_featuredThings_addButton"
onClick={this.onAddRoomsToSummaryClicked}
>
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<img src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a Room') }
</div>
@ -146,8 +150,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 +194,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 }),
},
);
});
};
@ -220,7 +225,7 @@ class FeaturedRoom extends React.Component {
let roomNameNode = null;
if (permalink) {
roomNameNode = <a href={permalink} onClick={this.onClick} >{ roomName }</a>;
roomNameNode = <a href={permalink} onClick={this.onClick}>{ roomName }</a>;
} else {
roomNameNode = <span>{ roomName }</span>;
}
@ -271,7 +276,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,27 +288,27 @@ 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);
};
render() {
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 />;
<img 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 +357,19 @@ 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 },
),
},
);
});
};
@ -392,6 +402,8 @@ class FeaturedUser extends React.Component {
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
@ -415,6 +427,7 @@ export default class GroupView extends React.Component {
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
};
componentDidMount() {
@ -464,7 +477,7 @@ export default class GroupView extends React.Component {
// Leave settings - the user might have clicked the "Leave" button
this._closeSettings();
}
this.setState({membershipBusy: false});
this.setState({ membershipBusy: false });
};
_initGroupStore(groupId, firstInit) {
@ -485,7 +498,7 @@ export default class GroupView extends React.Component {
group_id: groupId,
},
});
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${groupId}`}});
dis.dispatch({ action: 'require_registration', screen_after: { screen: `group/${groupId}` } });
willDoOnboarding = true;
}
if (stateKey === GroupStore.STATE_KEY.Summary) {
@ -586,7 +599,7 @@ export default class GroupView extends React.Component {
};
_closeSettings = () => {
dis.dispatch({action: 'close_settings'});
dis.dispatch({ action: 'close_settings' });
};
_onNameChange = (value) => {
@ -614,7 +627,7 @@ export default class GroupView extends React.Component {
const file = ev.target.files[0];
if (!file) return;
this.setState({uploadingAvatar: true});
this.setState({ uploadingAvatar: true });
this._matrixClient.uploadContent(file).then((url) => {
const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url });
this.setState({
@ -626,7 +639,7 @@ export default class GroupView extends React.Component {
avatarChanged: true,
});
}).catch((e) => {
this.setState({uploadingAvatar: false});
this.setState({ uploadingAvatar: false });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to upload avatar image", e);
Modal.createTrackedDialog('Failed to upload image', '', ErrorDialog, {
@ -643,7 +656,7 @@ export default class GroupView extends React.Component {
};
_onSaveClick = () => {
this.setState({saving: true});
this.setState({ saving: true });
const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve();
savePromise.then((result) => {
this.setState({
@ -682,7 +695,7 @@ export default class GroupView extends React.Component {
}
_onAcceptInviteClick = async () => {
this.setState({membershipBusy: true});
this.setState({ membershipBusy: true });
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
@ -691,7 +704,7 @@ export default class GroupView extends React.Component {
GroupStore.acceptGroupInvite(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => {
this.setState({membershipBusy: false});
this.setState({ membershipBusy: false });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error accepting invite', '', ErrorDialog, {
title: _t("Error"),
@ -701,7 +714,7 @@ export default class GroupView extends React.Component {
};
_onRejectInviteClick = async () => {
this.setState({membershipBusy: true});
this.setState({ membershipBusy: true });
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
@ -710,7 +723,7 @@ export default class GroupView extends React.Component {
GroupStore.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => {
this.setState({membershipBusy: false});
this.setState({ membershipBusy: false });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error rejecting invite', '', ErrorDialog, {
title: _t("Error"),
@ -721,11 +734,11 @@ export default class GroupView extends React.Component {
_onJoinClick = async () => {
if (this._matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}});
dis.dispatch({ action: 'require_registration', screen_after: { screen: `group/${this.props.groupId}` } });
return;
}
this.setState({membershipBusy: true});
this.setState({ membershipBusy: true });
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
@ -734,7 +747,7 @@ export default class GroupView extends React.Component {
GroupStore.joinGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => {
this.setState({membershipBusy: false});
this.setState({ membershipBusy: false });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error joining room', '', ErrorDialog, {
title: _t("Error"),
@ -767,8 +780,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"),
@ -776,7 +789,7 @@ export default class GroupView extends React.Component {
onFinished: async (confirmed) => {
if (!confirmed) return;
this.setState({membershipBusy: true});
this.setState({ membershipBusy: true });
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
@ -785,7 +798,7 @@ export default class GroupView extends React.Component {
GroupStore.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => {
this.setState({membershipBusy: false});
this.setState({ membershipBusy: false });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error leaving community', '', ErrorDialog, {
title: _t("Error"),
@ -800,6 +813,22 @@ export default class GroupView extends React.Component {
showGroupAddRoomDialog(this.props.groupId);
};
_dismissUpgradeNotice = () => {
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
this.setState({ showUpgradeNotice: false });
}
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
};
_onAdminsLinkClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.GroupMemberList,
});
};
_getGroupSection() {
const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
@ -812,12 +841,12 @@ export default class GroupView extends React.Component {
let hostingSignup = null;
if (hostingSignupLink && this.state.isUserPrivileged) {
hostingSignup = <div className="mx_GroupView_hostingSignup">
{_t(
{ _t(
"Want more than a community? <a>Get your own server</a>", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">{sub}</a>,
a: sub => <a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">{ sub }</a>,
},
)}
) }
<a href={hostingSignupLink} target="_blank" rel="noreferrer noopener">
<img src={require("../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
@ -836,10 +865,46 @@ export default class GroupView extends React.Component {
},
) }
</div> : <div />;
let communitiesUpgradeNotice;
if (this.state.showUpgradeNotice) {
let text;
if (this.state.isUserPrivileged) {
text = _t("You can create a Space from this community <a>here</a>.", {}, {
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
{ sub }
</AccessibleButton>,
});
} else {
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
"and keep a look out for the invite.", {}, {
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
{ sub }
</AccessibleButton>,
});
}
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
<h2>{ _t("Communities can now be made into Spaces") }</h2>
<p>
{ _t("Spaces are a new way to make a community, with new features coming.") }
&nbsp;
{ text }
&nbsp;
{ _t("Communities won't receive further updates.") }
</p>
<AccessibleButton
className="mx_GroupView_spaceUpgradePrompt_close"
onClick={this._dismissUpgradeNotice}
/>
</div>;
}
return <div className={groupSettingsSectionClasses}>
{ header }
{ hostingSignup }
{ changeDelayWarning }
{ communitiesUpgradeNotice }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }
@ -849,7 +914,6 @@ export default class GroupView extends React.Component {
_getRoomsNode() {
const RoomDetailList = sdk.getComponent('rooms.RoomDetailList');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const Spinner = sdk.getComponent('elements.Spinner');
const TooltipButton = sdk.getComponent('elements.TooltipButton');
@ -865,7 +929,7 @@ export default class GroupView extends React.Component {
onClick={this._onAddRoomsClick}
>
<div className="mx_GroupView_rooms_header_addRow_button">
<TintableSvg src={require("../../../res/img/icons-room-add.svg")} width="24" height="24" />
<img src={require("../../../res/img/icons-room-add.svg")} width="24" height="24" />
</div>
<div className="mx_GroupView_rooms_header_addRow_label">
{ _t('Add rooms to this community') }
@ -1055,10 +1119,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,
);
@ -1178,10 +1243,13 @@ export default class GroupView extends React.Component {
avatarImage = <Spinner />;
} else {
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
avatarImage = <GroupAvatar groupId={this.props.groupId}
avatarImage = <GroupAvatar
groupId={this.props.groupId}
groupName={this.state.profileForm.name}
groupAvatarUrl={this.state.profileForm.avatar_url}
width={28} height={28} resizeMethod='crop'
width={28}
height={28}
resizeMethod='crop'
/>;
}
@ -1192,9 +1260,12 @@ export default class GroupView extends React.Component {
</label>
<div className="mx_GroupView_avatarPicker_edit">
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
<img src={require("../../../res/img/camera.svg")}
alt={_t("Upload avatar")} title={_t("Upload avatar")}
width="17" height="15" />
<img
src={require("../../../res/img/camera.svg")}
alt={_t("Upload avatar")}
title={_t("Upload avatar")}
width="17"
height="15" />
</label>
<input id="avatarInput" className="mx_GroupView_uploadInput" type="file" onChange={this._onAvatarSelected} />
</div>
@ -1231,7 +1302,8 @@ export default class GroupView extends React.Component {
groupAvatarUrl={groupAvatarUrl}
groupName={groupName}
onClick={onGroupHeaderItemClick}
width={28} height={28}
width={28}
height={28}
/>;
if (summary.profile && summary.profile.name) {
nameNode = <div onClick={onGroupHeaderItemClick}>
@ -1262,28 +1334,32 @@ export default class GroupView extends React.Component {
key="_cancelButton"
onClick={this._onCancelClick}
>
<img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor"
width="18" height="18" alt={_t("Cancel")} />
<img
src={require("../../../res/img/cancel.svg")}
className="mx_filterFlipColor"
width="18"
height="18"
alt={_t("Cancel")} />
</AccessibleButton>,
);
} else {
if (summary.user && summary.user.membership === 'join') {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton"
<AccessibleButton
className="mx_GroupHeader_button mx_GroupHeader_editButton"
key="_editButton"
onClick={this._onEditClick}
title={_t("Community Settings")}
>
</AccessibleButton>,
/>,
);
}
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton"
<AccessibleButton
className="mx_GroupHeader_button mx_GroupHeader_shareButton"
key="_shareButton"
onClick={this._onShareClick}
title={_t('Share Community')}
>
</AccessibleButton>,
/>,
);
}
@ -1329,7 +1405,7 @@ export default class GroupView extends React.Component {
if (this.state.error.httpStatus === 404) {
return (
<div className="mx_GroupView_error">
{ _t('Community %(groupId)s not found', {groupId: this.props.groupId}) }
{ _t('Community %(groupId)s not found', { groupId: this.props.groupId }) }
</div>
);
} else {
@ -1339,7 +1415,7 @@ export default class GroupView extends React.Component {
}
return (
<div className="mx_GroupView_error">
{ _t('Failed to load %(groupId)s', {groupId: this.props.groupId }) }
{ _t('Failed to load %(groupId)s', { groupId: this.props.groupId }) }
{ extraText }
</div>
);

View file

@ -15,29 +15,29 @@ limitations under the License.
*/
import * as React from "react";
import {useContext, useState} from "react";
import { useContext, useState } from "react";
import AutoHideScrollbar from './AutoHideScrollbar';
import {getHomePageUrl} from "../../utils/pages";
import {_t} from "../../languageHandler";
import { getHomePageUrl } from "../../utils/pages";
import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
import * as sdk from "../../index";
import dis from "../../dispatcher/dispatcher";
import {Action} from "../../dispatcher/actions";
import { Action } from "../../dispatcher/actions";
import BaseAvatar from "../views/avatars/BaseAvatar";
import {OwnProfileStore} from "../../stores/OwnProfileStore";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import AccessibleButton from "../views/elements/AccessibleButton";
import {UPDATE_EVENT} from "../../stores/AsyncStore";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { useEventEmitter } from "../../hooks/useEventEmitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader";
import MiniAvatarUploader, { AVATAR_SIZE } from "../views/elements/MiniAvatarUploader";
import Analytics from "../../Analytics";
import CountlyAnalytics from "../../CountlyAnalytics";
const onClickSendDm = () => {
Analytics.trackEvent('home_page', 'button', 'dm');
CountlyAnalytics.instance.track("home_page_button", { button: "dm" });
dis.dispatch({action: 'view_create_chat'});
dis.dispatch({ action: 'view_create_chat' });
};
const onClickExplore = () => {
@ -49,7 +49,7 @@ const onClickExplore = () => {
const onClickNewRoom = () => {
Analytics.trackEvent('home_page', 'button', 'create_room');
CountlyAnalytics.instance.track("home_page_button", { button: "create_room" });
dis.dispatch({action: 'view_create_room'});
dis.dispatch({ action: 'view_create_room' });
};
interface IProps {
@ -96,6 +96,7 @@ const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
const pageUrl = getHomePageUrl(config);
if (pageUrl) {
// FIXME: Using an import will result in wrench-element-tests failures
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
}
@ -117,7 +118,6 @@ const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
</React.Fragment>;
}
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default">
<div className="mx_HomePage_default_wrapper">
{ introSection }

View file

@ -22,17 +22,20 @@ import {
import { _t } from "../../languageHandler";
import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig";
import {replaceableComponent} from "../../utils/replaceableComponent";
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);
}
};
public render(): React.ReactNode {
const hostSignupConfig = SdkConfig.get().hostSignup;

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.IndicatorScrollbar")
export default class IndicatorScrollbar extends React.Component {
@ -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();
}
}
@ -68,7 +70,6 @@ export default class IndicatorScrollbar extends React.Component {
this._autoHideScrollbar = autoHideScrollbar;
}
componentDidUpdate(prevProps) {
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
const curLen = this.props.children && this.props.children.length || 0;
@ -183,21 +184,24 @@ export default class IndicatorScrollbar extends React.Component {
};
render() {
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
const leftOverflowIndicator = this.props.trackHorizontalOverflow
// eslint-disable-next-line no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
const rightIndicatorStyle = { right: this.state.rightIndicatorOffset };
const leftOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = this.props.trackHorizontalOverflow
const rightOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
onWheel={this.onMouseWheel}
{...this.props}
{...otherProps}
>
{ leftOverflowIndicator }
{ this.props.children }
{ children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
}

View file

@ -1,300 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {InteractiveAuth} from "matrix-js-sdk/src/interactive-auth";
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents';
import * as sdk from '../../index';
import {replaceableComponent} from "../../utils/replaceableComponent";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
@replaceableComponent("structures.InteractiveAuthComponent")
export default class InteractiveAuthComponent extends React.Component {
static propTypes = {
// matrix client to use for UI auth requests
matrixClient: PropTypes.object.isRequired,
// response from initial request. If not supplied, will do a request on
// mount.
authData: PropTypes.shape({
flows: PropTypes.array,
params: PropTypes.object,
session: PropTypes.string,
}),
// callback
makeRequest: PropTypes.func.isRequired,
// callback called when the auth process has finished,
// successfully or unsuccessfully.
// @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object.
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
// the auth session.
// * clientSecret {string} The client secret used in auth
// sessions with the ID server.
onAuthFinished: PropTypes.func.isRequired,
// Inputs provided by the user to the auth process
// and used by various stages. As passed to js-sdk
// interactive-auth
inputs: PropTypes.object,
// As js-sdk interactive-auth
requestEmailToken: PropTypes.func,
sessionId: PropTypes.string,
clientSecret: PropTypes.string,
emailSid: PropTypes.string,
// If true, poll to see if the auth flow has been completed
// out-of-band
poll: PropTypes.bool,
// If true, components will be told that the 'Continue' button
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged: PropTypes.bool,
// Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric).
onStagePhaseChange: PropTypes.func,
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText: PropTypes.string,
continueKind: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
submitButtonEnabled: false,
};
this._unmounted = false;
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this._requestCallback,
busyChanged: this._onBusyChanged,
inputs: this.props.inputs,
stateUpdated: this._authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this._requestEmailToken,
});
this._intervalId = null;
if (this.props.poll) {
this._intervalId = setInterval(() => {
this._authLogic.poll();
}, 2000);
}
this._stageComponent = createRef();
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
this._authLogic.attemptAuth().then((result) => {
const extra = {
emailSid: this._authLogic.getEmailSid(),
clientSecret: this._authLogic.getClientSecret(),
};
this.props.onAuthFinished(true, result, extra);
}).catch((error) => {
this.props.onAuthFinished(false, error);
console.error("Error during user-interactive auth:", error);
if (this._unmounted) {
return;
}
const msg = error.message || error.toString();
this.setState({
errorText: msg,
});
});
}
componentWillUnmount() {
this._unmounted = true;
if (this._intervalId !== null) {
clearInterval(this._intervalId);
}
}
_requestEmailToken = async (...args) => {
this.setState({
busy: true,
});
try {
return await this.props.requestEmailToken(...args);
} finally {
this.setState({
busy: false,
});
}
};
tryContinue = () => {
if (this._stageComponent.current && this._stageComponent.current.tryContinue) {
this._stageComponent.current.tryContinue();
}
};
_authStateUpdated = (stageType, stageState) => {
const oldStage = this.state.authStage;
this.setState({
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage !== stageType) {
this._setFocus();
} else if (
!stageState.error && this._stageComponent.current &&
this._stageComponent.current.attemptFailed
) {
this._stageComponent.current.attemptFailed();
}
});
};
_requestCallback = (auth) => {
// This wrapper just exists because the js-sdk passes a second
// 'busy' param for backwards compat. This throws the tests off
// so discard it here.
return this.props.makeRequest(auth);
};
_onBusyChanged = (busy) => {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any
// immediate work has completed, but that's not really what we want at
// the UI layer, so we ignore this signal and show a spinner until
// there's a new screen to show the user. This is implemented by setting
// `busy: false` in `_authStateUpdated`.
// See also https://github.com/vector-im/element-web/issues/12546
};
_setFocus() {
if (this._stageComponent.current && this._stageComponent.current.focus) {
this._stageComponent.current.focus();
}
}
_submitAuthDict = authData => {
this._authLogic.submitAuthDict(authData);
};
_onPhaseChange = newPhase => {
if (this.props.onStagePhaseChange) {
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
}
};
_onStageCancel = () => {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
};
_renderCurrentStage() {
const stage = this.state.authStage;
if (!stage) {
if (this.state.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent
ref={this._stageComponent}
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this._authLogic.getSessionId()}
clientSecret={this._authLogic.getClientSecret()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this._onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this._onStageCancel}
/>
);
}
_onAuthStageFailed = e => {
this.props.onAuthFinished(false, e);
};
_setEmailSid = sid => {
this._authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this._renderCurrentStage() }
{ error }
</div>
</div>
);
}
}

View file

@ -0,0 +1,300 @@
/*
Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 {
AuthType,
IAuthData,
IAuthDict,
IInputs,
InteractiveAuth,
IStageStatus,
} from "matrix-js-sdk/src/interactive-auth";
import { MatrixClient } from "matrix-js-sdk/src/client";
import React, { createRef } from 'react';
import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
import Spinner from "../views/elements/Spinner";
import { replaceableComponent } from "../../utils/replaceableComponent";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
interface IProps {
// matrix client to use for UI auth requests
matrixClient: MatrixClient;
// response from initial request. If not supplied, will do a request on mount.
authData?: IAuthData;
// Inputs provided by the user to the auth process
// and used by various stages. As passed to js-sdk
// interactive-auth
inputs?: IInputs;
sessionId?: string;
clientSecret?: string;
emailSid?: string;
// If true, poll to see if the auth flow has been completed out-of-band
poll?: boolean;
// If true, components will be told that the 'Continue' button
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged?: boolean;
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText?: string;
continueKind?: string;
// callback
makeRequest(auth: IAuthData): Promise<IAuthData>;
// callback called when the auth process has finished,
// successfully or unsuccessfully.
// @param {boolean} status True if the operation requiring
// auth was completed successfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object.
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
// the auth session.
// * clientSecret {string} The client secret used in auth
// sessions with the ID server.
onAuthFinished(
status: boolean,
result: IAuthData | Error,
extra?: { emailSid?: string, clientSecret?: string },
): void;
// As js-sdk interactive-auth
requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
// Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric).
onStagePhaseChange?(stage: string, phase: string | number): void;
}
interface IState {
authStage?: AuthType;
stageState?: IStageStatus;
busy: boolean;
errorText?: string;
stageErrorText?: string;
submitButtonEnabled: boolean;
}
@replaceableComponent("structures.InteractiveAuthComponent")
export default class InteractiveAuthComponent extends React.Component<IProps, IState> {
private readonly authLogic: InteractiveAuth;
private readonly intervalId: number = null;
private readonly stageComponent = createRef<IStageComponent>();
private unmounted = false;
constructor(props) {
super(props);
this.state = {
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
submitButtonEnabled: false,
};
this.authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this.requestCallback,
busyChanged: this.onBusyChanged,
inputs: this.props.inputs,
stateUpdated: this.authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this.requestEmailToken,
});
if (this.props.poll) {
this.intervalId = setInterval(() => {
this.authLogic.poll();
}, 2000);
}
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line @typescript-eslint/naming-convention, camelcase
this.authLogic.attemptAuth().then((result) => {
const extra = {
emailSid: this.authLogic.getEmailSid(),
clientSecret: this.authLogic.getClientSecret(),
};
this.props.onAuthFinished(true, result, extra);
}).catch((error) => {
this.props.onAuthFinished(false, error);
console.error("Error during user-interactive auth:", error);
if (this.unmounted) {
return;
}
const msg = error.message || error.toString();
this.setState({
errorText: msg,
});
});
}
componentWillUnmount() {
this.unmounted = true;
if (this.intervalId !== null) {
clearInterval(this.intervalId);
}
}
private requestEmailToken = async (
email: string,
secret: string,
attempt: number,
session: string,
): Promise<{sid: string}> => {
this.setState({
busy: true,
});
try {
return await this.props.requestEmailToken(email, secret, attempt, session);
} finally {
this.setState({
busy: false,
});
}
};
private tryContinue = (): void => {
this.stageComponent.current?.tryContinue?.();
};
private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => {
const oldStage = this.state.authStage;
this.setState({
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
}, () => {
if (oldStage !== stageType) {
this.setFocus();
} else if (!stageState.error) {
this.stageComponent.current?.attemptFailed?.();
}
});
};
private requestCallback = (auth: IAuthData, background: boolean): Promise<IAuthData> => {
// This wrapper just exists because the js-sdk passes a second
// 'busy' param for backwards compat. This throws the tests off
// so discard it here.
return this.props.makeRequest(auth);
};
private onBusyChanged = (busy: boolean): void => {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any
// immediate work has completed, but that's not really what we want at
// the UI layer, so we ignore this signal and show a spinner until
// there's a new screen to show the user. This is implemented by setting
// `busy: false` in `authStateUpdated`.
// See also https://github.com/vector-im/element-web/issues/12546
};
private setFocus(): void {
this.stageComponent.current?.focus?.();
}
private submitAuthDict = (authData: IAuthDict): void => {
this.authLogic.submitAuthDict(authData);
};
private onPhaseChange = (newPhase: number): void => {
this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0);
};
private onStageCancel = (): void => {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
};
private renderCurrentStage(): JSX.Element {
const stage = this.state.authStage;
if (!stage) {
if (this.state.busy) {
return <Spinner />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent
ref={this.stageComponent as any}
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this.authLogic.getSessionId()}
clientSecret={this.authLogic.getClientSecret()}
stageParams={this.authLogic.getStageParams(stage)}
submitAuthDict={this.submitAuthDict}
errorText={this.state.stageErrorText}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this.onAuthStageFailed}
setEmailSid={this.setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this.onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this.onStageCancel}
/>
);
}
private onAuthStageFailed = (e: Error): void => {
this.props.onAuthFinished(false, e);
};
private setEmailSid = (sid: string): void => {
this.authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this.renderCurrentStage() }
{ error }
</div>
</div>
);
}
}

View file

@ -19,11 +19,10 @@ import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import GroupFilterPanel from "./GroupFilterPanel";
import CustomRoomTagPanel from "./CustomRoomTagPanel";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import CallHandler from "../../CallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu";
@ -32,17 +31,15 @@ import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
interface IProps {
isMinimized: boolean;
@ -51,7 +48,6 @@ interface IProps {
interface IState {
showBreadcrumbs: boolean;
showGroupFilterPanel: boolean;
activeSpace?: Room;
}
@ -66,9 +62,8 @@ 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;
private focusedElement = null;
private isDoingStickyHeaders = false;
@ -77,47 +72,59 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
activeSpace: SpaceStore.instance.activeSpace,
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.bgImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.onBackgroundImageUpdate);
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("LeftPanel", this.ref.current);
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() {
SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef);
SettingsStore.unwatchSetting(this.bgImageWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
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) => {
this.setState({ activeSpace });
};
private onDialPad = () => {
dis.fire(Action.OpenDialPad);
};
private onExplore = () => {
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) {
this.setState({showBreadcrumbs: newVal});
this.setState({ showBreadcrumbs: newVal });
// Update the sticky headers too as the breadcrumbs will be popping in or out.
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
@ -125,23 +132,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private onBackgroundImageUpdate = () => {
// Note: we do this in the LeftPanel as it uses this variable most prominently.
const avatarSize = 32; // arbitrary
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
if (settingBgMxc) {
avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
}
const avatarUrlProp = `url(${avatarUrl})`;
if (!avatarUrl) {
document.body.style.removeProperty("--avatar-url");
} else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) {
document.body.style.setProperty("--avatar-url", avatarUrlProp);
}
};
private handleStickyHeaders(list: HTMLDivElement) {
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
@ -154,10 +144,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
private doStickyHeaders(list: HTMLDivElement) {
const topEdge = list.scrollTop;
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist");
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
@ -228,7 +215,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 +235,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 +270,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 +336,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();
@ -369,9 +358,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>
@ -379,7 +365,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private renderSearchExplore(): React.ReactNode {
private renderSearchDialExplore(): React.ReactNode {
let dialPadButton = null;
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
dialPadButton =
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}
onClick={this.onDialPad}
title={_t("Open dial pad")}
/>;
}
return (
<div
className="mx_LeftPanel_filterContainer"
@ -392,36 +391,32 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown}
onSelectRoom={this.selectRoom}
/>
{ dialPadButton }
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_exploreButton", {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
title={_t("Explore rooms")}
title={this.state.activeSpace
? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
: _t("Explore rooms")}
/>
</div>
);
}
public render(): React.ReactNode {
let leftLeftPanel;
if (this.state.showGroupFilterPanel) {
leftLeftPanel = (
<div className="mx_LeftPanel_GroupFilterPanelContainer">
<GroupFilterPanel />
{SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null}
</div>
);
}
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,26 +430,24 @@ export default class LeftPanel extends React.Component<IProps, IState> {
);
return (
<div className={containerClasses}>
{leftLeftPanel}
<div className={containerClasses} ref={this.ref}>
<aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
{this.renderBreadcrumbs()}
<RoomListNumResults />
{ this.renderHeader() }
{ this.renderSearchDialExplore() }
{ this.renderBreadcrumbs() }
<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.
tabIndex={-1}
>
{roomList}
{ roomList }
</div>
</div>
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> }
{ !this.props.isMinimized && <LeftPanelWidget /> }
</aside>
</div>
);

View file

@ -14,29 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useEffect, useMemo} from "react";
import {Resizable} from "re-resizable";
import React, { useContext, useMemo } from "react";
import { Resizable } from "re-resizable";
import classNames from "classnames";
import AccessibleButton from "../views/elements/AccessibleButton";
import {useRovingTabIndex} from "../../accessibility/RovingTabIndex";
import {Key} from "../../Keyboard";
import {useLocalStorageState} from "../../hooks/useLocalStorageState";
import { useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { Key } from "../../Keyboard";
import { useLocalStorageState } from "../../hooks/useLocalStorageState";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
import {useAccountData} from "../../hooks/useAccountData";
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 { useSettingValue } from "../../hooks/useSettings";
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;
@ -66,15 +62,14 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
let content;
if (expanded) {
content = <Resizable
size={{height} as any}
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);
}}
handleWrapperClass="mx_LeftPanelWidget_resizerHandles"
handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}}
handleClasses={{ top: "mx_LeftPanelWidget_resizerHandle" }}
className="mx_LeftPanelWidget_resizeBox"
enable={{ top: true }}
>
@ -120,7 +115,7 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
aria-expanded={expanded}
aria-level={1}
onClick={() => {
setExpanded(e => !e);
setExpanded(!expanded);
}}
>
<span className={classNames({
@ -130,15 +125,15 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
<span>{ WidgetUtils.getWidgetName(app) }</span>
</AccessibleButton>
{/* Code for the maximise button for once we have full screen widgets */}
{/*<AccessibleTooltipButton
{ /* Code for the maximise button for once we have full screen widgets */ }
{ /*<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={() => {
}}
className="mx_LeftPanelWidget_maximizeButton"
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
title={_t("Maximize")}
/>*/}
/>*/ }
</div>
</div>

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018, 2020 New Vector Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,23 +15,19 @@ limitations under the License.
*/
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { DragDropContext } from 'react-beautiful-dnd';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import {Key} from '../../Keyboard';
import { Key } from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import MediaDeviceHandler from '../../MediaDeviceHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg';
import { IMatrixClientCreds } from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer';
import { Resizer, CollapseDistributor } from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
import HomePage from "./HomePage";
@ -51,14 +45,30 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay
import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload";
import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBindingsManager';
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import CallHandler from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import RoomView from './RoomView';
import ToastContainer from './ToastContainer';
import MyGroups from "./MyGroups";
import UserView from "./UserView";
import GroupView from "./GroupView";
import BackdropPanel from "./BackdropPanel";
import SpaceStore from "../../stores/SpaceStore";
import classNames from 'classnames';
import GroupFilterPanel from './GroupFilterPanel';
import CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media";
// 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.
@ -74,21 +84,23 @@ function canElementReceiveInput(el) {
interface IProps {
matrixClient: MatrixClient;
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
hideToSRUsers: boolean;
resizeNotifier: ResizeNotifier;
// eslint-disable-next-line camelcase
page_type: string;
autoJoin: boolean;
page_type?: string;
autoJoin?: boolean;
threepidInvite?: IThreepidInvite;
roomOobData?: object;
roomOobData?: IOOBData;
currentRoomId: string;
collapseLhs: boolean;
config: {
piwik: {
policyUrl: string;
},
[key: string]: any,
};
[key: string]: any;
};
currentUserId?: string;
currentGroupId?: string;
@ -119,6 +131,8 @@ interface IState {
usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number;
useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
backgroundImage?: string;
}
/**
@ -134,22 +148,13 @@ interface IState {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
static propTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: PropTypes.func,
// and lots and lots of other stuff.
};
private dispatcherRef: string;
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
protected compactLayoutWatcherRef: string;
protected backgroundImageWatcherRef: string;
protected resizer: Resizer;
constructor(props, context) {
@ -160,23 +165,26 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
CallMediaHandler.loadDevices();
MediaDeviceHandler.loadDevices();
fixupColorFonts();
this._roomView = React.createRef();
this._resizeContainer = React.createRef();
this.resizeHandler = React.createRef();
}
componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false);
document.addEventListener('keydown', this.onNativeKeyDown, false);
this.dispatcherRef = dis.register(this.onAction);
this._updateServerNoticeEvents();
this.updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
@ -191,67 +199,90 @@ class LoggedInView extends React.Component<IProps, IState> {
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
this.backgroundImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.refreshBackgroundImage,
);
this.resizer = this._createResizer();
this.resizer = this.createResizer();
this.resizer.attach();
this._loadResizerPreferences();
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
this.loadResizerPreferences();
this.refreshBackgroundImage();
}
componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false);
document.removeEventListener('keydown', this.onNativeKeyDown, false);
dis.unregister(this.dispatcherRef);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage);
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
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 refreshBackgroundImage = async (): Promise<void> => {
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
if (backgroundImage) {
// convert to http before going much further
backgroundImage = mediaFromMxc(backgroundImage).srcHttp;
} else {
backgroundImage = OwnProfileStore.instance.getHttpAvatarUrl();
}
this.setState({ backgroundImage });
};
canResetTimelineInRoom = (roomId) => {
private onAction = (payload): void => {
switch (payload.action) {
case 'call_state': {
const activeCalls = CallHandler.sharedInstance().getAllActiveCalls();
if (activeCalls !== this.state.activeCalls) {
this.setState({ activeCalls });
}
break;
}
}
};
public canResetTimelineInRoom = (roomId: string) => {
if (!this._roomView.current) {
return true;
}
return this._roomView.current.canResetTimeline();
};
_createResizer() {
let size;
let collapsed;
private createResizer() {
let panelSize;
let panelCollapsed;
const collapseConfig: ICollapseConfig = {
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
toggleSize: 206 - 50,
onCollapsed: (_collapsed) => {
collapsed = _collapsed;
if (_collapsed) {
dis.dispatch({action: "hide_left_panel"});
onCollapsed: (collapsed) => {
panelCollapsed = collapsed;
if (collapsed) {
dis.dispatch({ action: "hide_left_panel" });
window.localStorage.setItem("mx_lhs_size", '0');
} else {
dis.dispatch({action: "show_left_panel"});
dis.dispatch({ action: "show_left_panel" });
}
},
onResized: (_size) => {
size = _size;
onResized: (size) => {
panelSize = size;
this.props.resizeNotifier.notifyLeftHandleResized();
},
onResizeStart: () => {
this.props.resizeNotifier.startResizing();
},
onResizeStop: () => {
if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", '' + panelSize);
this.props.resizeNotifier.stopResizing();
},
isItemCollapsed: domNode => {
return domNode.classList.contains("mx_LeftPanel_minimized");
},
handler: this.resizeHandler.current,
};
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames({
@ -262,17 +293,17 @@ class LoggedInView extends React.Component<IProps, IState> {
return resizer;
}
_loadResizerPreferences() {
private loadResizerPreferences() {
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10);
if (isNaN(lhsSize)) {
lhsSize = 350;
}
this.resizer.forHandleAt(0).resize(lhsSize);
this.resizer.forHandleWithId('lp-resizer').resize(lhsSize);
}
onAccountData = (event) => {
private onAccountData = (event: MatrixEvent) => {
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({action: "ignore_state_changed"});
dis.dispatch({ action: "ignore_state_changed" });
}
};
@ -302,16 +333,16 @@ class LoggedInView extends React.Component<IProps, IState> {
}
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
this._updateServerNoticeEvents();
this.updateServerNoticeEvents();
} else {
this._calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
}
};
onRoomStateEvents = (ev, state) => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (serverNoticeList && serverNoticeList.some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
this.updateServerNoticeEvents();
}
};
@ -319,9 +350,9 @@ class LoggedInView extends React.Component<IProps, IState> {
this.setState({
usageLimitDismissed: true,
});
}
};
_calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
if (error) {
usageLimitEventContent = syncError.error.data;
@ -341,7 +372,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
}
_updateServerNoticeEvents = async () => {
private updateServerNoticeEvents = async () => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (!serverNoticeList) return [];
@ -355,7 +386,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);
}
@ -373,7 +404,7 @@ class LoggedInView extends React.Component<IProps, IState> {
);
});
const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent();
this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
this.calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
this.setState({
usageLimitEventContent,
usageLimitEventTs: pinnedEventTs,
@ -382,7 +413,7 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
_onPaste = (ev) => {
private onPaste = (ev) => {
let canReceiveInput = false;
let element = ev.target;
// test for all parents because the target can be a child of a contenteditable element
@ -394,7 +425,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// refocusing during a paste event will make the
// paste end up in the newly focused element,
// so dispatch synchronously before paste happens
dis.fire(Action.FocusComposer, true);
dis.fire(Action.FocusSendMessageComposer, true);
}
};
@ -420,22 +451,22 @@ class LoggedInView extends React.Component<IProps, IState> {
We also listen with a native listener on the document to get keydown events when no element is focused.
Bubbling is irrelevant here as the target is the body element.
*/
_onReactKeyDown = (ev) => {
private onReactKeyDown = (ev) => {
// events caught while bubbling up on the root element
// of this component, so something must be focused.
this._onKeyDown(ev);
this.onKeyDown(ev);
};
_onNativeKeyDown = (ev) => {
private onNativeKeyDown = (ev) => {
// only pass this if there is no focused element.
// if there is, _onKeyDown will be called by the
// if there is, onKeyDown will be called by the
// react keydown handler that respects the react bubbling order.
if (ev.target === document.body) {
this._onKeyDown(ev);
this.onKeyDown(ev);
}
};
_onKeyDown = (ev) => {
private onKeyDown = (ev) => {
let handled = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev);
@ -445,7 +476,7 @@ class LoggedInView extends React.Component<IProps, IState> {
case RoomAction.JumpToFirstMessage:
case RoomAction.JumpToLatestMessage:
// pass the event down to the scroll panel
this._onScrollKeyPressed(ev);
this.onScrollKeyPressed(ev);
handled = true;
break;
case RoomAction.FocusSearch:
@ -534,24 +565,24 @@ class LoggedInView extends React.Component<IProps, IState> {
}
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).
const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER);
// Do not capture the context menu key to improve keyboard accessibility
if (ev.key === Key.CONTEXT_MENU) {
return;
}
// We explicitly allow alt to be held due to it being a common accent modifier.
// XXX: Forwarding Dead keys in this way does not work as intended but better to at least
// move focus to the composer so the user can re-type the dead key correctly.
const isPrintable = ev.key.length === 1 || ev.key === "Dead";
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// If the user is entering a printable character outside of an input field
// redirect it to the composer for them.
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input
dis.fire(Action.FocusComposer, true);
dis.fire(Action.FocusSendMessageComposer, true);
ev.stopPropagation();
// we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
}
}
};
@ -560,63 +591,13 @@ class LoggedInView extends React.Component<IProps, IState> {
* dispatch a page-up/page-down/etc to the appropriate component
* @param {Object} ev The key event
*/
_onScrollKeyPressed = (ev) => {
private onScrollKeyPressed = (ev) => {
if (this._roomView.current) {
this._roomView.current.handleScrollKey(ev);
}
};
_onDragEnd = (result) => {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
}
const dest = result.destination.droppableId;
if (dest === 'tag-panel-droppable') {
// Could be "GroupTile +groupId:domain"
const draggableId = result.draggableId.split(' ').pop();
// Dispatch synchronously so that the GroupFilterPanel receives an
// optimistic update from GroupFilterOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this._matrixClient,
draggableId,
result.destination.index,
), true);
} else if (dest.startsWith('room-sub-list-droppable_')) {
this._onRoomTileEndDrag(result);
}
};
_onRoomTileEndDrag = (result) => {
let newTag = result.destination.droppableId.split('_')[1];
let prevTag = result.source.droppableId.split('_')[1];
if (newTag === 'undefined') newTag = undefined;
if (prevTag === 'undefined') prevTag = undefined;
const roomId = result.draggableId.split('_')[1];
const oldIndex = result.source.index;
const newIndex = result.destination.index;
dis.dispatch(RoomListActions.tagRoom(
this._matrixClient,
this._matrixClient.getRoom(roomId),
prevTag, newTag,
oldIndex, newIndex,
), true);
};
render() {
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const ToastContainer = sdk.getComponent('structures.ToastContainer');
let pageElement;
switch (this.props.page_type) {
@ -656,35 +637,73 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
}
let bodyClasses = 'mx_MatrixChat';
if (this.state.useCompactLayout) {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const wrapperClasses = classNames({
'mx_MatrixChat_wrapper': true,
'mx_MatrixChat_useCompactLayout': this.state.useCompactLayout,
});
const bodyClasses = classNames({
'mx_MatrixChat': true,
'mx_MatrixChat--with-avatar': this.state.backgroundImage,
});
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
);
});
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
onPaste={this._onPaste}
onKeyDown={this._onReactKeyDown}
className='mx_MatrixChat_wrapper'
onPaste={this.onPaste}
onKeyDown={this.onReactKeyDown}
className={wrapperClasses}
aria-hidden={this.props.hideToSRUsers}
>
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
<div className={bodyClasses}>
<div className='mx_LeftPanel_wrapper'>
{ SettingsStore.getValue('TagPanel.enableTagPanel') &&
(<div className="mx_GroupFilterPanelContainer">
<BackdropPanel
blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage}
/>
<GroupFilterPanel />
{ SettingsStore.getValue("feature_custom_tags") ? <CustomRoomTagPanel /> : null }
</div>)
}
{ SpaceStore.spacesEnabled ? <>
<BackdropPanel
blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage}
/>
<SpacePanel />
</> : null }
<BackdropPanel
backgroundImage={this.state.backgroundImage}
/>
<ResizeHandle />
<div
className="mx_LeftPanel_wrapper--user"
ref={this._resizeContainer}
data-collapsed={this.props.collapseLhs ? true : undefined}
>
<LeftPanel
isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
/>
</div>
</div>
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
<div className="mx_RoomView_wrapper">
{ pageElement }
</div>
</DragDropContext>
</div>
</div>
<CallContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
{ audioFeedArraysForCalls }
</MatrixClientContext.Provider>
);
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import { Resizable } from 're-resizable';
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.MainSplit")
export default class MainSplit extends React.Component {
@ -73,7 +73,7 @@ export default class MainSplit extends React.Component {
onResize={this._onResize}
onResizeStop={this._onResizeStop}
className="mx_RightPanel_ResizeWrapper"
handleClasses={{left: "mx_RightPanel_ResizeHandle"}}
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
>
{ panelView }
</Resizable>;

View file

@ -19,6 +19,8 @@ import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible';
// what-input helps improve keyboard accessibility
@ -34,8 +36,6 @@ import dis from "../../dispatcher/dispatcher";
import Notifier from '../../Notifier';
import Modal from "../../Modal";
import Tinter from "../../Tinter";
import * as sdk from '../../index';
import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite';
import * as Rooms from '../../Rooms';
import linkifyMatrix from "../../linkify-matrix";
@ -44,11 +44,11 @@ import * as Lifecycle from '../../Lifecycle';
import '../../stores/LifecycleStore';
import PageTypes from '../../PageTypes';
import createRoom, {IOpts} from "../../createRoom";
import {_t, _td, getCurrentLanguage} from '../../languageHandler';
import createRoom, { IOpts } from "../../createRoom";
import { _t, _td, getCurrentLanguage } from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore";
import ThemeController from "../../settings/controllers/ThemeController";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { startAnyRegistrationFlow } from "../../Registration";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
@ -56,7 +56,6 @@ import DMRoomMap from '../../utils/DMRoomMap';
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { FontWatcher } from '../../settings/watchers/FontWatcher';
import { storeRoomAliasInCache } from '../../RoomAliasCache';
import { defer, IDeferred, sleep } from "../../utils/promise";
import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView";
@ -66,7 +65,7 @@ import {
showToast as showAnalyticsToast,
hideToast as hideAnalyticsToast,
} from "../../toasts/AnalyticsToast";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
@ -74,17 +73,42 @@ import { SettingLevel } from "../../settings/SettingLevel";
import { leaveRoomBehaviour } from "../../utils/membership";
import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog";
import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore";
import {UIFeature} from "../../settings/UIFeature";
import { UIFeature } from "../../settings/UIFeature";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
import { shouldUseLoginForWelcome } from "../../utils/pages";
import SpaceStore from "../../stores/SpaceStore";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import RoomListStore from "../../stores/room-list/RoomListStore";
import {RoomUpdateCause} from "../../stores/room-list/models";
import { RoomUpdateCause } from "../../stores/room-list/models";
import defaultDispatcher from "../../dispatcher/dispatcher";
import SecurityCustomisations from "../../customisations/Security";
import Spinner from "../views/elements/Spinner";
import QuestionDialog from "../views/dialogs/QuestionDialog";
import UserSettingsDialog from '../views/dialogs/UserSettingsDialog';
import CreateGroupDialog from '../views/dialogs/CreateGroupDialog';
import CreateRoomDialog from '../views/dialogs/CreateRoomDialog';
import RoomDirectory from './RoomDirectory';
import KeySignatureUploadFailedDialog from "../views/dialogs/KeySignatureUploadFailedDialog";
import IncomingSasDialog from "../views/dialogs/IncomingSasDialog";
import CompleteSecurity from "./auth/CompleteSecurity";
import LoggedInView from './LoggedInView';
import Welcome from "../views/auth/Welcome";
import ForgotPassword from "./auth/ForgotPassword";
import E2eSetup from "./auth/E2eSetup";
import Registration from './auth/Registration';
import Login from "./auth/Login";
import ErrorBoundary from '../views/elements/ErrorBoundary';
import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout';
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
import { initSentry } from "../../sentry";
/** constants for MatrixChat.state.view */
export enum Views {
@ -133,7 +157,7 @@ const ONBOARDING_FLOW_STARTERS = [
interface IScreen {
screen: string;
params?: object;
params?: QueryDict;
}
/* eslint-disable camelcase */
@ -153,14 +177,19 @@ interface IRoomInfo {
/* eslint-enable camelcase */
interface IProps { // TODO type things better
config: Record<string, any>;
config: {
piwik: {
policyUrl: string;
};
[key: string]: any;
};
serverConfig?: ValidatedServerConfig;
onNewScreen: (screen: string, replaceLast: boolean) => void;
enableGuest?: boolean;
// the queryParams extracted from the [real] query-string of the URI
realQueryParams?: Record<string, string>;
realQueryParams?: QueryDict;
// the initial queryParams extracted from the hash-fragment of the URI
startingFragmentQueryParams?: Record<string, string>;
startingFragmentQueryParams?: QueryDict;
// called when we have completed a token login
onTokenLoginCompleted?: () => void;
// Represents the screen to display as a result of parsing the initial window.location
@ -168,7 +197,7 @@ interface IProps { // TODO type things better
// displayname, if any, to set on the device when logging in/registering.
defaultDeviceDisplayName?: string;
// A function that makes a registration URL
makeRegistrationUrl: (object) => string;
makeRegistrationUrl: (params: QueryDict) => string;
}
interface IState {
@ -201,7 +230,7 @@ interface IState {
resizeNotifier: ResizeNotifier;
serverConfig?: ValidatedServerConfig;
ready: boolean;
threepidInvite?: IThreepidInvite,
threepidInvite?: IThreepidInvite;
roomOobData?: object;
pendingInitialSync?: boolean;
justRegistered?: boolean;
@ -223,13 +252,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 accountPasswordTimer?: number;
private focusComposer: boolean;
private subTitleStatus: string;
private prevWindowWidth: number;
private readonly loggedInView: React.RefObject<LoggedInViewType>;
private readonly dispatcherRef: any;
@ -271,21 +300,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) {
// probably a threepid invite - try to store it
const roomId = this.screenAfterLogin.screen.substring("room/".length);
ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat);
ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat);
}
}
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;
// check we have the right tint applied for this theme.
// N.B. we don't call the whole of setTheme() here as we may be
// racing with the theme CSS download finishing from index.js
Tinter.tint();
// For PersistentElement
this.state.resizeNotifier.on("middlePanelResized", this.dispatchTimelineResize);
@ -366,7 +389,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable();
}
PosthogAnalytics.instance.updateAnonymityFromSettings();
PosthogAnalytics.instance.updatePlatformSuperProperties();
CountlyAnalytics.instance.enable(/* anonymous = */ true);
initSentry(SdkConfig.get()["sentry"]);
}
private async postLoginSetup() {
@ -376,7 +405,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
@ -399,7 +428,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) {
this.onLoggedIn();
} else {
this.setStateForNewView({view: Views.COMPLETE_SECURITY});
this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
}
} else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
this.setStateForNewView({ view: Views.E2E_SETUP });
@ -410,7 +439,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillUpdate(props, state) {
if (this.shouldTrackPageChange(this.state, state)) {
this.startPageChangeTimer();
@ -422,9 +451,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
PosthogAnalytics.instance.trackPageView(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusComposer);
dis.fire(Action.FocusSendMessageComposer);
this.focusComposer = false;
}
}
@ -434,7 +464,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);
@ -452,7 +482,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
let props = this.state.serverConfig;
if (!props) props = this.props.serverConfig; // for unit tests
if (!props) props = SdkConfig.get()["validated_server_config"];
return {serverConfig: props};
return { serverConfig: props };
}
private loadSession() {
@ -470,9 +500,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (!loadedSession) {
// fall back to showing the welcome screen... unless we have a 3pid invite pending
if (ThreepidInviteStore.instance.pickBestInvite()) {
dis.dispatch({action: 'start_registration'});
dis.dispatch({ action: 'start_registration' });
} else {
dis.dispatch({action: "view_welcome_page"});
dis.dispatch({ action: "view_welcome_page" });
}
} else if (SettingsStore.getValue("analyticsOptIn")) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
@ -484,42 +514,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) {
@ -542,7 +552,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onAction = (payload) => {
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// Start the onboarding process for certain actions
if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() &&
@ -556,14 +565,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'do_after_sync_prepared',
deferred_action: payload,
});
dis.dispatch({action: 'require_registration'});
dis.dispatch({ action: 'require_registration' });
return;
}
switch (payload.action) {
case 'MatrixActions.accountData':
// XXX: This is a collection of several hacks to solve a minor problem. We want to
// update our local state when the ID server changes, but don't want to put that in
// update our local state when the identity server changes, but don't want to put that in
// the js-sdk as we'd be then dictating how all consumers need to behave. However,
// this component is already bloated and we probably don't want this tiny logic in
// here, but there's no better place in the react-sdk for it. Additionally, we're
@ -581,11 +590,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
// redispatch the change with a more specific action
dis.dispatch({action: 'id_server_changed'});
dis.dispatch({ action: 'id_server_changed' });
}
break;
case 'logout':
dis.dispatch({action: "hangup_all"});
dis.dispatch({ action: "hangup_all" });
Lifecycle.logout();
break;
case 'require_registration':
@ -629,6 +638,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'forget_room':
this.forgetRoom(payload.room_id);
break;
case 'copy_room':
this.copyRoom(payload.room_id);
break;
case 'reject_invite':
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
title: _t('Reject invitation'),
@ -636,13 +648,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onFinished: (confirm) => {
if (confirm) {
// FIXME: controller shouldn't be loading a view :(
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
MatrixClientPeg.get().leave(payload.room_id).then(() => {
modal.close();
if (this.state.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_home_page'});
dis.dispatch({ action: 'view_home_page' });
}
}, (err) => {
modal.close();
@ -673,9 +684,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
{initialTabId: tabPayload.initialTabId},
{ initialTabId: tabPayload.initialTabId },
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
// View the welcome or home page if we need something to look at
@ -683,14 +693,15 @@ 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")
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
CreateGroupDialog = CreateCommunityPrototypeDialog;
}
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
const prototype = SettingsStore.getValue("feature_communities_v2_prototypes");
Modal.createTrackedDialog(
'Create Community',
'',
prototype ? CreateCommunityPrototypeDialog : CreateGroupDialog,
);
break;
}
case Action.ViewRoomDirectory: {
@ -700,7 +711,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
room_id: SpaceStore.instance.activeSpace.roomId,
});
} else {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
@ -740,12 +750,14 @@ 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) {
dis.dispatch({action: 'view_last_screen'});
dis.dispatch({ action: 'view_last_screen' });
} else {
dis.dispatch({action: 'view_my_groups'});
dis.dispatch({ action: 'view_my_groups' });
}
break;
case 'hide_left_panel':
@ -786,7 +798,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedOut();
break;
case 'will_start_client':
this.setState({ready: false}, () => {
this.setState({ ready: false }, () => {
// if the client is about to start, we are, by definition, not ready.
// Set ready to false now, then it'll be set to true when the sync
// listener we set below fires.
@ -906,6 +918,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;
@ -999,6 +1016,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setStateForNewView({
view: Views.LOGGED_IN,
justRegistered,
currentRoomId: null,
});
this.setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
@ -1017,12 +1035,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return;
}
this.notifyNewScreen('user/' + userId);
this.setState({currentUserId: userId});
this.setState({ currentUserId: userId });
this.setPage(PageTypes.UserView);
});
}
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
@ -1035,8 +1053,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,16 +1114,30 @@ 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 = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications.
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
const warnings = [];
const memberCount = roomToLeave.currentState.getJoinedMemberCount();
if (memberCount === 1) {
warnings.push((
<span className="warning" key="only_member_warning">
{ ' '/* Whitespace, otherwise the sentences get smashed together */ }
{ _t("You are the only person here. " +
"If you leave, no one will be able to join in the future, including you.") }
</span>
));
return warnings;
}
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
if (joinRules) {
const rule = joinRules.getContent().join_rule;
if (rule !== "public") {
warnings.push((
<span className="warning" key="non_public_warning">
{' '/* Whitespace, otherwise the sentences get smashed together */ }
{ ' '/* Whitespace, otherwise the sentences get smashed together */ }
{ isSpace
? _t("This space is not public. You will not be able to rejoin without an invite.")
: _t("This room is not public. You will not be able to rejoin without an invite.") }
@ -1115,18 +1149,23 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
private leaveRoom(roomId: string) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"),
description: (
<span>
{ isSpace
? _t("Are you sure you want to leave the space '%(spaceName)s'?", {spaceName: roomToLeave.name})
: _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
? _t(
"Are you sure you want to leave the space '%(spaceName)s'?",
{ spaceName: roomToLeave.name },
)
: _t(
"Are you sure you want to leave the room '%(roomName)s'?",
{ roomName: roomToLeave.name },
) }
{ warnings }
</span>
),
@ -1136,8 +1175,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const d = leaveRoomBehaviour(roomId);
// FIXME: controller shouldn't be loading a view :(
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
d.finally(() => modal.close());
dis.dispatch({
@ -1164,12 +1202,23 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}).catch((err) => {
const errCode = err.errcode || _td("unknown error code");
Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, {
title: _t("Failed to forget room %(errCode)s", {errCode}),
title: _t("Failed to forget room %(errCode)s", { errCode }),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
}
private async copyRoom(roomId: string) {
const roomLink = makeRoomPermalink(roomId);
const success = await copyPlaintext(roomLink);
if (!success) {
Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, {
title: _t("Unable to copy room link"),
description: _t("Unable to copy a link to the room to the clipboard."),
});
}
}
/**
* Starts a chat with the welcome user, if the user doesn't already have one
* @returns {string} The room ID of the new room, or null if no room was created
@ -1248,7 +1297,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (welcomeUserRoom === null) {
// We didn't redirect to the welcome user room, so show
// the homepage.
dis.dispatch({action: 'view_home_page', justRegistered: true});
dis.dispatch({ action: 'view_home_page', justRegistered: true });
}
} else if (ThreepidInviteStore.instance.pickBestInvite()) {
// The user has a 3pid invite pending - show them that
@ -1257,11 +1306,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// HACK: This is a pretty brutal way of threading the invite back through
// our systems, but it's the safest we have for now.
const params = ThreepidInviteStore.instance.translateToWireFormat(threepidInvite);
this.showScreen(`room/${threepidInvite.roomId}`, params)
this.showScreen(`room/${threepidInvite.roomId}`, params);
} else {
// The user has just logged in after registering,
// so show the homepage.
dis.dispatch({action: 'view_home_page', justRegistered: true});
dis.dispatch({ action: 'view_home_page', justRegistered: true });
}
} else {
this.showScreenAfterLogin();
@ -1297,9 +1346,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewLastRoom();
} else {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_welcome_page'});
dis.dispatch({ action: 'view_welcome_page' });
} else {
dis.dispatch({action: 'view_home_page'});
dis.dispatch({ action: 'view_home_page' });
}
}
}
@ -1379,15 +1428,15 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// So dispatch directly from here. Ideally we'd use a SyncStateStore that
// would do this dispatch and expose the sync state itself (by listening to
// its own dispatch).
dis.dispatch({action: 'sync_state', prevState, state});
dis.dispatch({ action: 'sync_state', prevState, state });
if (state === "ERROR" || state === "RECONNECTING") {
if (data.error instanceof InvalidStoreError) {
Lifecycle.handleInvalidStoreError(data.error);
}
this.setState({syncError: data.error || true});
this.setState({ syncError: data.error || true });
} else if (this.state.syncError) {
this.setState({syncError: null});
this.setState({ syncError: null });
}
this.updateStatusIndicator(state, prevState);
@ -1404,7 +1453,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
showNotificationsToast(false);
}
dis.fire(Action.FocusComposer);
dis.fire(Action.FocusSendMessageComposer);
this.setState({
ready: true,
});
@ -1432,7 +1481,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
});
cli.on('no_consent', function(message, consentUri) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('No Consent Dialog', '', QuestionDialog, {
title: _t('Terms and Conditions'),
description: <div>
@ -1455,7 +1503,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total));
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
@ -1541,8 +1589,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => {
const KeySignatureUploadFailedDialog =
sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog');
Modal.createTrackedDialog(
'Failed to upload key signatures',
'Failed to upload key signatures',
@ -1552,7 +1598,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
cli.on("crypto.verification.request", request => {
if (request.verifier) {
const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog");
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier: request.verifier,
}, null, /* priority = */ false, /* static = */ true);
@ -1561,16 +1606,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
key: 'verifreq_' + request.channel.transactionId,
title: _t("Verification requested"),
icon: "verification",
props: {request},
component: sdk.getComponent("toasts.VerificationRequestToast"),
props: { request },
component: VerificationRequestToast,
priority: 90,
});
}
});
// Fire the tinter right on startup to ensure the default theme is applied
// A later sync can/will correct the tint to be the right value for the user
const colorScheme = SettingsStore.getValue("roomColor");
Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color);
}
/**
@ -1611,11 +1652,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',
@ -1660,7 +1703,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// TODO if logged in, skip SSO
let cli = MatrixClientPeg.get();
if (!cli) {
const {hsUrl, isUrl} = this.props.serverConfig;
const { hsUrl, isUrl } = this.props.serverConfig;
cli = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
@ -1670,6 +1713,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 (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" });
return;
}
dis.dispatch({
action: 'view_my_groups',
});
@ -1753,6 +1800,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params.action,
});
} else if (screen.indexOf('group/') === 0) {
if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" });
return;
}
const groupId = screen.substring(6);
// TODO: Check valid group ID
@ -1775,7 +1827,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onAliasClick(event: MouseEvent, alias: string) {
event.preventDefault();
dis.dispatch({action: 'view_room', room_alias: alias});
dis.dispatch({ action: 'view_room', room_alias: alias });
}
onUserClick(event: MouseEvent, userId: string) {
@ -1791,7 +1843,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onGroupClick(event: MouseEvent, groupId: string) {
event.preventDefault();
dis.dispatch({action: 'view_group', group_id: groupId});
dis.dispatch({ action: 'view_group', group_id: groupId });
}
onLogoutClick(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
@ -1803,31 +1855,25 @@ 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() {
dis.dispatch({ action: 'timeline_resize' });
}
onRoomCreated(roomId: string) {
dis.dispatch({
action: "view_room",
room_id: roomId,
});
}
onRegisterClick = () => {
this.showScreen("register");
};
@ -1852,14 +1898,14 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get();
if (!cli) {
dis.dispatch({action: 'message_send_failed'});
dis.dispatch({ action: 'message_send_failed' });
return;
}
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
dis.dispatch({action: 'message_sent'});
dis.dispatch({ action: 'message_sent' });
}, (err) => {
dis.dispatch({action: 'message_send_failed'});
dis.dispatch({ action: 'message_send_failed' });
});
}
@ -1906,10 +1952,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
onServerConfigChange = (serverConfig: ValidatedServerConfig) => {
this.setState({serverConfig});
this.setState({ serverConfig });
};
private makeRegistrationUrl = (params: {[key: string]: string}) => {
private makeRegistrationUrl = (params: QueryDict) => {
if (this.props.startingFragmentQueryParams.referrer) {
params.referrer = this.props.startingFragmentQueryParams.referrer;
}
@ -1935,6 +1981,9 @@ 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
@ -1959,21 +2008,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
let view = null;
if (this.state.view === Views.LOADING) {
const Spinner = sdk.getComponent('elements.Spinner');
view = (
<div className="mx_MatrixChat_splash">
<Spinner />
</div>
);
} else if (this.state.view === Views.COMPLETE_SECURITY) {
const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity');
view = (
<CompleteSecurity
onFinished={this.onCompleteSecurityE2eSetupFinished}
/>
);
} else if (this.state.view === Views.E2E_SETUP) {
const E2eSetup = sdk.getComponent('structures.auth.E2eSetup');
view = (
<E2eSetup
onFinished={this.onCompleteSecurityE2eSetupFinished}
@ -1994,43 +2040,37 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* we should go through and figure out what we actually need to pass down, as well
* as using something like redux to avoid having a billion bits of state kicking around.
*/
const LoggedInView = sdk.getComponent('structures.LoggedInView');
view = (
<LoggedInView
{...this.props}
{...this.state}
ref={this.loggedInView}
matrixClient={MatrixClientPeg.get()}
onRoomCreated={this.onRoomCreated}
onCloseAllSettings={this.onCloseAllSettings}
onRegistered={this.onRegistered}
currentRoomId={this.state.currentRoomId}
/>
);
} else {
// we think we are logged in, but are still waiting for the /sync to complete
const Spinner = sdk.getComponent('elements.Spinner');
let errorBox;
if (this.state.syncError && !isStoreError) {
errorBox = <div className="mx_MatrixChat_syncError">
{messageForSyncError(this.state.syncError)}
{ messageForSyncError(this.state.syncError) }
</div>;
}
view = (
<div className="mx_MatrixChat_splash">
{errorBox}
{ errorBox }
<Spinner />
<a href="#" className="mx_MatrixChat_splashButtons" onClick={this.onLogoutClick}>
{_t('Logout')}
{ _t('Logout') }
</a>
</div>
);
}
} else if (this.state.view === Views.WELCOME) {
const Welcome = sdk.getComponent('auth.Welcome');
view = <Welcome />;
} else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) {
const Registration = sdk.getComponent('structures.auth.Registration');
const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail;
view = (
<Registration
@ -2049,7 +2089,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
/>
);
} else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) {
const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword');
view = (
<ForgotPassword
onComplete={this.onLoginClick}
@ -2060,7 +2099,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
} else if (this.state.view === Views.LOGIN) {
const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset);
const Login = sdk.getComponent('structures.auth.Login');
view = (
<Login
isSyncing={this.state.pendingInitialSync}
@ -2071,11 +2109,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin}
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string}
{...this.getServerProperties()}
/>
);
} else if (this.state.view === Views.SOFT_LOGOUT) {
const SoftLogout = sdk.getComponent('structures.auth.SoftLogout');
view = (
<SoftLogout
realQueryParams={this.props.realQueryParams}
@ -2087,9 +2125,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
console.error(`Unknown view ${this.state.view}`);
}
const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary');
return <ErrorBoundary>
{view}
{ view }
</ErrorBoundary>;
}
}

View file

@ -24,7 +24,8 @@ import dis from '../../dispatcher/dispatcher';
import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import BetaCard from "../views/beta/BetaCard";
@replaceableComponent("structures.MyGroups")
export default class MyGroups extends React.Component {
@ -40,19 +41,19 @@ export default class MyGroups extends React.Component {
}
_onCreateGroupClick = () => {
dis.dispatch({action: 'view_create_group'});
dis.dispatch({ action: 'view_create_group' });
};
_fetch() {
this.context.getJoinedGroups().then((result) => {
this.setState({groups: result.groups, error: null});
this.setState({ groups: result.groups, error: null });
}, (err) => {
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') {
// Indicate that the guest isn't in any groups (which should be true)
this.setState({groups: [], error: null});
this.setState({ groups: [], error: null });
return;
}
this.setState({groups: null, error: err});
this.setState({ groups: null, error: err });
});
}
@ -81,8 +82,7 @@ export default class MyGroups extends React.Component {
</p>
<p>
{ _t(
"To set up a filter, drag a community avatar over to the filter panel on " +
"the far left hand side of the screen. You can click on an avatar in the " +
"You can click on an avatar in the " +
"filter panel at any time to see only the rooms and people associated " +
"with that community.",
) }
@ -109,8 +109,7 @@ export default class MyGroups extends React.Component {
<SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} />
<div className='mx_MyGroups_header'>
<div className="mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
</AccessibleButton>
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick} />
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">
{ _t('Create a new community') }
@ -121,9 +120,9 @@ export default class MyGroups extends React.Component {
) }
</div>
</div>
{/*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
{ /*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="50" height="50" />
<img src={require("../../../res/img/icons-create-room.svg")} width="50" height="50" />
</AccessibleButton>
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">
@ -137,8 +136,9 @@ export default class MyGroups extends React.Component {
{ 'i': (sub) => <i>{ sub }</i> })
}
</div>
</div>*/}
</div>*/ }
</div>
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
<div className="mx_MyGroups_content">
{ contentHeader }
{ content }

View file

@ -18,13 +18,13 @@ import * as React from "react";
import { ComponentClass } from "../../@types/common";
import NonUrgentToastStore from "../../stores/NonUrgentToastStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
interface IProps {
}
interface IState {
toasts: ComponentClass[],
toasts: ComponentClass[];
}
@replaceableComponent("structures.NonUrgentToastContainer")
@ -44,21 +44,21 @@ export default class NonUrgentToastContainer extends React.PureComponent<IProps,
}
private onUpdateToasts = () => {
this.setState({toasts: NonUrgentToastStore.instance.components});
this.setState({ toasts: NonUrgentToastStore.instance.components });
};
public render() {
const toasts = this.state.toasts.map((t, i) => {
return (
<div className="mx_NonUrgentToastContainer_toast" key={`toast-${i}`}>
{React.createElement(t, {})}
{ React.createElement(t, {}) }
</div>
);
});
return (
<div className="mx_NonUrgentToastContainer" role="alert">
{toasts}
{ toasts }
</div>
);
}

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,50 +14,51 @@ 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";
import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout";
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>
<h2>{ _t('Youre all caught up') }</h2>
<p>{ _t('You have no visible notifications.') }</p>
</div>);
let content;
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
// wrap a TimelinePanel with the jump-to-event bits turned off.
content = (
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview={false}
tileShape="notif"
tileShape={TileShape.Notif}
empty={emptyState}
alwaysShowTimestamps={true}
layout={Layout.Group}
/>
);
} else {
console.error("No notifTimelineSet available!");
content = <Loader />;
content = <Spinner />;
}
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
@ -67,5 +66,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,108 @@ 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 { RoomState } from "matrix-js-sdk/src/models/room-state";
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 ThreadView from "./ThreadView";
import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { throttle } from 'lodash';
import SpaceStore from "../../stores/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { E2EStatus } from '../../utils/ShieldUtils';
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;
permalinkCreator?: RoomPermalinkCreator;
e2eStatus?: E2EStatus;
}
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 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.forceUpdate();
}, 500);
}
// Helper function to split out the logic for _getPhaseFromProps() and the constructor
private readonly delayedUpdate = throttle((): void => {
this.forceUpdate();
}, 500, { leading: true, trailing: true });
// 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});
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 (SpaceStore.spacesEnabled && 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 +147,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 +155,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
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line
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, state: RoomState, 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 +209,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 +239,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;
@ -263,7 +271,7 @@ export default class RightPanel extends React.Component {
case RightPanelPhases.EncryptionPanel:
panel = <UserInfo
user={this.state.member}
room={this.state.phase === RightPanelPhases.SpaceMemberInfo ? this.state.space : this.props.room}
room={this.context.getRoom(this.state.member.roomId) ?? this.props.room}
key={roomId || this.state.member.userId}
onClose={this.onClose}
phase={this.state.phase}
@ -282,6 +290,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,10 +305,33 @@ 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;
case RightPanelPhases.ThreadView:
panel = <ThreadView
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus} />;
break;
case RightPanelPhases.ThreadPanel:
panel = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose} />;
break;
case RightPanelPhases.RoomSummary:
panel = <RoomSummaryCard room={this.props.room} 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,72 @@ 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 { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
import { Visibility } from "matrix-js-sdk/src/@types/partials";
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
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 NetworkDropdown, { ALL_ROOMS, 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 ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads";
import { getDisplayAliasForAliasSet } from "../../Rooms";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
function track(action) {
const LAST_SERVER_KEY = "mx_last_room_directory_server";
const LAST_INSTANCE_KEY = "mx_last_room_directory_instance";
function track(action: string) {
Analytics.trackEvent('RoomDirectory', action);
}
interface IProps extends IDialogProps {
initialText?: string;
}
interface IState {
publicRooms: IPublicRoomsChunkRoom[];
loading: boolean;
protocolsLoading: boolean;
error?: string;
instanceId: string;
roomServer: string;
filterString: string;
selectedCommunityId?: string;
communityName?: string;
}
@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: number;
private protocols: Protocols;
constructor(props) {
super(props);
@ -56,41 +88,51 @@ 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});
const myHomeserver = MatrixClientPeg.getHomeserverName();
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
let roomServer = myHomeserver;
if (
SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) ||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
) {
roomServer = lsRoomServer;
}
let instanceId: string = null;
if (roomServer === myHomeserver && (
lsInstanceId === ALL_ROOMS ||
Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
)) {
instanceId = lsInstanceId;
}
// Refresh the room list only if validation failed and we had to change these
if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) {
this.setState({
protocolsLoading: false,
instanceId,
roomServer,
});
this.refreshRoomList();
return;
}
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 +145,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: localStorage.getItem(LAST_INSTANCE_KEY),
roomServer: localStorage.getItem(LAST_SERVER_KEY),
filterString: this.props.initialText || "",
selectedCommunityId,
communityName: null,
protocolsLoading,
};
}
componentDidMount() {
@ -126,10 +180,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,44 +219,44 @@ export default class RoomDirectory extends React.Component {
this.getMoreRooms();
};
getMoreRooms() {
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve();
private getMoreRooms(): Promise<boolean> {
if (this.state.selectedCommunityId) return Promise.resolve(false); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve(false);
this.setState({
loading: true,
});
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: IRoomDirectoryOptions = { 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;
return false;
}
if (this._unmounted) {
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
return false;
}
if (this.state.filterString) {
@ -211,25 +265,24 @@ 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) {
// as above: we don't care about errors for old
// requests either
return;
filterString != this.state.filterString ||
roomServer != this.state.roomServer ||
nextBatch != this.nextBatch) {
// as above: we don't care about errors for old requests either
return false;
}
if (this._unmounted) {
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return;
return false;
}
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
@ -252,31 +305,27 @@ 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: IPublicRoomsChunkRoom) {
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});
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', { alias, name });
} else {
desc = _t('Remove %(name)s from the directory?', {name: name});
desc = _t('Remove %(name)s from the directory?', { name: name });
}
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);
let step = _t('remove %(name)s from the directory.', {name: name});
const modal = Modal.createDialog(Spinner);
let step = _t('remove %(name)s from the directory.', { name: name });
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => {
if (!alias) return;
step = _t('delete the address.');
return MatrixClientPeg.get().deleteAlias(alias);
@ -289,23 +338,24 @@ 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: IPublicRoomsChunkRoom, ev: React.MouseEvent) => {
// If room was shift-clicked, remove it from the room directory
if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault();
this.removeFromDirectory(room);
} else {
this.showRoom(room);
}
};
onOptionChange = (server, instanceId) => {
private onOptionChange = (server: string, instanceId?: string) => {
// clear next batch so we don't try to load more rooms
this.nextBatch = null;
this.setState({
@ -323,17 +373,25 @@ export default class RoomDirectory extends React.Component {
// find the five gitter ones, at which point we do not want
// to render all those rooms when switching back to 'all networks'.
// Easiest to just blow away the state & re-fetch.
// We have to be careful here so that we don't set instanceId = "undefined"
localStorage.setItem(LAST_SERVER_KEY, server);
if (instanceId) {
localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
} else {
localStorage.removeItem(LAST_INSTANCE_KEY);
}
};
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,
filterString: alias || "",
});
// don't send the request for a little bit,
@ -349,10 +407,10 @@ export default class RoomDirectory extends React.Component {
}, 700);
};
onFilterClear = () => {
private onFilterClear = () => {
// update immediately
this.setState({
filterString: null,
filterString: "",
}, this.refreshRoomList);
if (this.filterTimeout) {
@ -360,7 +418,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 +431,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 +446,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 +460,37 @@ export default class RoomDirectory extends React.Component {
}
};
onPreviewClick = (ev, room) => {
private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
this.showRoom(room, null, false, true);
ev.stopPropagation();
};
onViewClick = (ev, room) => {
private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
this.showRoom(room);
ev.stopPropagation();
};
onJoinClick = (ev, room) => {
private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
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: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished();
const payload = {
const payload: ActionPayload = {
action: 'view_room',
auto_join: autoJoin,
should_peek: shouldPeek,
@ -444,20 +502,20 @@ export default class RoomDirectory extends React.Component {
// to the directory.
if (MatrixClientPeg.get().isGuest()) {
if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({action: 'require_registration'});
dis.dispatch({ action: 'require_registration' });
return;
}
}
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 +529,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: IPublicRoomsChunkRoom) {
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 +551,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)}...`;
}
@ -524,72 +586,80 @@ export default class RoomDirectory extends React.Component {
let avatarUrl = null;
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
return [
<div key={ `${room.room_id}_avatar` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={`${room.room_id}_avatar`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomAvatar"
>
<BaseAvatar 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` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={`${room.room_id}_description`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomDescription"
>
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
<div
className="mx_RoomDirectory_name"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ name }
</div>&nbsp;
<div
className="mx_RoomDirectory_topic"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
dangerouslySetInnerHTML={{ __html: topic }}
/>
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
<div
className="mx_RoomDirectory_alias"
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
>
{ getDisplayAliasForRoom(room) }
</div>
</div>,
<div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={`${room.room_id}_memberCount`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomMemberCount"
>
{ room.num_joined_members }
</div>,
<div key={ `${room.room_id}_preview` }
onClick={(ev) => this.onRoomClicked(room, ev)}
<div
key={`${room.room_id}_preview`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_preview"
>
{previewButton}
{ previewButton }
</div>,
<div key={ `${room.room_id}_join` }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
<div
key={`${room.room_id}_join`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_join"
>
{joinOrViewButton}
{ joinOrViewButton }
</div>,
];
}
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 +675,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 +749,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;
}
}
@ -718,17 +796,16 @@ export default class RoomDirectory extends React.Component {
showJoinButton={showJoinButton}
initialText={this.props.initialText}
/>
{dropdown}
{ dropdown }
</div>;
}
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
@ -737,16 +814,16 @@ export default class RoomDirectory extends React.Component {
}) : _t("Explore rooms");
return (
<BaseDialog
className={'mx_RoomDirectory_dialog'}
className="mx_RoomDirectory_dialog"
hasCancel={true}
onFinished={this.onFinished}
title={title}
>
<div className="mx_RoomDirectory">
{explanation}
{ explanation }
<div className="mx_RoomDirectory_list">
{listHeader}
{content}
{ listHeader }
{ content }
</div>
</div>
</BaseDialog>
@ -756,6 +833,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] : "");
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
}

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();
@ -96,22 +108,22 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
};
private openSearch = () => {
defaultDispatcher.dispatch({action: "show_left_panel"});
defaultDispatcher.dispatch({action: "focus_room_filter"});
defaultDispatcher.dispatch({ action: "show_left_panel" });
defaultDispatcher.dispatch({ action: "focus_room_filter" });
};
private onChange = () => {
if (!this.inputRef.current) return;
this.setState({query: this.inputRef.current.value});
this.setState({ query: this.inputRef.current.value });
};
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
this.setState({focused: true});
this.setState({ focused: true });
ev.target.select();
};
private onBlur = (ev: React.FocusEvent<HTMLInputElement>) => {
this.setState({focused: false});
this.setState({ focused: false });
};
private onKeyDown = (ev: React.KeyboardEvent) => {
@ -119,7 +131,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
switch (action) {
case RoomListAction.ClearSearch:
this.clearInput();
defaultDispatcher.fire(Action.FocusComposer);
defaultDispatcher.fire(Action.FocusSendMessageComposer);
break;
case RoomListAction.NextRoom:
case RoomListAction.PrevRoom:
@ -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"
/>
);
@ -192,9 +209,9 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
return (
<div className={classes}>
{icon}
{input}
{clearButton}
{ icon }
{ input }
{ clearButton }
</div>
);
}

View file

@ -1,5 +1,5 @@
/*
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.
@ -17,19 +17,23 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../languageHandler';
import {MatrixClientPeg} from '../../MatrixClientPeg';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {EventStatus} from "matrix-js-sdk/src/models/event";
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
import { Action } from "../../dispatcher/actions";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { EventStatus } from "matrix-js-sdk/src/models/event";
import NotificationBadge from "../views/rooms/NotificationBadge";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner";
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
function getUnsentMessages(room) {
export function getUnsentMessages(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
@ -37,7 +41,7 @@ function getUnsentMessages(room) {
}
@replaceableComponent("structures.RoomStatusBar")
export default class RoomStatusBar extends React.Component {
export default class RoomStatusBar extends React.PureComponent {
static propTypes = {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
@ -76,6 +80,7 @@ export default class RoomStatusBar extends React.Component {
syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
unsentMessages: getUnsentMessages(this.props.room),
isResending: false,
};
componentDidMount() {
@ -109,20 +114,24 @@ export default class RoomStatusBar extends React.Component {
};
_onResendAllClick = () => {
Resend.resendUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer);
Resend.resendUnsentEvents(this.props.room).then(() => {
this.setState({ isResending: false });
});
this.setState({ isResending: true });
dis.fire(Action.FocusSendMessageComposer);
};
_onCancelAllClick = () => {
Resend.cancelUnsentEvents(this.props.room);
dis.fire(Action.FocusComposer);
dis.fire(Action.FocusSendMessageComposer);
};
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
if (room.roomId !== this.props.room.roomId) return;
const messages = getUnsentMessages(this.props.room);
this.setState({
unsentMessages: getUnsentMessages(this.props.room),
unsentMessages: messages,
isResending: messages.length > 0 && this.state.isResending,
});
};
@ -141,7 +150,7 @@ export default class RoomStatusBar extends React.Component {
_getSize() {
if (this._shouldShowConnectionError()) {
return STATUS_BAR_EXPANDED;
} else if (this.state.unsentMessages.length > 0) {
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return STATUS_BAR_EXPANDED_LARGE;
}
return STATUS_BAR_HIDDEN;
@ -162,7 +171,6 @@ export default class RoomStatusBar extends React.Component {
_getUnsentMessageContent() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
let title;
@ -192,89 +200,96 @@ 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.",
),
});
} else if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error
) {
title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error;
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('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
title = _t('Some of your messages have not been sent');
}
const content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> " +
"now. You can also select individual messages to resend or cancel.",
{ count: unsentMessages.length },
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
let buttonRow = <>
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{ _t("Delete all") }
</AccessibleButton>
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
{ _t("Retry all") }
</AccessibleButton>
</>;
if (this.state.isResending) {
buttonRow = <>
<InlineSpinner w={20} h={20} />
{ /* span for css */ }
<span>{ _t("Sending") }</span>
</>;
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
return <>
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ title }
</div>
<div className="mx_RoomStatusBar_unsentDescription">
{ _t("You can select all or individual messages to retry or delete") }
</div>
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{ buttonRow }
</div>
</div>
</div>
</div>;
</>;
}
// return suitable content for the main (text) part of the status bar.
_getContent() {
render() {
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
<div className="mx_RoomStatusBar">
<div role="alert">
<div className="mx_RoomStatusBar_connectionLostBar">
<img
src={require("../../../res/img/feather-customised/warning-triangle.svg")}
width="24"
height="24"
title="/!\ "
alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
</div>
</div>
</div>
</div>
</div>
);
}
if (this.state.unsentMessages.length > 0) {
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
return this._getUnsentMessageContent();
}
return null;
}
render() {
const content = this._getContent();
return (
<div className="mx_RoomStatusBar">
<div role="alert">
{ content }
</div>
</div>
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from "react";
import PropTypes from 'prop-types';
import React, { createRef, CSSProperties, ReactNode, SyntheticEvent, KeyboardEvent } from "react";
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {getKeyBindingsManager, RoomAction} from "../../KeyBindingsManager";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager";
import ResizeNotifier from "../../utils/ResizeNotifier";
const DEBUG_SCROLL = false;
// The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight.
// See getExcessHeight.
const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
@ -43,6 +43,75 @@ if (DEBUG_SCROLL) {
debuglog = function() {};
}
interface IProps {
/* stickyBottom: if set to true, then once the user hits the bottom of
* the list, any new children added to the list will cause the list to
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom?: boolean;
/* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom.
* XXX: It's likely this is unnecessary and can be derived from
* stickyBottom, but I'm adding an extra parameter to ensure
* behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line.
*/
startAtBottom?: boolean;
/* className: classnames to add to the top-level div
*/
className?: string;
/* style: styles to add to the top-level div
*/
style?: CSSProperties;
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier?: ResizeNotifier;
/* fixedChildren: allows for children to be passed which are rendered outside
* of the wrapper
*/
fixedChildren?: ReactNode;
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
* false) of the list.
*
* This should return a promise; no more calls will be made until the
* promise completes.
*
* The promise should resolve to true if there is more data to be
* retrieved in this direction (in which case onFillRequest may be
* called again immediately), or false if there is no more data in this
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest?(backwards: boolean): Promise<boolean>;
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
* without causing pagination to occur.
*
* This function should accept a boolean, which is true to indicate the back/top
* of the panel and false otherwise, and a scroll token, which refers to the
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll?(event: Event): void;
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll?(event: SyntheticEvent): void;
}
/* This component implements an intelligent scrolling list.
*
* It wraps a list of <li> children; when items are added to the start or end
@ -84,93 +153,60 @@ if (DEBUG_SCROLL) {
* offset as normal.
*/
export interface IScrollState {
stuckAtBottom: boolean;
trackedNode?: HTMLElement;
trackedScrollToken?: string;
bottomOffset?: number;
pixelOffset?: number;
}
interface IPreventShrinkingState {
offsetFromBottom: number;
offsetNode: HTMLElement;
}
@replaceableComponent("structures.ScrollPanel")
export default class ScrollPanel extends React.Component {
static propTypes = {
/* stickyBottom: if set to true, then once the user hits the bottom of
* the list, any new children added to the list will cause the list to
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom: PropTypes.bool,
/* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom.
* XXX: It's likely this is unnecessary and can be derived from
* stickyBottom, but I'm adding an extra parameter to ensure
* behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line.
*/
startAtBottom: PropTypes.bool,
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
* false) of the list.
*
* This should return a promise; no more calls will be made until the
* promise completes.
*
* The promise should resolve to true if there is more data to be
* retrieved in this direction (in which case onFillRequest may be
* called again immediately), or false if there is no more data in this
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest: PropTypes.func,
/* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed
* without causing pagination to occur.
*
* This function should accept a boolean, which is true to indicate the back/top
* of the panel and false otherwise, and a scroll token, which refers to the
* first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top.
*/
onUnfillRequest: PropTypes.func,
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll: PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: PropTypes.string,
/* style: styles to add to the top-level div
*/
style: PropTypes.object,
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier: PropTypes.object,
/* fixedChildren: allows for children to be passed which are rendered outside
* of the wrapper
*/
fixedChildren: PropTypes.node,
};
export default class ScrollPanel extends React.Component<IProps> {
static defaultProps = {
stickyBottom: true,
startAtBottom: true,
onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onFillRequest: function(backwards: boolean) { return Promise.resolve(false); },
onUnfillRequest: function(backwards: boolean, scrollToken: string) {},
onScroll: function() {},
};
constructor(props) {
super(props);
private readonly pendingFillRequests: Record<"b" | "f", boolean> = {
b: null,
f: null,
};
private readonly itemlist = createRef<HTMLOListElement>();
private unmounted = false;
private scrollTimeout: Timer;
// Are we currently trying to backfill?
private isFilling: boolean;
// Is the current fill request caused by a props update?
private isFillingDueToPropsUpdate = false;
// Did another request to check the fill state arrive while we were trying to backfill?
private fillRequestWhileRunning: boolean;
// Is that next fill request scheduled because of a props update?
private pendingFillDueToPropsUpdate: boolean;
private scrollState: IScrollState;
private preventShrinkingState: IPreventShrinkingState;
private unfillDebouncer: number;
private bottomGrowth: number;
private pages: number;
private heightUpdateInProgress: boolean;
private divScroll: HTMLDivElement;
this._pendingFillRequests = {b: null, f: null};
constructor(props, context) {
super(props, context);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
}
this.resetScrollState();
this._itemlist = createRef();
}
componentDidMount() {
@ -183,7 +219,7 @@ export default class ScrollPanel extends React.Component {
// adding events to the top).
//
// This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll();
this.checkScroll(true);
this.updatePreventShrinking();
}
@ -199,18 +235,18 @@ export default class ScrollPanel extends React.Component {
}
}
onScroll = ev => {
private onScroll = ev => {
// skip scroll events caused by resizing
if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
debuglog("onScroll", this._getScrollNode().scrollTop);
this._scrollTimeout.restart();
this._saveScrollState();
debuglog("onScroll", this.getScrollNode().scrollTop);
this.scrollTimeout.restart();
this.saveScrollState();
this.updatePreventShrinking();
this.props.onScroll(ev);
this.checkFillState();
};
onResize = () => {
private onResize = () => {
debuglog("onResize");
this.checkScroll();
// update preventShrinkingState if present
@ -221,12 +257,12 @@ export default class ScrollPanel extends React.Component {
// after an update to the contents of the panel, check that the scroll is
// where it ought to be, and set off pagination requests if necessary.
checkScroll = () => {
public checkScroll = (isFromPropsUpdate = false) => {
if (this.unmounted) {
return;
}
this._restoreSavedScrollState();
this.checkFillState();
this.restoreSavedScrollState();
this.checkFillState(0, isFromPropsUpdate);
};
// return true if the content is fully scrolled down right now; else false.
@ -234,8 +270,8 @@ export default class ScrollPanel extends React.Component {
// note that this is independent of the 'stuckAtBottom' state - it is simply
// about whether the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update.
isAtBottom = () => {
const sn = this._getScrollNode();
public isAtBottom = () => {
const sn = this.getScrollNode();
// fractional values (both too big and too small)
// for scrollTop happen on certain browsers/platforms
// when scrolled all the way down. E.g. Chrome 72 on debian.
@ -274,10 +310,10 @@ export default class ScrollPanel extends React.Component {
// |#########| - |
// |#########| |
// `---------' -
_getExcessHeight(backwards) {
const sn = this._getScrollNode();
const contentHeight = this._getMessagesHeight();
const listHeight = this._getListHeight();
private getExcessHeight(backwards: boolean): number {
const sn = this.getScrollNode();
const contentHeight = this.getMessagesHeight();
const listHeight = this.getListHeight();
const clippedHeight = contentHeight - listHeight;
const unclippedScrollTop = sn.scrollTop + clippedHeight;
@ -289,13 +325,13 @@ export default class ScrollPanel extends React.Component {
}
// check the scroll state and send out backfill requests if necessary.
checkFillState = async (depth=0) => {
public checkFillState = async (depth = 0, isFromPropsUpdate = false): Promise<void> => {
if (this.unmounted) {
return;
}
const isFirstCall = depth === 0;
const sn = this._getScrollNode();
const sn = this.getScrollNode();
// if there is less than a screenful of messages above or below the
// viewport, try to get some more messages.
@ -325,18 +361,24 @@ export default class ScrollPanel extends React.Component {
// don't allow more than 1 chain of calls concurrently
// do make a note when a new request comes in while already running one,
// so we can trigger a new chain of calls once done.
// However, we make an exception for when we're already filling due to a
// props (or children) update, because very often the children include
// spinners to say whether we're paginating or not, so this would cause
// infinite paginating.
if (isFirstCall) {
if (this._isFilling) {
debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request");
this._fillRequestWhileRunning = true;
if (this.isFilling && !this.isFillingDueToPropsUpdate) {
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
this.fillRequestWhileRunning = true;
this.pendingFillDueToPropsUpdate = isFromPropsUpdate;
return;
}
debuglog("_isFilling: setting");
this._isFilling = true;
debuglog("isFilling: setting");
this.isFilling = true;
this.isFillingDueToPropsUpdate = isFromPropsUpdate;
}
const itemlist = this._itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild;
const itemlist = this.itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = [];
@ -344,13 +386,13 @@ export default class ScrollPanel extends React.Component {
// try backward filling
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
// need to back-fill
fillPromises.push(this._maybeFill(depth, true));
fillPromises.push(this.maybeFill(depth, true));
}
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
// try forward filling
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
// need to forward-fill
fillPromises.push(this._maybeFill(depth, false));
fillPromises.push(this.maybeFill(depth, false));
}
if (fillPromises.length) {
@ -361,26 +403,29 @@ export default class ScrollPanel extends React.Component {
}
}
if (isFirstCall) {
debuglog("_isFilling: clearing");
this._isFilling = false;
debuglog("isFilling: clearing");
this.isFilling = false;
this.isFillingDueToPropsUpdate = false;
}
if (this._fillRequestWhileRunning) {
this._fillRequestWhileRunning = false;
this.checkFillState();
if (this.fillRequestWhileRunning) {
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
this.fillRequestWhileRunning = false;
this.pendingFillDueToPropsUpdate = false;
this.checkFillState(0, refillDueToPropsUpdate);
}
};
// check if unfilling is possible and send an unfill request if necessary
_checkUnfillState(backwards) {
let excessHeight = this._getExcessHeight(backwards);
private checkUnfillState(backwards: boolean): void {
let excessHeight = this.getExcessHeight(backwards);
if (excessHeight <= 0) {
return;
}
const origExcessHeight = excessHeight;
const tiles = this._itemlist.current.children;
const tiles = this.itemlist.current.children;
// The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null;
@ -409,11 +454,11 @@ export default class ScrollPanel extends React.Component {
if (markerScrollToken) {
// Use a debouncer to prevent multiple unfill calls in quick succession
// This is to make the unfilling process less aggressive
if (this._unfillDebouncer) {
clearTimeout(this._unfillDebouncer);
if (this.unfillDebouncer) {
clearTimeout(this.unfillDebouncer);
}
this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null;
this.unfillDebouncer = setTimeout(() => {
this.unfillDebouncer = null;
debuglog("unfilling now", backwards, origExcessHeight);
this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS);
@ -421,9 +466,9 @@ export default class ScrollPanel extends React.Component {
}
// check if there is already a pending fill request. If not, set one off.
_maybeFill(depth, backwards) {
private maybeFill(depth: number, backwards: boolean): Promise<void> {
const dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) {
if (this.pendingFillRequests[dir]) {
debuglog("Already a "+dir+" fill in progress - not starting another");
return;
}
@ -432,7 +477,7 @@ export default class ScrollPanel extends React.Component {
// onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call.
this._pendingFillRequests[dir] = true;
this.pendingFillRequests[dir] = true;
// wait 1ms before paginating, because otherwise
// this will block the scroll event handler for +700ms
@ -441,13 +486,13 @@ export default class ScrollPanel extends React.Component {
return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
return this.props.onFillRequest(backwards);
}).finally(() => {
this._pendingFillRequests[dir] = false;
this.pendingFillRequests[dir] = false;
}).then((hasMoreResults) => {
if (this.unmounted) {
return;
}
// Unpaginate once filling is complete
this._checkUnfillState(!backwards);
this.checkUnfillState(!backwards);
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) {
@ -473,7 +518,7 @@ export default class ScrollPanel extends React.Component {
* the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel.
*/
getScrollState = () => this.scrollState;
public getScrollState = (): IScrollState => this.scrollState;
/* reset the saved scroll state.
*
@ -487,35 +532,35 @@ export default class ScrollPanel extends React.Component {
* no use if no children exist yet, or if you are about to replace the
* child list.)
*/
resetScrollState = () => {
public resetScrollState = (): void => {
this.scrollState = {
stuckAtBottom: this.props.startAtBottom,
};
this._bottomGrowth = 0;
this._pages = 0;
this._scrollTimeout = new Timer(100);
this._heightUpdateInProgress = false;
this.bottomGrowth = 0;
this.pages = 0;
this.scrollTimeout = new Timer(100);
this.heightUpdateInProgress = false;
};
/**
* jump to the top of the content.
*/
scrollToTop = () => {
this._getScrollNode().scrollTop = 0;
this._saveScrollState();
public scrollToTop = (): void => {
this.getScrollNode().scrollTop = 0;
this.saveScrollState();
};
/**
* jump to the bottom of the content.
*/
scrollToBottom = () => {
public scrollToBottom = (): void => {
// the easiest way to make sure that the scroll state is correctly
// saved is to do the scroll, then save the updated state. (Calculating
// it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here).
const sn = this._getScrollNode();
const sn = this.getScrollNode();
sn.scrollTop = sn.scrollHeight;
this._saveScrollState();
this.saveScrollState();
};
/**
@ -523,33 +568,41 @@ export default class ScrollPanel extends React.Component {
*
* @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative = mult => {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
public scrollRelative = (mult: number): void => {
const scrollNode = this.getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta);
this._saveScrollState();
this.saveScrollState();
};
/**
* Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event
*/
handleScrollKey = ev => {
public handleScrollKey = (ev: KeyboardEvent) => {
let isScrolling = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
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
@ -563,17 +616,17 @@ export default class ScrollPanel extends React.Component {
* node (specifically, the bottom of it) will be positioned. If omitted, it
* defaults to 0.
*/
scrollToToken = (scrollToken, pixelOffset, offsetBase) => {
public scrollToToken = (scrollToken: string, pixelOffset: number, offsetBase: number): void => {
pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0;
// set the trackedScrollToken so we can get the node through _getTrackedNode
// set the trackedScrollToken so we can get the node through getTrackedNode
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: scrollToken,
};
const trackedNode = this._getTrackedNode();
const scrollNode = this._getScrollNode();
const trackedNode = this.getTrackedNode();
const scrollNode = this.getScrollNode();
if (trackedNode) {
// set the scrollTop to the position we want.
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
@ -581,36 +634,36 @@ export default class ScrollPanel extends React.Component {
// This because when setting the scrollTop only 10 or so events might be loaded,
// not giving enough content below the trackedNode to scroll downwards
// enough so it ends up in the top of the viewport.
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
debuglog("scrollToken: setting scrollTop", { offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop });
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
this._saveScrollState();
this.saveScrollState();
}
};
_saveScrollState() {
private saveScrollState(): void {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
debuglog("saved stuckAtBottom state");
return;
}
const scrollNode = this._getScrollNode();
const scrollNode = this.getScrollNode();
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this._itemlist.current;
const itemlist = this.itemlist.current;
const messages = itemlist.children;
let node = null;
// TODO: do a binary search here, as items are sorted by offsetTop
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length-1; i >= 0; --i) {
if (!messages[i].dataset.scrollTokens) {
for (let i = messages.length - 1; i >= 0; --i) {
if (!(messages[i] as HTMLElement).dataset.scrollTokens) {
continue;
}
node = messages[i];
// break at the first message (coming from the bottom)
// that has it's offsetTop above the bottom of the viewport.
if (this._topFromBottom(node) > viewportBottom) {
if (this.topFromBottom(node) > viewportBottom) {
// Use this node as the scrollToken
break;
}
@ -622,7 +675,7 @@ export default class ScrollPanel extends React.Component {
}
const scrollToken = node.dataset.scrollTokens.split(',')[0];
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
const bottomOffset = this._topFromBottom(node);
const bottomOffset = this.topFromBottom(node);
this.scrollState = {
stuckAtBottom: false,
trackedNode: node,
@ -632,35 +685,35 @@ export default class ScrollPanel extends React.Component {
};
}
async _restoreSavedScrollState() {
private async restoreSavedScrollState(): Promise<void> {
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
const sn = this._getScrollNode();
const sn = this.getScrollNode();
if (sn.scrollTop !== sn.scrollHeight) {
sn.scrollTop = sn.scrollHeight;
}
} else if (scrollState.trackedScrollToken) {
const itemlist = this._itemlist.current;
const trackedNode = this._getTrackedNode();
const itemlist = this.itemlist.current;
const trackedNode = this.getTrackedNode();
if (trackedNode) {
const newBottomOffset = this._topFromBottom(trackedNode);
const newBottomOffset = this.topFromBottom(trackedNode);
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
this._bottomGrowth += bottomDiff;
this.bottomGrowth += bottomDiff;
scrollState.bottomOffset = newBottomOffset;
const newHeight = `${this._getListHeight()}px`;
const newHeight = `${this.getListHeight()}px`;
if (itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight;
}
debuglog("balancing height because messages below viewport grew by", bottomDiff);
}
}
if (!this._heightUpdateInProgress) {
this._heightUpdateInProgress = true;
if (!this.heightUpdateInProgress) {
this.heightUpdateInProgress = true;
try {
await this._updateHeight();
await this.updateHeight();
} finally {
this._heightUpdateInProgress = false;
this.heightUpdateInProgress = false;
}
} else {
debuglog("not updating height because request already in progress");
@ -668,11 +721,11 @@ export default class ScrollPanel extends React.Component {
}
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
async _updateHeight() {
private async updateHeight(): Promise<void> {
// wait until user has stopped scrolling
if (this._scrollTimeout.isRunning()) {
if (this.scrollTimeout.isRunning()) {
debuglog("updateHeight waiting for scrolling to end ... ");
await this._scrollTimeout.finished();
await this.scrollTimeout.finished();
} else {
debuglog("updateHeight getting straight to business, no scrolling going on.");
}
@ -682,14 +735,14 @@ export default class ScrollPanel extends React.Component {
return;
}
const sn = this._getScrollNode();
const itemlist = this._itemlist.current;
const contentHeight = this._getMessagesHeight();
const sn = this.getScrollNode();
const itemlist = this.itemlist.current;
const contentHeight = this.getMessagesHeight();
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
this._pages = Math.ceil(height / PAGE_SIZE);
this._bottomGrowth = 0;
const newHeight = `${this._getListHeight()}px`;
this.pages = Math.ceil(height / PAGE_SIZE);
this.bottomGrowth = 0;
const newHeight = `${this.getListHeight()}px`;
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
@ -701,7 +754,7 @@ export default class ScrollPanel extends React.Component {
}
debuglog("updateHeight to", newHeight);
} else if (scrollState.trackedScrollToken) {
const trackedNode = this._getTrackedNode();
const trackedNode = this.getTrackedNode();
// if the timeline has been reloaded
// this can be called before scrollToBottom or whatever has been called
// so don't do anything if the node has disappeared from
@ -718,22 +771,22 @@ export default class ScrollPanel extends React.Component {
// yield out of date values and cause a jump
// when setting it
sn.scrollBy(0, topDiff);
debuglog("updateHeight to", {newHeight, topDiff});
debuglog("updateHeight to", { newHeight, topDiff });
}
}
}
_getTrackedNode() {
private getTrackedNode(): HTMLElement {
const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode;
if (!trackedNode || !trackedNode.parentElement) {
let node;
const messages = this._itemlist.current.children;
const messages = this.itemlist.current.children;
const scrollToken = scrollState.trackedScrollToken;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
const m = messages[i] as HTMLElement;
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
@ -756,45 +809,45 @@ export default class ScrollPanel extends React.Component {
return scrollState.trackedNode;
}
_getListHeight() {
return this._bottomGrowth + (this._pages * PAGE_SIZE);
private getListHeight(): number {
return this.bottomGrowth + (this.pages * PAGE_SIZE);
}
_getMessagesHeight() {
const itemlist = this._itemlist.current;
const lastNode = itemlist.lastElementChild;
private getMessagesHeight(): number {
const itemlist = this.itemlist.current;
const lastNode = itemlist.lastElementChild as HTMLElement;
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
const firstNodeTop = itemlist.firstElementChild ? (itemlist.firstElementChild as HTMLElement).offsetTop : 0;
// 18 is itemlist padding
return lastNodeBottom - firstNodeTop + (18 * 2);
}
_topFromBottom(node) {
private topFromBottom(node: HTMLElement): number {
// current capped height - distance from top = distance from bottom of container to top of tracked element
return this._itemlist.current.clientHeight - node.offsetTop;
return this.itemlist.current.clientHeight - node.offsetTop;
}
/* get the DOM node which has the scrollTop property we care about for our
* message panel.
*/
_getScrollNode() {
private getScrollNode(): HTMLDivElement {
if (this.unmounted) {
// this shouldn't happen, but when it does, turn the NPE into
// something more meaningful.
throw new Error("ScrollPanel._getScrollNode called when unmounted");
throw new Error("ScrollPanel.getScrollNode called when unmounted");
}
if (!this._divScroll) {
if (!this.divScroll) {
// Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before AutoHideScrollbar ref collected");
throw new Error("ScrollPanel.getScrollNode called before AutoHideScrollbar ref collected");
}
return this._divScroll;
return this.divScroll;
}
_collectScroll = divScroll => {
this._divScroll = divScroll;
private collectScroll = (divScroll: HTMLDivElement) => {
this.divScroll = divScroll;
};
/**
@ -802,15 +855,15 @@ export default class ScrollPanel extends React.Component {
anything below it changes, by calling updatePreventShrinking, to keep
the same minimum bottom offset, effectively preventing the timeline to shrink.
*/
preventShrinking = () => {
const messageList = this._itemlist.current;
public preventShrinking = (): void => {
const messageList = this.itemlist.current;
const tiles = messageList && messageList.children;
if (!messageList) {
return;
}
let lastTileNode;
for (let i = tiles.length - 1; i >= 0; i--) {
const node = tiles[i];
const node = tiles[i] as HTMLElement;
if (node.dataset.scrollTokens) {
lastTileNode = node;
break;
@ -829,8 +882,8 @@ export default class ScrollPanel extends React.Component {
};
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
clearPreventShrinking = () => {
const messageList = this._itemlist.current;
public clearPreventShrinking = (): void => {
const messageList = this.itemlist.current;
const balanceElement = messageList && messageList.parentElement;
if (balanceElement) balanceElement.style.paddingBottom = null;
this.preventShrinkingState = null;
@ -845,12 +898,12 @@ export default class ScrollPanel extends React.Component {
from the bottom of the marked tile grows larger than
what it was when marking.
*/
updatePreventShrinking = () => {
public updatePreventShrinking = (): void => {
if (this.preventShrinkingState) {
const sn = this._getScrollNode();
const sn = this.getScrollNode();
const scrollState = this.scrollState;
const messageList = this._itemlist.current;
const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
const messageList = this.itemlist.current;
const { offsetNode, offsetFromBottom } = this.preventShrinkingState;
// element used to set paddingBottom to balance the typing notifs disappearing
const balanceElement = messageList.parentElement;
// if the offsetNode got unmounted, clear
@ -884,16 +937,21 @@ 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}
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>
);
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>
);
}
}

View file

@ -15,14 +15,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { Key } from '../../Keyboard';
import dis from '../../dispatcher/dispatcher';
import {throttle} from 'lodash';
import { throttle } from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import classNames from 'classnames';
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
@replaceableComponent("structures.SearchBox")
export default class SearchBox extends React.Component {
@ -89,7 +89,7 @@ export default class SearchBox extends React.Component {
onSearch = throttle(() => {
this.props.onSearch(this._search.current.value);
}, 200, {trailing: true, leading: true});
}, 200, { trailing: true, leading: true });
_onKeyDown = ev => {
switch (ev.key) {
@ -101,7 +101,7 @@ export default class SearchBox extends React.Component {
};
_onFocus = ev => {
this.setState({blurred: false});
this.setState({ blurred: false });
ev.target.select();
if (this.props.onFocus) {
this.props.onFocus(ev);
@ -109,7 +109,7 @@ export default class SearchBox extends React.Component {
};
_onBlur = ev => {
this.setState({blurred: true});
this.setState({ blurred: true });
if (this.props.onBlur) {
this.props.onBlur(ev);
}
@ -136,8 +136,8 @@ export default class SearchBox extends React.Component {
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
onClick={() => {this._clearSearch("button"); }}
/>) : undefined;
// show a shorter placeholder when blurred, if requested
// this is used for the room filter field that has
@ -147,18 +147,18 @@ export default class SearchBox extends React.Component {
this.props.placeholder;
const className = this.props.className || "";
return (
<div className={classNames("mx_SearchBox", "mx_textinput", {"mx_SearchBox_blurred": this.state.blurred})}>
<div className={classNames("mx_SearchBox", "mx_textinput", { "mx_SearchBox_blurred": this.state.blurred })}>
<input
key="searchfield"
type="text"
ref={this._search}
className={"mx_textinput_icon mx_textinput_search " + className}
value={ this.state.searchTerm }
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
value={this.state.searchTerm}
onFocus={this._onFocus}
onChange={this.onChange}
onKeyDown={this._onKeyDown}
onBlur={this._onBlur}
placeholder={ placeholder }
placeholder={placeholder}
autoComplete="off"
autoFocus={this.props.autoFocus}
/>

View file

@ -0,0 +1,717 @@
/*
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, {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
KeyboardEvent,
KeyboardEventHandler,
useContext,
SetStateAction,
Dispatch,
} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { sortBy } from "lodash";
import dis from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import BaseAvatar from "../views/avatars/BaseAvatar";
import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext";
interface IProps {
space: Room;
initialText?: string;
additionalButtons?: ReactNode;
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin?: boolean): void;
}
interface ITileProps {
room: IHierarchyRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const cli = useContext(MatrixClientContext);
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 [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
};
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
};
let button;
if (joinedRoom) {
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
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 !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
}
let suggestedSection;
if (suggested) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
}
const content = <React.Fragment>
{ avatar }
<div className="mx_SpaceHierarchy_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div
className="mx_SpaceHierarchy_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_SpaceHierarchy_actions">
{ button }
{ checkbox }
</div>
</React.Fragment>;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
if (showChildren) {
const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceHierarchy_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
}
onKeyDown = (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
} else {
toggleShowChildren();
}
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <li
className="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</li>;
};
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
const room = hierarchy.roomMap.get(roomId);
// Don't let the user view a room they won't be able to either peek or join:
// fail earlier so they don't have to click back to the directory.
if (cli.isGuest()) {
if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({ action: "require_registration" });
return;
}
}
const roomAlias = getDisplayAliasForRoom(room) || undefined;
dis.dispatch({
action: "view_room",
auto_join: autoJoin,
should_peek: true,
_type: "room_directory", // instrumentation
room_alias: roomAlias,
room_id: room.room_id,
via_servers: Array.from(hierarchy.viaMap.get(roomId) || []),
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 || roomAlias || _t("Unnamed room"),
},
});
};
interface IHierarchyLevelProps {
root: IHierarchyRoom;
roomSet: Set<IHierarchyRoom>;
hierarchy: RoomHierarchy;
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
root,
roomSet,
hierarchy,
parents,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = useContext(MatrixClientContext);
const space = cli.getRoom(root.room_id);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy(root.children_state, ev => {
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
}
return result;
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
const newParents = new Set(parents).add(root.room_id);
return <React.Fragment>
{
childRooms.map(room => (
<Tile
key={room.room_id}
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(room.room_id, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
))
}
{
subspaces.filter(room => !newParents.has(room.room_id)).map(space => (
<Tile
key={space.room_id}
room={space}
numChildRooms={space.children_state.filter(ev => {
const room = hierarchy.roomMap.get(ev.state_key);
return room && roomSet.has(room) && !room.room_type;
}).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(space.room_id, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
>
<HierarchyLevel
root={space}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))
}
</React.Fragment>;
};
const INITIAL_PAGE_SIZE = 20;
export const useSpaceSummary = (space: Room): {
loading: boolean;
rooms: IHierarchyRoom[];
hierarchy: RoomHierarchy;
loadMore(pageSize?: number): Promise <void>;
} => {
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
const [loading, setLoading] = useState(true);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
const resetHierarchy = useCallback(() => {
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
setHierarchy(hierarchy);
let discard = false;
hierarchy.load().then(() => {
if (discard) return;
setRooms(hierarchy.rooms);
setLoading(false);
});
return () => {
discard = true;
};
}, [space]);
useEffect(resetHierarchy, [resetHierarchy]);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setLoading(true);
setRooms([]); // TODO
resetHierarchy();
}
}));
const loadMore = useCallback(async (pageSize?: number) => {
if (!hierarchy.canLoadMore || hierarchy.noSupport) return;
setLoading(true);
await hierarchy.load(pageSize);
setRooms(hierarchy.rooms);
setLoading(false);
}, [hierarchy]);
return { loading, rooms, hierarchy, loadMore };
};
const useIntersectionObserver = (callback: () => void) => {
const handleObserver = (entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting) {
callback();
}
};
const observerRef = useRef<IntersectionObserver>();
return (element: HTMLDivElement) => {
if (observerRef.current) {
observerRef.current.disconnect();
} else if (element) {
observerRef.current = new IntersectionObserver(handleObserver, {
root: element.parentElement,
rootMargin: "0px 0px 600px 0px",
});
}
if (observerRef.current && element) {
observerRef.current.observe(element);
}
};
};
interface IManageButtonsProps {
hierarchy: RoomHierarchy;
selected: Map<string, Set<string>>;
setSelected: Dispatch<SetStateAction<Map<string, Set<string>>>>;
setError: Dispatch<SetStateAction<string>>;
}
const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageButtonsProps) => {
const cli = useContext(MatrixClientContext);
const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false);
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return hierarchy.isSuggested(parentId, childId);
});
const disabled = !selectedRelations.length || removing || saving;
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,
};
}
return <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
hierarchy.removeRelation(parentId, childId);
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
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 = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
};
const SpaceHierarchy = ({
space,
initialText = "",
showRoom,
additionalButtons,
}: IProps) => {
const cli = useContext(MatrixClientContext);
const [query, setQuery] = useState(initialText);
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms.length) return new Set();
const lcQuery = query.toLowerCase().trim();
if (!lcQuery) return new Set(rooms);
const directMatches = rooms.filter(r => {
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
});
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
const visited = new Set<string>();
const queue = [...directMatches.map(r => r.room_id)];
while (queue.length) {
const roomId = queue.pop();
visited.add(roomId);
hierarchy.backRefs.get(roomId)?.forEach(parentId => {
if (!visited.has(parentId)) {
queue.push(parentId);
}
});
}
return new Set(rooms.filter(r => visited.has(r.room_id)));
}, [rooms, hierarchy, query]);
const [error, setError] = useState("");
const loaderRef = useIntersectionObserver(loadMore);
if (!loading && hierarchy.noSupport) {
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
state.refs[0]?.current?.focus();
}
};
const onToggleClick = (parentId: string, childId: string): void => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
};
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content: JSX.Element;
let loader: JSX.Element;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
const hasPermissions = space?.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
let results: JSX.Element;
if (filteredRoomSet.size) {
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(cli, hierarchy, roomId, autoJoin);
}}
/>
</>;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
<span>
{ additionalButtons }
{ hasPermissions && (
<ManageButtons
hierarchy={hierarchy}
selected={selected}
setSelected={setSelected}
setError={setError}
/>
) }
</span>
</div>
{ error && <div className="mx_SpaceHierarchy_error">
{ error }
</div> }
<ul
className="mx_SpaceHierarchy_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{ results }
</ul>
{ loader }
</>;
}
return <>
<SearchBox
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ content }
</>;
} }
</RovingTabIndexProvider>;
};
export default SpaceHierarchy;

View file

@ -1,621 +0,0 @@
/*
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, {useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import classNames from "classnames";
import {sortBy} from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
import RoomAvatar from "../views/avatars/RoomAvatar";
import RoomName from "../views/elements/RoomName";
import {useAsyncMemo} from "../../hooks/useAsyncMemo";
import {EnhancedMap} from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar";
import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle";
interface IHierarchyProps {
space: Room;
initialText?: string;
refreshToken?: any;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
/* eslint-disable camelcase */
export interface ISpaceSummaryRoom {
canonical_alias?: string;
aliases: string[];
avatar_url?: string;
guest_can_join: boolean;
name?: string;
num_joined_members: number
room_id: string;
topic?: string;
world_readable: boolean;
num_refs: number;
room_type: string;
}
export interface ISpaceSummaryEvent {
room_id: string;
event_id: string;
origin_server_ts: number;
type: string;
state_key: string;
content: {
order?: string;
suggested?: boolean;
auto_join?: boolean;
via?: string;
};
}
/* eslint-enable camelcase */
interface ITileProps {
room: ISpaceSummaryRoom;
suggested?: boolean;
selected?: boolean;
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean): void;
onToggleClick?(): void;
}
const Tile: React.FC<ITileProps> = ({
room,
suggested,
selected,
hasPermissions,
onToggleClick,
onViewRoomClick,
numChildRooms,
children,
}) => {
const 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);
let button;
if (myMembership === "join") {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary">
{ _t("Join") }
</AccessibleButton>;
}
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation() }}
>
<StyledCheckbox disabled={true} />
</TextWithTooltip>;
}
}
let url: string;
if (room.avatar_url) {
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio));
}
let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms });
}
if (room.topic) {
description += " · " + room.topic;
}
let suggestedSection;
if (suggested) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
}
const content = <React.Fragment>
<BaseAvatar name={name} idName={room.room_id} url={url} width={20} height={20} />
<div className="mx_SpaceRoomDirectory_roomTile_name">
{ name }
{ suggestedSection }
</div>
<div className="mx_SpaceRoomDirectory_roomTile_info">
{ description }
</div>
<div className="mx_SpaceRoomDirectory_actions">
{ button }
{ checkbox }
</div>
</React.Fragment>;
let childToggle;
let childSection;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceRoomDirectory_subspace_toggle", {
mx_SpaceRoomDirectory_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
if (showChildren) {
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
{ children }
</div>;
}
}
return <>
<AccessibleButton
className={classNames("mx_SpaceRoomDirectory_roomTile", {
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</>;
};
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
// Don't let the user view a room they won't be able to either peek or join:
// fail earlier so they don't have to click back to the directory.
if (MatrixClientPeg.get().isGuest()) {
if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({ action: "require_registration" });
return;
}
}
const roomAlias = getDisplayAliasForRoom(room) || undefined;
dis.dispatch({
action: "view_room",
auto_join: autoJoin,
should_peek: true,
_type: "room_directory", // instrumentation
room_alias: roomAlias,
room_id: room.room_id,
via_servers: viaServers,
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 || roomAlias || _t("Unnamed room"),
},
});
};
interface IHierarchyLevelProps {
spaceId: string;
rooms: Map<string, ISpaceSummaryRoom>;
relations: Map<string, Map<string, ISpaceSummaryEvent>>;
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean): void;
onToggleClick?(parentId: string, childId: string): void;
}
export const HierarchyLevel = ({
spaceId,
rooms,
relations,
parents,
selectedMap,
onViewRoomClick,
onToggleClick,
}: IHierarchyLevelProps) => {
const cli = MatrixClientPeg.get();
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 [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;
if (!rooms.has(roomId)) return result;
result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId);
return result;
}, [[], []]) || [[], []];
const newParents = new Set(parents).add(spaceId);
return <React.Fragment>
{
childRooms.map(roomId => (
<Tile
key={roomId}
room={rooms.get(roomId)}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
/>
))
}
{
subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => (
<Tile
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}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => {
onViewRoomClick(roomId, autoJoin);
}}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(spaceId, roomId) : undefined}
>
<HierarchyLevel
spaceId={roomId}
rooms={rooms}
relations={relations}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))
}
</React.Fragment>
};
// mutate argument refreshToken to force a reload
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>,
Map<string, Set<string>>,
Map<string, Set<string>>,
] | [] => {
// TODO pagination
return useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
const childParentRelations = new EnhancedMap<string, Set<string>>();
const viaMap = new EnhancedMap<string, Set<string>>();
data.events.map((ev: ISpaceSummaryEvent) => {
if (ev.type === EventType.SpaceChild) {
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"])) {
const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via));
}
});
return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
} catch (e) {
console.error(e); // TODO
}
return [];
}, [space, refreshToken], []);
};
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
space,
initialText = "",
showRoom,
refreshToken,
children,
}) => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const [query, setQuery] = useState(initialText);
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
const roomsMap = useMemo(() => {
if (!rooms) return null;
const lcQuery = query.toLowerCase().trim();
const roomsMap = new Map<string, ISpaceSummaryRoom>(rooms.map(r => [r.room_id, r]));
if (!lcQuery) return roomsMap;
const directMatches = rooms.filter(r => {
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
});
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
const visited = new Set<string>();
const queue = [...directMatches.map(r => r.room_id)];
while (queue.length) {
const roomId = queue.pop();
visited.add(roomId);
childParentMap.get(roomId)?.forEach(parentId => {
if (!visited.has(parentId)) {
queue.push(parentId);
}
});
}
// Remove any mappings for rooms which were not visited in the walk
Array.from(roomsMap.keys()).forEach(roomId => {
if (!visited.has(roomId)) {
roomsMap.delete(roomId);
}
});
return roomsMap;
}, [rooms, childParentMap, query]);
const [error, setError] = useState("");
const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false);
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let editSection;
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 disabled = 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>
</>;
}
editSection = <span>
{ buttons }
</span>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
{ editSection }
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else if (!rooms) {
content = <Spinner />;
} else {
content = <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
// TODO loading state/error state
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Search names and description") }
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
/>
{ content }
</>;
};
interface IProps {
space: Room;
initialText?: string;
onFinished(): void;
}
const SpaceRoomDirectory: React.FC<IProps> = ({ space, onFinished, initialText }) => {
const onCreateRoomClick = () => {
dis.dispatch({
action: 'view_create_room',
public: true,
});
onFinished();
};
const title = <React.Fragment>
<RoomAvatar room={space} height={32} width={32} />
<div>
<h1>{ _t("Explore rooms") }</h1>
<div><RoomName room={space} /></div>
</div>
</React.Fragment>;
return (
<BaseDialog className="mx_SpaceRoomDirectory" hasCancel={true} onFinished={onFinished} title={title}>
<div className="mx_Dialog_content">
{ _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="link" onClick={onCreateRoomClick}>{sub}</AccessibleButton>;
}},
) }
<SpaceHierarchy
space={space}
showRoom={(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
showRoom(room, viaServers, autoJoin);
onFinished();
}}
initialText={initialText}
>
<AccessibleButton
onClick={onCreateRoomClick}
kind="primary"
className="mx_SpaceRoomDirectory_createRoom"
>
{ _t("Create room") }
</AccessibleButton>
</SpaceHierarchy>
</div>
</BaseDialog>
);
};
export default SpaceRoomDirectory;
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
function getDisplayAliasForRoom(room: ISpaceSummaryRoom) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
}

View file

@ -14,43 +14,70 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {RefObject, useContext, useRef, useState} from "react";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {EventSubscription} from "fbemitter";
import React, { RefObject, useContext, useRef, useState } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventSubscription } from "fbemitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomAvatar from "../views/avatars/RoomAvatar";
import {_t} from "../../languageHandler";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import RoomName from "../views/elements/RoomName";
import RoomTopic from "../views/elements/RoomTopic";
import InlineSpinner from "../views/elements/InlineSpinner";
import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite";
import {useRoomMembers} from "../../hooks/useRoomMembers";
import createRoom, {IOpts, Preset} from "../../createRoom";
import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite";
import { useRoomMembers } from "../../hooks/useRoomMembers";
import createRoom, { IOpts } from "../../createRoom";
import Field from "../views/elements/Field";
import {useEventEmitter} from "../../hooks/useEventEmitter";
import { useEventEmitter } from "../../hooks/useEventEmitter";
import withValidation from "../views/elements/Validation";
import * as Email from "../../email";
import defaultDispatcher from "../../dispatcher/dispatcher";
import {Action} from "../../dispatcher/actions";
import ResizeNotifier from "../../utils/ResizeNotifier"
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import ResizeNotifier from "../../utils/ResizeNotifier";
import MainSplit from './MainSplit';
import ErrorBoundary from "../views/elements/ErrorBoundary";
import {ActionPayload} from "../../dispatcher/payloads";
import { ActionPayload } from "../../dispatcher/payloads";
import RightPanel from "./RightPanel";
import RightPanelStore from "../../stores/RightPanelStore";
import {RightPanelPhases} from "../../stores/RightPanelStorePhases";
import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import {useStateArray} from "../../hooks/useStateArray";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import { useStateArray } from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space";
import {showRoom, SpaceHierarchy} from "./SpaceRoomDirectory";
import {
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceSettings,
} from "../../utils/space";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import {useStateToggle} from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
import {
AddExistingToSpace,
defaultDmsRenderer,
defaultRoomsRenderer,
defaultSpacesRenderer,
} 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 { UserTab } from "../views/dialogs/UserSettingsDialog";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
interface IProps {
space: Room;
@ -62,6 +89,7 @@ interface IProps {
interface IState {
phase: Phase;
firstRoomId?: string; // internal state for the creation wizard
showRightPanel: boolean;
myMembership: string;
}
@ -109,7 +137,7 @@ const SpaceInfo = ({ space }) => {
return <div className="mx_SpaceRoomView_info">
{ visibilitySection }
{ joinRule === "public" && <RoomMemberCount room={space}>
{(count) => count > 0 ? (
{ (count) => count > 0 ? (
<AccessibleButton
kind="link"
onClick={() => {
@ -122,30 +150,83 @@ const SpaceInfo = ({ space }) => {
>
{ _t("%(count)s members", { count }) }
</AccessibleButton>
) : null}
) : null }
</RoomMemberCount> }
</div>
</div>;
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
const onBetaClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
};
// XXX: temporary community migration component
const GroupTile = ({ groupId }: { groupId: string }) => {
const cli = useContext(MatrixClientContext);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
if (!groupSummary) return <Spinner />;
return <>
<GroupAvatar
groupId={groupId}
groupName={groupSummary.profile.name}
groupAvatarUrl={groupSummary.profile.avatar_url}
width={16}
height={16}
resizeMethod='crop'
/>
{ groupSummary.profile.name }
</>;
};
interface ISpacePreviewProps {
space: Room;
onJoinButtonClicked(): void;
onRejectButtonClicked(): void;
}
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
const [busy, setBusy] = useState(false);
const spacesEnabled = SpaceStore.spacesEnabled;
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
&& space.getJoinRule() !== JoinRule.Public;
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);
if (inviteSender) {
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
<MemberAvatar member={inviter} width={32} height={32} />
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div>
<div className="mx_SpaceRoomView_preview_inviter_name">
{ _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter.name || inviteSender }</b>,
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
}) }
</div>
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
@ -171,6 +252,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true);
onJoinButtonClicked();
}}
disabled={!spacesEnabled}
>
{ _t("Accept") }
</AccessibleButton>
@ -183,17 +265,53 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true);
onJoinButtonClicked();
}}
disabled={!spacesEnabled || cannotJoin}
>
{ _t("Join") }
</AccessibleButton>
)
);
}
if (busy) {
joinButtons = <InlineSpinner />;
}
let footer;
if (!spacesEnabled) {
footer = <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>;
} else if (cannotJoin) {
footer = <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ _t("To view %(spaceName)s, you need an invite", {
spaceName: space.name,
}) }
</div>;
}
let migratedCommunitySection: JSX.Element;
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
if (createContent[CreateEventField]) {
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
{ _t("Created from <Community />", {}, {
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
}) }
</div>;
}
return <div className="mx_SpaceRoomView_preview">
{ migratedCommunitySection }
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">
@ -201,7 +319,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</h1>
<SpaceInfo space={space} />
<RoomTopic room={space}>
{(topic, ref) =>
{ (topic, ref) =>
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
{ topic }
</div>
@ -211,9 +329,78 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
<div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons }
</div>
{ footer }
</div>;
};
const SpaceLandingAddButton = ({ space }) => {
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(space)) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showAddExistingRooms(space);
}}
/>
<IconizedContextMenuOption
label={_t("Add space")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewSubspace(space);
}}
>
<BetaPill />
</IconizedContextMenuOption>
</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);
@ -236,34 +423,20 @@ const SpaceLanding = ({ space }) => {
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
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} />;
}
let settingsButton;
if (shouldShowSpaceSettings(cli, space)) {
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => {
showSpaceSettings(cli, space);
}}>
{ _t("Settings") }
</AccessibleButton>;
if (shouldShowSpaceSettings(space)) {
settingsButton = <AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => {
showSpaceSettings(space);
}}
title={_t("Settings")}
/>;
}
const onMembersClick = () => {
@ -275,32 +448,33 @@ const SpaceLanding = ({ space }) => {
};
return <div className="mx_SpaceRoomView_landing">
<SpaceFeedbackPrompt />
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<div className="mx_SpaceRoomView_landing_name">
<RoomName room={space}>
{(name) => {
{ (name) => {
const tags = { name: () => <div className="mx_SpaceRoomView_landing_nameRow">
<h1>{ name }</h1>
</div> };
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
}}
} }
</RoomName>
</div>
<div className="mx_SpaceRoomView_landing_info">
<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>
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} />
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>;
};
@ -309,9 +483,8 @@ 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 fields = new Array(numFields).fill(0).map((x, i) => {
const name = "roomName" + i;
return <Field
key={name}
@ -322,17 +495,23 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
value={roomNames[i]}
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
disabled={busy}
autoComplete="off"
/>;
});
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 isPublic = space.getJoinRule() === JoinRule.Public;
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
const roomIds = await Promise.all(filteredRoomNames.map(name => {
return createRoom({
createOpts: {
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat,
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
name,
},
spinner: false,
@ -340,9 +519,11 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
andView: false,
inlineErrors: true,
parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined,
suggested: true,
});
}));
onFinished();
onFinished(roomIds[0]);
} catch (e) {
console.error("Failed to create initial space rooms", e);
setError(_t("Failed to create initial space rooms"));
@ -350,11 +531,14 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setBusy(false);
};
let onClick = onFinished;
let onClick = (ev) => {
ev.preventDefault();
onFinished();
};
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 +546,57 @@ 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>
</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>
}
filterPlaceholder={_t("Search for rooms or spaces")}
onFinished={onFinished}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={defaultSpacesRenderer}
dmsRenderer={defaultDmsRenderer}
/>
</div>;
};
interface ISpaceSetupPublicShareProps extends Pick<IProps & IState, "justCreatedOpts" | "space" | "firstRoomId"> {
onFinished(): void;
}
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, firstRoomId }: ISpaceSetupPublicShareProps) => {
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,33 +605,39 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Go to my first room") }
{ firstRoomId ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
</div>
</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
className="mx_SpaceRoomView_privateScope_justMeButton"
onClick={() => { onFinished(false) }}
onClick={() => { onFinished(false); }}
>
<h3>{ _t("Just me") }</h3>
<div>{ _t("A private space to organise your rooms") }</div>
</AccessibleButton>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_meAndMyTeammatesButton"
onClick={() => { onFinished(true) }}
onClick={() => { onFinished(true); }}
>
<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, but just want to let you know.") }</p>
</div>
</div>;
};
@ -431,7 +655,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
const numFields = 3;
const fieldRefs: RefObject<Field>[] = [useRef(), useRef(), useRef()];
const [emailAddresses, setEmailAddress] = useStateArray(numFields, "");
const fields = new Array(numFields).fill(0).map((_, i) => {
const fields = new Array(numFields).fill(0).map((x, i) => {
const name = "emailAddress" + i;
return <Field
key={name}
@ -444,10 +668,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,11 +708,14 @@ 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;
buttonLabel = busy ? _t("Inviting...") : _t("Continue")
buttonLabel = busy ? _t("Inviting...") : _t("Continue");
}
return <div className="mx_SpaceRoomView_inviteTeammates">
@ -494,8 +724,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,9 +750,15 @@ 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>
</div>;
};
@ -564,6 +813,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
};
private onAction = (payload: ActionPayload) => {
if (payload.action === "view_room" && payload.room_id === this.props.space.roomId) {
this.setState({ phase: Phase.Landing });
return;
}
if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return;
if (payload.action === Action.ViewUser && payload.member) {
@ -594,33 +848,10 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
};
private goToFirstRoom = async () => {
// TODO actually go to the first room
const childRooms = SpaceStore.instance.getChildRooms(this.props.space.roomId);
if (childRooms.length) {
const room = childRooms[0];
if (this.state.firstRoomId) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.roomId,
});
return;
}
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;
}
if (suggestedRooms.length) {
const room = suggestedRooms[0];
defaultDispatcher.dispatch({
action: "view_room",
room_id: room.room_id,
oobData: {
avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"),
},
room_id: this.state.firstRoomId,
});
return;
}
@ -631,7 +862,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" && SpaceStore.spacesEnabled) {
return <SpaceLanding space={this.props.space} />;
} else {
return <SpacePreview
@ -644,22 +875,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={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })}
/>;
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}
firstRoomId={this.state.firstRoomId}
/>;
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 +910,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={(firstRoomId: string) => this.setState({ phase: Phase.Landing, firstRoomId })}
/>;
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
}

View file

@ -17,10 +17,11 @@ limitations under the License.
*/
import * as React from "react";
import {_t} from '../../languageHandler';
import * as sdk from "../../index";
import { _t } from '../../languageHandler';
import AutoHideScrollbar from './AutoHideScrollbar';
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import classNames from "classnames";
import AccessibleButton from "../views/elements/AccessibleButton";
/**
* Represents a tab for the TabbedView.
@ -37,9 +38,16 @@ export class Tab {
}
}
export enum TabLocation {
LEFT = 'left',
TOP = 'top',
}
interface IProps {
tabs: Tab[];
initialTabId?: string;
tabLocation: TabLocation;
onChange?: (tabId: string) => void;
}
interface IState {
@ -62,7 +70,11 @@ export default class TabbedView extends React.Component<IProps, IState> {
};
}
private _getActiveTabIndex() {
static defaultProps = {
tabLocation: TabLocation.LEFT,
};
private getActiveTabIndex() {
if (!this.state || !this.state.activeTabIndex) return 0;
return this.state.activeTabIndex;
}
@ -72,34 +84,33 @@ export default class TabbedView extends React.Component<IProps, IState> {
* @param {Tab} tab the tab to show
* @private
*/
private _setActiveTab(tab: Tab) {
private setActiveTab(tab: Tab) {
const idx = this.props.tabs.indexOf(tab);
if (idx !== -1) {
this.setState({activeTabIndex: idx});
if (this.props.onChange) this.props.onChange(tab.id);
this.setState({ activeTabIndex: idx });
} else {
console.error("Could not find tab " + tab.label + " in tabs");
}
}
private _renderTabLabel(tab: Tab) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
private renderTabLabel(tab: Tab) {
let classes = "mx_TabbedView_tabLabel ";
const idx = this.props.tabs.indexOf(tab);
if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
if (idx === this.getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
let tabIcon = null;
if (tab.icon) {
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
}
const onClickHandler = () => this._setActiveTab(tab);
const onClickHandler = () => this.setActiveTab(tab);
const label = _t(tab.label);
return (
<AccessibleButton className={classes} key={"tab_label_" + tab.label} onClick={onClickHandler}>
{tabIcon}
{ tabIcon }
<span className="mx_TabbedView_tabLabel_text">
{ label }
</span>
@ -107,26 +118,32 @@ export default class TabbedView extends React.Component<IProps, IState> {
);
}
private _renderTabPanel(tab: Tab): React.ReactNode {
private renderTabPanel(tab: Tab): React.ReactNode {
return (
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
<AutoHideScrollbar className='mx_TabbedView_tabPanelContent'>
{tab.body}
{ tab.body }
</AutoHideScrollbar>
</div>
);
}
public render(): React.ReactNode {
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);
const labels = this.props.tabs.map(tab => this.renderTabLabel(tab));
const panel = this.renderTabPanel(this.props.tabs[this.getActiveTabIndex()]);
const tabbedViewClasses = classNames({
'mx_TabbedView': true,
'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT,
'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP,
});
return (
<div className="mx_TabbedView">
<div className={tabbedViewClasses}>
<div className="mx_TabbedView_tabLabels">
{labels}
{ labels }
</div>
{panel}
{ panel }
</div>
);
}

View file

@ -0,0 +1,93 @@
/*
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, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import ResizeNotifier from '../../utils/ResizeNotifier';
import EventTile from '../views/rooms/EventTile';
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
}
interface IState {
threads?: Thread[];
}
@replaceableComponent("structures.ThreadView")
export default class ThreadPanel extends React.Component<IProps, IState> {
private room: Room;
constructor(props: IProps) {
super(props);
this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
}
public componentDidMount(): void {
this.room.on("Thread.update", this.onThreadEventReceived);
this.room.on("Thread.ready", this.onThreadEventReceived);
}
public componentWillUnmount(): void {
this.room.removeListener("Thread.update", this.onThreadEventReceived);
this.room.removeListener("Thread.ready", this.onThreadEventReceived);
}
private onThreadEventReceived = () => this.updateThreads();
private updateThreads = (callback?: () => void): void => {
this.setState({
threads: this.room.getThreads(),
}, callback);
};
private renderEventTile(event: MatrixEvent): JSX.Element {
return <EventTile
key={event.getId()}
mxEvent={event}
enableFlair={false}
showReadReceipts={false}
as="div"
/>;
}
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
{
this.state?.threads.map((thread: Thread) => {
if (thread.ready) {
return this.renderEventTile(thread.rootEvent);
}
})
}
</BaseCard>
);
}
}

View file

@ -0,0 +1,160 @@
/*
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, Room } from 'matrix-js-sdk/src';
import { Thread } from 'matrix-js-sdk/src/models/thread';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { replaceableComponent } from "../../utils/replaceableComponent";
import ResizeNotifier from '../../utils/ResizeNotifier';
import { TileShape } from '../views/rooms/EventTile';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/Layout';
import TimelinePanel from './TimelinePanel';
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
import { Action } from '../../dispatcher/actions';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { E2EStatus } from '../../utils/ShieldUtils';
interface IProps {
room: Room;
onClose: () => void;
resizeNotifier: ResizeNotifier;
mxEvent: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
e2eStatus?: E2EStatus;
}
interface IState {
replyToEvent?: MatrixEvent;
thread?: Thread;
}
@replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
constructor(props: IProps) {
super(props);
this.state = {};
}
public componentDidMount(): void {
this.setupThread(this.props.mxEvent);
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount(): void {
this.teardownThread();
dis.unregister(this.dispatcherRef);
}
public componentDidUpdate(prevProps) {
if (prevProps.mxEvent !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(this.props.mxEvent);
}
if (prevProps.room !== this.props.room) {
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
});
}
}
private onAction = (payload: ActionPayload): void => {
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
if (payload.event !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(payload.event);
}
}
};
private setupThread = (mxEv: MatrixEvent) => {
let thread = mxEv.getThread();
if (!thread) {
const client = MatrixClientPeg.get();
thread = new Thread([mxEv], this.props.room, client);
mxEv.setThread(thread);
}
thread.on("Thread.update", this.updateThread);
thread.once("Thread.ready", this.updateThread);
this.updateThread(thread);
};
private teardownThread = () => {
if (this.state.thread) {
this.state.thread.removeListener("Thread.update", this.updateThread);
this.state.thread.removeListener("Thread.ready", this.updateThread);
}
};
private updateThread = (thread?: Thread) => {
if (thread) {
this.setState({
thread,
replyToEvent: thread.replyToEvent,
});
}
this.timelinePanelRef.current?.refreshTimeline();
};
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
{ this.state.thread && (
<TimelinePanel
ref={this.timelinePanelRef}
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={false}
tileShape={TileShape.Notif}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
/>
) }
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
replyInThread={true}
replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
</BaseCard>
);
}
}

View file

@ -15,9 +15,9 @@ limitations under the License.
*/
import * as React from "react";
import ToastStore, {IToast} from "../../stores/ToastStore";
import ToastStore, { IToast } from "../../stores/ToastStore";
import classNames from "classnames";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
interface IState {
toasts: IToast<any>[];
@ -37,14 +37,14 @@ export default class ToastContainer extends React.Component<{}, IState> {
// toasts may dismiss themselves in their didMount if they find
// they're already irrelevant by the time they're mounted, and
// our own componentDidMount is too late.
ToastStore.sharedInstance().on('update', this._onToastStoreUpdate);
ToastStore.sharedInstance().on('update', this.onToastStoreUpdate);
}
componentWillUnmount() {
ToastStore.sharedInstance().removeListener('update', this._onToastStoreUpdate);
ToastStore.sharedInstance().removeListener('update', this.onToastStoreUpdate);
}
_onToastStoreUpdate = () => {
private onToastStoreUpdate = () => {
this.setState({
toasts: ToastStore.sharedInstance().getToasts(),
countSeen: ToastStore.sharedInstance().getCountSeen(),
@ -55,40 +55,53 @@ 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;
const toastClasses = classNames("mx_Toast_toast", {
const { title, icon, key, component, className, bodyClassName, props } = topToast;
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
const toastClasses = classNames("mx_Toast_toast", className, {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
}, className);
let countIndicator;
if (isStacked || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
});
const toastProps = Object.assign({}, props, {
key,
toastKey: key,
});
toast = (<div className={toastClasses}>
<div className="mx_Toast_title">
<h2>{title}</h2>
<span>{countIndicator}</span>
const content = React.createElement(component, toastProps);
let countIndicator;
if (title && isStacked || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
let titleElement;
if (title) {
titleElement = (
<div className="mx_Toast_title">
<h2>{ title }</h2>
<span>{ countIndicator }</span>
</div>
);
}
toast = (
<div className={toastClasses}>
{ titleElement }
<div className={bodyClasses}>{ content }</div>
</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

@ -25,7 +25,8 @@ import { Action } from "../../dispatcher/actions";
import ProgressBar from "../views/elements/ProgressBar";
import AccessibleButton from "../views/elements/AccessibleButton";
import { IUpload } from "../../models/IUpload";
import {replaceableComponent} from "../../utils/replaceableComponent";
import { replaceableComponent } from "../../utils/replaceableComponent";
import MatrixClientContext from "../../contexts/MatrixClientContext";
interface IProps {
room: Room;
@ -38,6 +39,8 @@ interface IState {
@replaceableComponent("structures.UploadBar")
export default class UploadBar extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
private dispatcherRef: string;
private mounted: boolean;
@ -47,7 +50,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
// Set initial state to any available upload in this room - we might be mounting
// earlier than the first progress event, so should show something relevant.
const uploadsHere = this.getUploadsInRoom();
this.state = {currentUpload: uploadsHere[0], uploadsHere};
this.state = { currentUpload: uploadsHere[0], uploadsHere };
}
componentDidMount() {
@ -74,7 +77,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
case Action.UploadFailed: {
if (!this.mounted) return;
const uploadsHere = this.getUploadsInRoom();
this.setState({currentUpload: uploadsHere[0], uploadsHere});
this.setState({ currentUpload: uploadsHere[0], uploadsHere });
break;
}
}
@ -82,7 +85,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
private onCancelClick = (ev) => {
ev.preventDefault();
ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise);
ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise, this.context);
};
render() {
@ -101,7 +104,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
const uploadSize = filesize(this.state.currentUpload.total);
return (
<div className="mx_UploadBar">
<div className="mx_UploadBar_filename">{uploadText} ({uploadSize})</div>
<div className="mx_UploadBar_filename">{ uploadText } ({ uploadSize })</div>
<AccessibleButton onClick={this.onCancelClick} className='mx_UploadBar_cancel' />
<ProgressBar value={this.state.currentUpload.loaded} max={this.state.currentUpload.total} />
</div>

View file

@ -26,14 +26,14 @@ import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu";
import { USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB } from "../views/dialogs/UserSettingsDialog";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import { getCustomTheme } from "../../theme";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import { getHomePageUrl } from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
@ -56,8 +56,9 @@ import HostSignupAction from "./HostSignupAction";
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 { replaceableComponent } from "../../utils/replaceableComponent";
import InlineSpinner from "../views/elements/InlineSpinner";
import TooltipButton from "../views/elements/TooltipButton";
interface IProps {
isMinimized: boolean;
}
@ -68,12 +69,14 @@ interface IState {
contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean;
selectedSpace?: Room;
pendingRoomJoin: Set<string>;
}
@replaceableComponent("structures.UserMenu")
export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private dndWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
private tagStoreRef: fbEmitter.EventSubscription;
@ -83,12 +86,16 @@ 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);
if (SettingsStore.getValue("feature_spaces")) {
if (SpaceStore.spacesEnabled) {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
// Force update is the easiest way to trigger the UI update (we don't store state for this)
this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate());
}
private get hasHomePage(): boolean {
@ -99,18 +106,25 @@ 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() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
this.tagStoreRef.remove();
if (SettingsStore.getValue("feature_spaces")) {
if (SpaceStore.spacesEnabled) {
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 = () => {
this.forceUpdate(); // we don't have anything useful in state to update
};
@ -138,24 +152,48 @@ export default class UserMenu extends React.Component<IProps, IState> {
};
private onThemeChanged = () => {
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
this.setState({ isDarkTheme: this.isUserOnDarkTheme() });
};
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();
const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent) => {
@ -172,7 +210,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
};
private onCloseMenu = () => {
this.setState({contextMenuPosition: null});
this.setState({ contextMenuPosition: null });
};
private onSwitchThemeClick = (ev: React.MouseEvent) => {
@ -190,9 +228,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.preventDefault();
ev.stopPropagation();
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId };
defaultDispatcher.dispatch(payload);
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onShowArchived = (ev: ButtonEvent) => {
@ -209,7 +247,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation();
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSignOutClick = async (ev: ButtonEvent) => {
@ -219,30 +257,30 @@ export default class UserMenu extends React.Component<IProps, IState> {
const cli = MatrixClientPeg.get();
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
// log out without user prompt if they have no local megolm sessions
dis.dispatch({action: 'logout'});
dis.dispatch({ action: 'logout' });
} else {
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
}
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSignInClick = () => {
dis.dispatch({ action: 'start_login' });
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onRegisterClick = () => {
dis.dispatch({ action: 'start_registration' });
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({action: 'view_home_page'});
this.setState({contextMenuPosition: null}); // also close the menu
defaultDispatcher.dispatch({ action: 'view_home_page' });
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunitySettingsClick = (ev: ButtonEvent) => {
@ -252,7 +290,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, {
communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(),
});
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunityMembersClick = (ev: ButtonEvent) => {
@ -269,7 +307,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
action: 'view_room',
room_id: chat.roomId,
}, true);
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList});
dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList });
} else {
// "This should never happen" clauses go here for the prototype.
Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, {
@ -277,7 +315,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
description: _t("Failed to find the general chat for this community"),
});
}
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunityInviteClick = (ev: ButtonEvent) => {
@ -285,7 +323,13 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation();
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
this.setState({contextMenuPosition: null}); // also close the menu
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onDndToggle = (ev) => {
ev.stopPropagation();
const current = SettingsStore.getValue("doNotDisturb");
SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current);
};
private renderContextMenu = (): React.ReactNode => {
@ -298,22 +342,22 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (MatrixClientPeg.get().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
{_t("Got an account? <a>Sign in</a>", {}, {
{ _t("Got an account? <a>Sign in</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onSignInClick}>
{sub}
{ sub }
</AccessibleButton>
),
})}
{_t("New here? <a>Create an account</a>", {}, {
}) }
{ _t("New here? <a>Create an account</a>", {}, {
a: sub => (
<AccessibleButton kind="link" onClick={this.onRegisterClick}>
{sub}
{ sub }
</AccessibleButton>
),
})}
}) }
</div>
)
);
} else if (hostSignupConfig) {
if (hostSignupConfig && hostSignupConfig.url) {
// If hostSignup.domains is set to a non-empty array, only show
@ -322,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} />;
}
}
}
@ -352,37 +394,37 @@ export default class UserMenu extends React.Component<IProps, IState> {
let primaryHeader = (
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
);
let primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{homeButton}
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notification settings")}
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & privacy")}
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{/* <IconizedContextMenuOption
{ /* <IconizedContextMenuOption
iconClassName="mx_UserMenu_iconArchive"
label={_t("Archived rooms")}
onClick={this.onShowArchived}
/> */}
/> */ }
{ feedbackButton }
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
@ -401,7 +443,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
primaryHeader = (
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{prototypeCommunityName}
{ prototypeCommunityName }
</span>
</div>
);
@ -428,13 +470,13 @@ export default class UserMenu extends React.Component<IProps, IState> {
}
primaryOptionList = (
<IconizedContextMenuOptionList>
{settingsOption}
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMembers"
label={_t("Members")}
onClick={this.onCommunityMembersClick}
/>
{inviteOption}
{ inviteOption }
</IconizedContextMenuOptionList>
);
secondarySection = (
@ -443,10 +485,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
</div>
@ -467,7 +509,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</IconizedContextMenuOptionList>
</React.Fragment>
)
);
} else if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<React.Fragment>
@ -498,7 +540,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
className={classes}
>
<div className="mx_UserMenu_contextMenu_header">
{primaryHeader}
{ primaryHeader }
<AccessibleTooltipButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
@ -511,9 +553,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</AccessibleTooltipButton>
</div>
{topSection}
{primaryOptionList}
{secondarySection}
{ topSection }
{ primaryOptionList }
{ secondarySection }
</IconizedContextMenu>;
};
@ -528,26 +570,27 @@ export default class UserMenu extends React.Component<IProps, IState> {
let isPrototype = false;
let menuName = _t("User menu");
let name = <span className="mx_UserMenu_userName">{displayName}</span>;
let name = <span className="mx_UserMenu_userName">{ displayName }</span>;
let buttons = (
<span className="mx_UserMenu_headerButtons">
{/* masked image in CSS */}
{ /* masked image in CSS */ }
</span>
);
let dnd;
if (this.state.selectedSpace) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{displayName}</span>
<span className="mx_UserMenu_userName">{ displayName }</span>
<RoomName room={this.state.selectedSpace}>
{(roomName) => <span className="mx_UserMenu_subUserName">{roomName}</span>}
{ (roomName) => <span className="mx_UserMenu_subUserName">{ roomName }</span> }
</RoomName>
</div>
);
} else if (prototypeCommunityName) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{prototypeCommunityName}</span>
<span className="mx_UserMenu_subUserName">{displayName}</span>
<span className="mx_UserMenu_userName">{ prototypeCommunityName }</span>
<span className="mx_UserMenu_subUserName">{ displayName }</span>
</div>
);
menuName = _t("Community and user menu");
@ -555,11 +598,21 @@ export default class UserMenu extends React.Component<IProps, IState> {
} else if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{_t("Home")}</span>
<span className="mx_UserMenu_subUserName">{displayName}</span>
<span className="mx_UserMenu_userName">{ _t("Home") }</span>
<span className="mx_UserMenu_subUserName">{ displayName }</span>
</div>
);
isPrototype = true;
} else if (SettingsStore.getValue("feature_dnd")) {
const isDnd = SettingsStore.getValue("doNotDisturb");
dnd = <AccessibleButton
onClick={this.onDndToggle}
className={classNames({
"mx_UserMenu_dnd": true,
"mx_UserMenu_dnd_noisy": !isDnd,
"mx_UserMenu_dnd_muted": isDnd,
})}
/>;
}
if (this.props.isMinimized) {
name = null;
@ -594,11 +647,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
className="mx_UserMenu_userAvatar"
/>
</span>
{name}
{buttons}
{ name }
{ this.state.pendingRoomJoin.size > 0 && (
<InlineSpinner>
<TooltipButton helpText={_t(
"Currently joining %(count)s rooms",
{ count: this.state.pendingRoomJoin.size },
)} />
</InlineSpinner>
) }
{ dnd }
{ buttons }
</div>
</ContextMenuButton>
{this.renderContextMenu()}
{ this.renderContextMenu() }
</React.Fragment>
);
}

View file

@ -17,14 +17,14 @@ limitations under the License.
import React from "react";
import PropTypes from "prop-types";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import * as sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import HomePage from "./HomePage";
import {replaceableComponent} from "../../utils/replaceableComponent";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@replaceableComponent("structures.UserView")
export default class UserView extends React.Component {
@ -56,7 +56,7 @@ export default class UserView extends React.Component {
async _loadProfileInfo() {
const cli = MatrixClientPeg.get();
this.setState({loading: true});
this.setState({ loading: true });
let profileInfo;
try {
profileInfo = await cli.getProfileInfo(this.props.userId);
@ -66,13 +66,13 @@ export default class UserView extends React.Component {
title: _t('Could not load user profile'),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
this.setState({loading: false});
this.setState({ loading: false });
return;
}
const fakeEvent = new MatrixEvent({type: "m.room.member", content: profileInfo});
const fakeEvent = new MatrixEvent({ type: "m.room.member", content: profileInfo });
const member = new RoomMember(null, this.props.userId);
member.setMembershipEvent(fakeEvent);
this.setState({member, loading: false});
this.setState({ member, loading: false });
}
render() {

View file

@ -55,7 +55,7 @@ export default class ViewSource extends React.Component {
viewSourceContent() {
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
const isEncrypted = mxEvent.isEncrypted();
const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
const originalEventSource = mxEvent.event;
if (isEncrypted) {
@ -63,23 +63,23 @@ export default class ViewSource extends React.Component {
<>
<details open className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">{_t("Decrypted event source")}</span>
<span className="mx_ViewSource_heading">{ _t("Decrypted event source") }</span>
</summary>
<SyntaxHighlight className="json">{JSON.stringify(decryptedEventSource, null, 2)}</SyntaxHighlight>
<SyntaxHighlight className="json">{ JSON.stringify(decryptedEventSource, null, 2) }</SyntaxHighlight>
</details>
<details className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">{_t("Original event source")}</span>
<span className="mx_ViewSource_heading">{ _t("Original event source") }</span>
</summary>
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
<SyntaxHighlight className="json">{ JSON.stringify(originalEventSource, null, 2) }</SyntaxHighlight>
</details>
</>
);
} else {
return (
<>
<div className="mx_ViewSource_heading">{_t("Original event source")}</div>
<SyntaxHighlight className="json">{JSON.stringify(originalEventSource, null, 2)}</SyntaxHighlight>
<div className="mx_ViewSource_heading">{ _t("Original event source") }</div>
<SyntaxHighlight className="json">{ JSON.stringify(originalEventSource, null, 2) }</SyntaxHighlight>
</>
);
}
@ -110,7 +110,7 @@ export default class ViewSource extends React.Component {
if (isStateEvent) {
return (
<MatrixClientContext.Consumer>
{(cli) => (
{ (cli) => (
<SendCustomEvent
room={cli.getRoom(roomId)}
forceStateEvent={true}
@ -121,7 +121,7 @@ export default class ViewSource extends React.Component {
stateKey: mxEvent.getStateKey(),
}}
/>
)}
) }
</MatrixClientContext.Consumer>
);
} else {
@ -142,7 +142,7 @@ export default class ViewSource extends React.Component {
};
return (
<MatrixClientContext.Consumer>
{(cli) => (
{ (cli) => (
<SendCustomEvent
room={cli.getRoom(roomId)}
forceStateEvent={false}
@ -153,7 +153,7 @@ export default class ViewSource extends React.Component {
evContent: JSON.stringify(newContent, null, "\t"),
}}
/>
)}
) }
</MatrixClientContext.Consumer>
);
}
@ -176,16 +176,16 @@ export default class ViewSource extends React.Component {
return (
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>
<div>
<div>Room ID: {roomId}</div>
<div>Event ID: {eventId}</div>
<div>Room ID: { roomId }</div>
<div>Event ID: { eventId }</div>
<div className="mx_ViewSource_separator" />
{isEditing ? this.editSourceContent() : this.viewSourceContent()}
{ isEditing ? this.editSourceContent() : this.viewSourceContent() }
</div>
{!isEditing && canEdit && (
{ !isEditing && canEdit && (
<div className="mx_Dialog_buttons">
<button onClick={() => this.onEdit()}>{_t("Edit")}</button>
<button onClick={() => this.onEdit()}>{ _t("Edit") }</button>
</div>
)}
) }
</BaseDialog>
);
}

View file

@ -15,64 +15,60 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_LOADING,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
} from '../../../stores/SetupEncryptionStore';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
onFinished: () => void;
}
interface IState {
phase: Phase;
}
@replaceableComponent("structures.auth.CompleteSecurity")
export default class CompleteSecurity extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
constructor() {
super();
export default class CompleteSecurity extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate);
store.on("update", this.onStoreUpdate);
store.start();
this.state = {phase: store.phase};
this.state = { phase: store.phase };
}
_onStoreUpdate = () => {
private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance();
this.setState({phase: store.phase});
this.setState({ phase: store.phase });
};
componentWillUnmount() {
public componentWillUnmount(): void {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate);
store.off("update", this.onStoreUpdate);
store.stop();
}
render() {
public render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const {phase} = this.state;
const { phase } = this.state;
let icon;
let title;
if (phase === PHASE_LOADING) {
if (phase === Phase.Loading) {
return null;
} else if (phase === PHASE_INTRO) {
} else if (phase === Phase.Intro) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else if (phase === PHASE_DONE) {
} else if (phase === Phase.Done) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Session verified");
} else if (phase === PHASE_CONFIRM_SKIP) {
} else if (phase === Phase.ConfirmSkip) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Are you sure?");
} else if (phase === PHASE_BUSY) {
} else if (phase === Phase.Busy) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else {
@ -83,8 +79,8 @@ export default class CompleteSecurity extends React.Component {
<AuthPage>
<CompleteSecurityBody>
<h2 className="mx_CompleteSecurity_header">
{icon}
{title}
{ icon }
{ title }
</h2>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} />

View file

@ -15,20 +15,19 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import AuthPage from '../../views/auth/AuthPage';
import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody';
import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
onFinished: () => void;
accountPassword?: string;
tokenLogin?: boolean;
}
@replaceableComponent("structures.auth.E2eSetup")
export default class E2eSetup extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
accountPassword: PropTypes.string,
tokenLogin: PropTypes.bool,
};
export default class E2eSetup extends React.Component<IProps> {
render() {
return (
<AuthPage>

View file

@ -17,41 +17,65 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from "../../../Modal";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
import ServerPicker from "../../views/elements/ServerPicker";
import PassphraseField from '../../views/auth/PassphraseField';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
// Phases
// Show the forgot password inputs
const PHASE_FORGOT = 1;
// Email is in the process of being sent
const PHASE_SENDING_EMAIL = 2;
// Email has been sent
const PHASE_EMAIL_SENT = 3;
// User has clicked the link in email and completed reset
const PHASE_DONE = 4;
import { IValidationResult } from "../../views/elements/Validation";
import InlineSpinner from '../../views/elements/InlineSpinner';
enum Phase {
// Show the forgot password inputs
Forgot = 1,
// Email is in the process of being sent
SendingEmail = 2,
// Email has been sent
EmailSent = 3,
// User has clicked the link in email and completed reset
Done = 4,
}
interface IProps {
serverConfig: ValidatedServerConfig;
onServerConfigChange: (serverConfig: ValidatedServerConfig) => void;
onLoginClick?: () => void;
onComplete: () => void;
}
interface IState {
phase: Phase;
email: string;
password: string;
password2: string;
errorText: string;
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
passwordFieldValid: boolean;
currentHttpRequest?: Promise<any>;
}
@replaceableComponent("structures.auth.ForgotPassword")
export default class ForgotPassword extends React.Component {
static propTypes = {
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
onServerConfigChange: PropTypes.func.isRequired,
onLoginClick: PropTypes.func,
onComplete: PropTypes.func.isRequired,
};
export default class ForgotPassword extends React.Component<IProps, IState> {
private reset: PasswordReset;
state = {
phase: PHASE_FORGOT,
state: IState = {
phase: Phase.Forgot,
email: "",
password: "",
password2: "",
@ -64,30 +88,31 @@ export default class ForgotPassword extends React.Component {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
passwordFieldValid: false,
};
constructor(props) {
constructor(props: IProps) {
super(props);
CountlyAnalytics.instance.track("onboarding_forgot_password_begin");
}
componentDidMount() {
public componentDidMount() {
this.reset = null;
this._checkServerLiveliness(this.props.serverConfig);
this.checkServerLiveliness(this.props.serverConfig);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
UNSAFE_componentWillReceiveProps(newProps) {
// eslint-disable-next-line
public UNSAFE_componentWillReceiveProps(newProps: IProps): void {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Do a liveliness check on the new URLs
this._checkServerLiveliness(newProps.serverConfig);
this.checkServerLiveliness(newProps.serverConfig);
}
async _checkServerLiveliness(serverConfig) {
private async checkServerLiveliness(serverConfig): Promise<void> {
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl,
@ -98,46 +123,49 @@ export default class ForgotPassword extends React.Component {
serverIsAlive: true,
});
} catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState);
}
}
submitPasswordReset(email, password) {
public submitPasswordReset(email: string, password: string): void {
this.setState({
phase: PHASE_SENDING_EMAIL,
phase: Phase.SendingEmail,
});
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password).then(() => {
this.setState({
phase: PHASE_EMAIL_SENT,
phase: Phase.EmailSent,
});
}, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
phase: PHASE_FORGOT,
phase: Phase.Forgot,
});
});
}
onVerify = async ev => {
private onVerify = async (ev: React.MouseEvent): Promise<void> => {
ev.preventDefault();
if (!this.reset) {
console.error("onVerify called before submitPasswordReset!");
return;
}
if (this.state.currentHttpRequest) return;
try {
await this.reset.checkEmailLinkClicked();
this.setState({ phase: PHASE_DONE });
await this.handleHttpRequest(this.reset.checkEmailLinkClicked());
this.setState({ phase: Phase.Done });
} catch (err) {
this.showErrorDialog(err.message);
}
};
onSubmitForm = async ev => {
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
if (this.state.currentHttpRequest) return;
// refresh the server errors, just in case the server came back online
await this._checkServerLiveliness(this.props.serverConfig);
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
await this['password_field'].validate({ allowEmpty: false });
@ -172,32 +200,43 @@ export default class ForgotPassword extends React.Component {
}
};
onInputChanged = (stateKey, ev) => {
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
this.setState({
[stateKey]: ev.target.value,
});
[stateKey]: ev.currentTarget.value,
} as any);
};
onLoginClick = ev => {
private onLoginClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
showErrorDialog(body, title) {
public showErrorDialog(description: string, title?: string) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
title: title,
description: body,
title,
description,
});
}
onPasswordValidate(result) {
private onPasswordValidate(result: IValidationResult) {
this.setState({
passwordFieldValid: result.valid,
});
}
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
this.setState({
currentHttpRequest: request,
});
return request.finally(() => {
this.setState({
currentHttpRequest: undefined,
});
});
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
@ -216,14 +255,14 @@ export default class ForgotPassword extends React.Component {
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
{ this.state.serverDeadError }
</div>
);
}
return <div>
{errorText}
{serverDeadSection}
{ errorText }
{ serverDeadSection }
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
@ -266,10 +305,10 @@ export default class ForgotPassword extends React.Component {
autoComplete="new-password"
/>
</div>
<span>{_t(
<span>{ _t(
'A verification email will be sent to your inbox to confirm ' +
'setting your new password.',
)}</span>
) }</span>
<input
className="mx_Login_submit"
type="submit"
@ -277,7 +316,7 @@ export default class ForgotPassword extends React.Component {
/>
</form>
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}
{ _t('Sign in instead') }
</a>
</div>;
}
@ -289,23 +328,32 @@ export default class ForgotPassword extends React.Component {
renderEmailSent() {
return <div>
{_t("An email has been sent to %(emailAddress)s. Once you've followed the " +
"link it contains, click below.", { emailAddress: this.state.email })}
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
"link it contains, click below.", { emailAddress: this.state.email }) }
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
<input
className="mx_Login_submit"
type="button"
onClick={this.onVerify}
value={_t('I have verified my email address')} />
{ this.state.currentHttpRequest && (
<div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>)
}
</div>;
}
renderDone() {
return <div>
<p>{_t("Your password has been reset.")}</p>
<p>{_t(
<p>{ _t("Your password has been reset.") }</p>
<p>{ _t(
"You have been logged out of all sessions and will no longer receive " +
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
)}</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
) }</p>
<input
className="mx_Login_submit"
type="button"
onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>;
}
@ -316,18 +364,20 @@ export default class ForgotPassword extends React.Component {
let resetPasswordJsx;
switch (this.state.phase) {
case PHASE_FORGOT:
case Phase.Forgot:
resetPasswordJsx = this.renderForgot();
break;
case PHASE_SENDING_EMAIL:
case Phase.SendingEmail:
resetPasswordJsx = this.renderSendingEmail();
break;
case PHASE_EMAIL_SENT:
case Phase.EmailSent:
resetPasswordJsx = this.renderEmailSent();
break;
case PHASE_DONE:
case Phase.Done:
resetPasswordJsx = this.renderDone();
break;
default:
resetPasswordJsx = <div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>;
}
return (
@ -335,7 +385,7 @@ export default class ForgotPassword extends React.Component {
<AuthHeader />
<AuthBody>
<h2> { _t('Set a new password') } </h2>
{resetPasswordJsx}
{ resetPasswordJsx }
</AuthBody>
</AuthPage>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016, 2017, 2018, 2019 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.
@ -14,28 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ReactNode} from 'react';
import {MatrixError} from "matrix-js-sdk/src/http-api";
import React, { ReactNode } from 'react';
import { MatrixError } from "matrix-js-sdk/src/http-api";
import {_t, _td} from '../../../languageHandler';
import * as sdk from '../../../index';
import Login, {ISSOFlow, LoginFlow} from '../../../Login';
import { _t, _td } from '../../../languageHandler';
import Login, { ISSOFlow, LoginFlow } from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import AuthPage from "../../views/auth/AuthPage";
import PlatformPeg from '../../../PlatformPeg';
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import { UIFeature } from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {IMatrixClientCreds} from "../../../MatrixClientPeg";
import { IMatrixClientCreds } from "../../../MatrixClientPeg";
import PasswordLogin from "../../views/auth/PasswordLogin";
import InlineSpinner from "../../views/elements/InlineSpinner";
import Spinner from "../../views/elements/Spinner";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
@ -59,6 +60,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
@ -94,7 +96,7 @@ interface IState {
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
serverDeadError?: ReactNode;
}
/*
@ -119,7 +121,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
flows: null,
username: "",
username: props.defaultUsername? props.defaultUsername: '',
phoneCountry: null,
phoneNumber: "",
@ -142,7 +144,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillMount() {
this.initLoginLogic(this.props.serverConfig);
}
@ -152,7 +154,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
@ -165,7 +167,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
if (!this.state.serverIsAlive) {
this.setState({busy: true});
this.setState({ busy: true });
// Do a quick liveliness check on the URLs
let aliveAgain = true;
try {
@ -173,7 +175,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
);
this.setState({serverIsAlive: true, errorText: ""});
this.setState({ serverIsAlive: true, errorText: "" });
} catch (e) {
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
this.setState({
@ -200,7 +202,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in.
this.setState({ serverIsAlive: true }); // it must be, we logged in.
this.props.onLoggedIn(data, password);
}, (error) => {
if (this.unmounted) {
@ -237,8 +239,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
);
errorText = (
<div>
<div>{errorTop}</div>
<div className="mx_Login_smallError">{errorDetail}</div>
<div>{ errorTop }</div>
<div className="mx_Login_smallError">{ errorDetail }</div>
</div>
);
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
@ -249,10 +251,10 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
<div>
<div>{ _t('Incorrect username and/or password.') }</div>
<div className="mx_Login_smallError">
{_t(
{ _t(
'Please note you are logging into the %(hs)s server, not matrix.org.',
{hs: this.props.serverConfig.hsName},
)}
{ hs: this.props.serverConfig.hsName },
) }
</div>
</div>
);
@ -362,7 +364,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
};
private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) {
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig) {
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
@ -461,7 +463,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener"
return <a
target="_blank"
rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
@ -500,9 +504,9 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
return <React.Fragment>
{ flows.map(flow => {
const stepRenderer = this.stepRendererMap[flow.type];
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>;
}) }
</React.Fragment>
</React.Fragment>;
}
private renderPasswordStep = () => {
@ -540,8 +544,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
};
render() {
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Spinner /></div> : null;
@ -565,7 +567,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
{ this.state.serverDeadError }
</div>
);
}
@ -578,15 +580,15 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
{ this.props.isSyncing ? _t("Syncing...") : _t("Signing In...") }
</div>
{ this.props.isSyncing && <div className="mx_AuthBody_paddedFooter_subtitle">
{_t("If you've joined lots of rooms, this might take a while")}
{ _t("If you've joined lots of rooms, this might take a while") }
</div> }
</div>;
} else if (SettingsStore.getValue(UIFeature.Registration)) {
footer = (
<span className="mx_AuthBody_changeFlow">
{_t("New? <a>Create account</a>", {}, {
{ _t("New? <a>Create account</a>", {}, {
a: sub => <a onClick={this.onTryRegisterClick} href="#">{ sub }</a>,
})}
}) }
</span>
);
}
@ -596,8 +598,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
<AuthBody>
<h2>
{_t('Sign in')}
{loader}
{ _t('Sign in') }
{ loader }
</h2>
{ errorTextSection }
{ serverDeadSection }

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016, 2017, 2018, 2019, 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.
@ -14,23 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {createClient} from 'matrix-js-sdk/src/matrix';
import React, {ReactNode} from 'react';
import {MatrixClient} from "matrix-js-sdk/src/client";
import { createClient } from 'matrix-js-sdk/src/matrix';
import React, { ReactNode } from 'react';
import { MatrixClient } from "matrix-js-sdk/src/client";
import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login, {ISSOFlow} from "../../../Login";
import Login, { ISSOFlow } from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from '../../views/elements/ServerPicker';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RegistrationForm from '../../views/auth/RegistrationForm';
import AccessibleButton from '../../views/elements/AccessibleButton';
import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
import InteractiveAuth from "../InteractiveAuth";
import Spinner from "../../views/elements/Spinner";
interface IProps {
serverConfig: ValidatedServerConfig;
@ -47,13 +52,7 @@ interface IProps {
// - The user's password, if available and applicable (may be cached in memory
// for a short time so the user is not required to re-enter their password
// for operations like uploading cross-signing keys).
onLoggedIn(params: {
userId: string;
deviceId: string
homeserverUrl: string;
identityServerUrl?: string;
accessToken: string;
}, password: string): void;
onLoggedIn(params: IMatrixClientCreds, password: string): void;
makeRegistrationUrl(params: {
/* eslint-disable camelcase */
client_secret: string;
@ -61,7 +60,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;
@ -95,7 +94,7 @@ interface IState {
// be seeing.
serverIsAlive: boolean;
serverErrorIsFatal: boolean;
serverDeadError: string;
serverDeadError?: ReactNode;
// Our matrix client - part of state because we can't render the UI auth
// component without it.
@ -131,7 +130,7 @@ export default class Registration extends React.Component<IProps, IState> {
serverDeadError: "",
};
const {hsUrl, isUrl} = this.props.serverConfig;
const { hsUrl, isUrl } = this.props.serverConfig;
this.loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
});
@ -142,7 +141,7 @@ export default class Registration extends React.Component<IProps, IState> {
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
@ -180,7 +179,7 @@ export default class Registration extends React.Component<IProps, IState> {
}
}
const {hsUrl, isUrl} = serverConfig;
const { hsUrl, isUrl } = serverConfig;
const cli = createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
@ -223,13 +222,14 @@ 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.
if (ssoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({action: 'start_login'});
dis.dispatch({ action: 'start_login' });
} else {
this.setState({
serverErrorIsFatal: true, // fatal because user cannot continue on this server
@ -245,7 +245,7 @@ export default class Registration extends React.Component<IProps, IState> {
}
}
private onFormSubmit = formVals => {
private onFormSubmit = async (formVals): Promise<void> => {
this.setState({
errorText: "",
busy: true,
@ -266,9 +266,9 @@ export default class Registration extends React.Component<IProps, IState> {
session_id: sessionId,
}),
);
}
};
private onUIAuthFinished = async (success, response, extra) => {
private onUIAuthFinished = async (success: boolean, response: any) => {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
@ -290,8 +290,8 @@ export default class Registration extends React.Component<IProps, IState> {
},
);
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
<p>{ errorTop }</p>
<p>{ errorDetail }</p>
</div>;
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false;
@ -431,18 +431,16 @@ export default class Registration extends React.Component<IProps, IState> {
private onLoginClickWithCheck = async ev => {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true });
if (!sessionLoaded) {
// ok fine, there's still no session: really go to the login page
this.props.onLoginClick();
}
return sessionLoaded;
};
private renderRegisterComponent() {
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent('elements.Spinner');
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this.state.matrixClient}
@ -465,7 +463,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
@ -484,7 +482,13 @@ export default class Registration extends React.Component<IProps, IState> {
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
<h3 className="mx_AuthBody_centered">
{ _t("%(ssoButtons)s Or %(usernamePassword)s", { ssoButtons: "", usernamePassword: ""}).trim() }
{ _t(
"%(ssoButtons)s Or %(usernamePassword)s",
{
ssoButtons: "",
usernamePassword: "",
},
).trim() }
</h3>
</React.Fragment>;
}
@ -507,10 +511,6 @@ export default class Registration extends React.Component<IProps, IState> {
}
render() {
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let errorText;
const err = this.state.errorText;
if (err) {
@ -526,15 +526,15 @@ export default class Registration extends React.Component<IProps, IState> {
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
{ this.state.serverDeadError }
</div>
);
}
const signIn = <span className="mx_AuthBody_changeFlow">
{_t("Already have an account? <a>Sign in here</a>", {}, {
{ _t("Already have an account? <a>Sign in here</a>", {}, {
a: sub => <a onClick={this.onLoginClick} href="#">{ sub }</a>,
})}
}) }
</span>;
// Only show the 'go back' button if you're not looking at the form
@ -550,38 +550,47 @@ export default class Registration extends React.Component<IProps, IState> {
let regDoneText;
if (this.state.differentLoggedInUserId) {
regDoneText = <div>
<p>{_t(
<p>{ _t(
"Your new account (%(newAccountId)s) is registered, but you're already " +
"logged into a different account (%(loggedInUserId)s).", {
newAccountId: this.state.registeredUsername,
loggedInUserId: this.state.differentLoggedInUserId,
},
)}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
{_t("Continue with previous account")}
) }</p>
<p><AccessibleButton
element="span"
className="mx_linkButton"
onClick={async event => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_welcome_page" });
}
}}
>
{ _t("Continue with previous account") }
</AccessibleButton></p>
</div>;
} else if (this.state.formVals.password) {
// We're the client that started the registration
regDoneText = <h3>{_t(
regDoneText = <h3>{ _t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{ sub }</a>,
},
)}</h3>;
) }</h3>;
} else {
// We're not the original client: the user probably got to us by clicking the
// email validation link. We can't offer a 'go straight to your account' link
// as we don't have the original creds.
regDoneText = <h3>{_t(
regDoneText = <h3>{ _t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{ sub }</a>,
},
)}</h3>;
) }</h3>;
}
body = <div>
<h2>{_t("Registration Successful")}</h2>
<h2>{ _t("Registration Successful") }</h2>
{ regDoneText }
</div>;
} else {

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.
@ -15,41 +15,43 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
import * as sdk from '../../../index';
import {
SetupEncryptionStore,
PHASE_LOADING,
PHASE_INTRO,
PHASE_BUSY,
PHASE_DONE,
PHASE_CONFIRM_SKIP,
PHASE_FINISHED,
} from '../../../stores/SetupEncryptionStore';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton from '../../views/elements/AccessibleButton';
import Spinner from '../../views/elements/Spinner';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
function keyHasPassphrase(keyInfo) {
return (
function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
return Boolean(
keyInfo.passphrase &&
keyInfo.passphrase.salt &&
keyInfo.passphrase.iterations
keyInfo.passphrase.iterations,
);
}
@replaceableComponent("structures.auth.SetupEncryptionBody")
export default class SetupEncryptionBody extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
interface IProps {
onFinished: (boolean) => void;
}
constructor() {
super();
interface IState {
phase: Phase;
verificationRequest: VerificationRequest;
backupInfo: IKeyBackupInfo;
}
@replaceableComponent("structures.auth.SetupEncryptionBody")
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
constructor(props) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this._onStoreUpdate);
store.on("update", this.onStoreUpdate);
store.start();
this.state = {
phase: store.phase,
@ -61,10 +63,10 @@ export default class SetupEncryptionBody extends React.Component {
};
}
_onStoreUpdate = () => {
private onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === PHASE_FINISHED) {
this.props.onFinished();
if (store.phase === Phase.Finished) {
this.props.onFinished(true);
return;
}
this.setState({
@ -74,18 +76,18 @@ export default class SetupEncryptionBody extends React.Component {
});
};
componentWillUnmount() {
public componentWillUnmount() {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this._onStoreUpdate);
store.off("update", this.onStoreUpdate);
store.stop();
}
_onUsePassphraseClick = async () => {
private onUsePassphraseClick = async () => {
const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase();
}
};
_onVerifyClick = () => {
private onVerifyClick = () => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const requestPromise = cli.requestVerification(userId);
@ -99,44 +101,46 @@ export default class SetupEncryptionBody extends React.Component {
request.cancel();
},
});
}
};
onSkipClick = () => {
private onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
}
};
onSkipConfirmClick = () => {
private onSkipConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm();
}
};
onSkipBackClick = () => {
private onSkipBackClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterSkip();
}
};
onDoneClick = () => {
private onDoneClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
}
};
render() {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
private onEncryptionPanelClose = () => {
this.props.onFinished(false);
};
public render() {
const {
phase,
} = this.state;
if (this.state.verificationRequest) {
const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel");
return <EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.props.onFinished}
onClose={this.onEncryptionPanelClose}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
isRoomEncrypted={false}
/>;
} else if (phase === PHASE_INTRO) {
} else if (phase === Phase.Intro) {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
@ -147,85 +151,84 @@ export default class SetupEncryptionBody extends React.Component {
let useRecoveryKeyButton;
if (recoveryKeyPrompt) {
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this._onUsePassphraseClick}>
{recoveryKeyPrompt}
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}>
{ recoveryKeyPrompt }
</AccessibleButton>;
}
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = <AccessibleButton kind="primary" onClick={this._onVerifyClick}>
verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{ _t("Use another login") }
</AccessibleButton>;
}
return (
<div>
<p>{_t(
<p>{ _t(
"Verify your identity to access encrypted messages and prove your identity to others.",
)}</p>
) }</p>
<div className="mx_CompleteSecurity_actionRow">
{verifyButton}
{useRecoveryKeyButton}
{ verifyButton }
{ useRecoveryKeyButton }
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
{_t("Skip")}
{ _t("Skip") }
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_DONE) {
} else if (phase === Phase.Done) {
let message;
if (this.state.backupInfo) {
message = <p>{_t(
message = <p>{ _t(
"Your new session is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
)}</p>;
) }</p>;
} else {
message = <p>{_t(
message = <p>{ _t(
"Your new session is now verified. Other users will see it as trusted.",
)}</p>;
) }</p>;
}
return (
<div>
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified" />
{message}
{ message }
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="primary"
onClick={this.onDoneClick}
>
{_t("Done")}
{ _t("Done") }
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_CONFIRM_SKIP) {
} else if (phase === Phase.ConfirmSkip) {
return (
<div>
<p>{_t(
<p>{ _t(
"Without verifying, you wont have access to all your messages " +
"and may appear as untrusted to others.",
)}</p>
) }</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
className="warning"
kind="secondary"
onClick={this.onSkipConfirmClick}
>
{_t("Skip")}
{ _t("Skip") }
</AccessibleButton>
<AccessibleButton
kind="danger"
onClick={this.onSkipBackClick}
>
{_t("Go Back")}
{ _t("Go Back") }
</AccessibleButton>
</div>
</div>
);
} else if (phase === PHASE_BUSY || phase === PHASE_LOADING) {
const Spinner = sdk.getComponent('views.elements.Spinner');
} else if (phase === Phase.Busy || phase === Phase.Loading) {
return <Spinner />;
} else {
console.log(`SetupEncryptionBody: Unknown phase ${phase}`);

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.
@ -15,18 +15,22 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { ISSOFlow, LoginFlow, sendLoginRequest } from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform";
import SSOButtons from "../../views/elements/SSOButtons";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog';
import Field from '../../views/elements/Field';
import AccessibleButton from '../../views/elements/AccessibleButton';
import Spinner from "../../views/elements/Spinner";
import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
const LOGIN_VIEW = {
LOADING: 1,
@ -42,37 +46,49 @@ const FLOWS_TO_VIEWS = {
"m.login.sso": LOGIN_VIEW.SSO,
};
@replaceableComponent("structures.auth.SoftLogout")
export default class SoftLogout extends React.Component {
static propTypes = {
// Query parameters from MatrixChat
realQueryParams: PropTypes.object, // {loginToken}
// Called when the SSO login completes
onTokenLoginCompleted: PropTypes.func,
interface IProps {
// Query parameters from MatrixChat
realQueryParams: {
loginToken?: string;
};
fragmentAfterLogin?: string;
constructor() {
super();
// Called when the SSO login completes
onTokenLoginCompleted: () => void;
}
interface IState {
loginView: number;
keyBackupNeeded: boolean;
busy: boolean;
password: string;
errorText: string;
flows: LoginFlow[];
}
@replaceableComponent("structures.auth.SoftLogout")
export default class SoftLogout extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
loginView: LOGIN_VIEW.LOADING,
keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount)
busy: false,
password: "",
errorText: "",
flows: [],
};
}
componentDidMount(): void {
// We've ended up here when we don't need to - navigate to login
if (!Lifecycle.isSoftLogout()) {
dis.dispatch({action: "start_login"});
dis.dispatch({ action: "start_login" });
return;
}
this._initLogin();
this.initLogin();
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled()) {
@ -83,7 +99,6 @@ export default class SoftLogout extends React.Component {
}
onClearAll = () => {
const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog');
Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, {
onFinished: (wipeData) => {
if (!wipeData) return;
@ -94,11 +109,11 @@ export default class SoftLogout extends React.Component {
});
};
async _initLogin() {
private async initLogin() {
const queryParams = this.props.realQueryParams;
const hasAllParams = queryParams && queryParams['loginToken'];
if (hasAllParams) {
this.setState({loginView: LOGIN_VIEW.LOADING});
this.setState({ loginView: LOGIN_VIEW.LOADING });
this.trySsoLogin();
return;
}
@ -114,18 +129,18 @@ export default class SoftLogout extends React.Component {
}
onPasswordChange = (ev) => {
this.setState({password: ev.target.value});
this.setState({ password: ev.target.value });
};
onForgotPassword = () => {
dis.dispatch({action: 'start_password_recovery'});
dis.dispatch({ action: 'start_password_recovery' });
};
onPasswordLogin = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({busy: true});
this.setState({ busy: true });
const hsUrl = MatrixClientPeg.get().getHomeserverUrl();
const isUrl = MatrixClientPeg.get().getIdentityServerUrl();
@ -157,12 +172,12 @@ export default class SoftLogout extends React.Component {
Lifecycle.hydrateSession(credentials).catch((e) => {
console.error(e);
this.setState({busy: false, errorText: _t("Failed to re-authenticate")});
this.setState({ busy: false, errorText: _t("Failed to re-authenticate") });
});
};
async trySsoLogin() {
this.setState({busy: true});
this.setState({ busy: true });
const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
@ -177,7 +192,7 @@ export default class SoftLogout extends React.Component {
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
} catch (e) {
console.error(e);
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED });
return;
}
@ -185,13 +200,12 @@ export default class SoftLogout extends React.Component {
if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted();
}).catch((e) => {
console.error(e);
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
this.setState({ busy: false, loginView: LOGIN_VIEW.UNSUPPORTED });
});
}
_renderSignInSection() {
private renderSignInSection() {
if (this.state.loginView === LOGIN_VIEW.LOADING) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
@ -203,12 +217,9 @@ export default class SoftLogout extends React.Component {
}
if (this.state.loginView === LOGIN_VIEW.PASSWORD) {
const Field = sdk.getComponent("elements.Field");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let error = null;
if (this.state.errorText) {
error = <span className='mx_Login_error'>{this.state.errorText}</span>;
error = <span className='mx_Login_error'>{ this.state.errorText }</span>;
}
if (!introText) {
@ -217,8 +228,8 @@ export default class SoftLogout extends React.Component {
return (
<form onSubmit={this.onPasswordLogin}>
<p>{introText}</p>
{error}
<p>{ introText }</p>
{ error }
<Field
type="password"
label={_t("Password")}
@ -232,10 +243,10 @@ export default class SoftLogout extends React.Component {
type="submit"
disabled={this.state.busy}
>
{_t("Sign In")}
{ _t("Sign In") }
</AccessibleButton>
<AccessibleButton onClick={this.onForgotPassword} kind="link">
{_t("Forgotten your password?")}
{ _t("Forgotten your password?") }
</AccessibleButton>
</form>
);
@ -247,11 +258,11 @@ export default class SoftLogout extends React.Component {
} // else we already have a message and should use it (key backup warning)
const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso";
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType);
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
return (
<div>
<p>{introText}</p>
<p>{ introText }</p>
<SSOButtons
matrixClient={MatrixClientPeg.get()}
flow={flow}
@ -266,43 +277,39 @@ export default class SoftLogout extends React.Component {
// Default: assume unsupported/error
return (
<p>
{_t(
{ _t(
"You cannot sign in to your account. Please contact your " +
"homeserver admin for more information.",
)}
) }
</p>
);
}
render() {
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2>
{_t("You're signed out")}
{ _t("You're signed out") }
</h2>
<h3>{_t("Sign in")}</h3>
<h3>{ _t("Sign in") }</h3>
<div>
{this._renderSignInSection()}
{ this.renderSignInSection() }
</div>
<h3>{_t("Clear personal data")}</h3>
<h3>{ _t("Clear personal data") }</h3>
<p>
{_t(
{ _t(
"Warning: Your personal data (including encryption keys) is still stored " +
"in this session. Clear it if you're finished using this session, or want to sign " +
"in to another account.",
)}
) }
</p>
<div>
<AccessibleButton onClick={this.onClearAll} kind="danger">
{_t("Clear all data")}
{ _t("Clear all data") }
</AccessibleButton>
</div>
</AuthBody>

View file

@ -0,0 +1,93 @@
/*
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, { createRef, ReactNode, RefObject } from "react";
import PlayPauseButton from "./PlayPauseButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { formatBytes } from "../../../utils/FormattingUtils";
import DurationClock from "./DurationClock";
import { Key } from "../../../Keyboard";
import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase from "./AudioPlayerBase";
@replaceableComponent("views.audio_messages.AudioPlayer")
export default class AudioPlayer extends AudioPlayerBase {
private playPauseRef: RefObject<PlayPauseButton> = createRef();
private seekRef: RefObject<SeekBar> = createRef();
private onKeyDown = (ev: React.KeyboardEvent) => {
// stopPropagation() prevents the FocusComposer catch-all from triggering,
// but we need to do it on key down instead of press (even though the user
// interaction is typically on press).
if (ev.key === Key.SPACE) {
ev.stopPropagation();
this.playPauseRef.current?.toggleState();
} else if (ev.key === Key.ARROW_LEFT) {
ev.stopPropagation();
this.seekRef.current?.left();
} else if (ev.key === Key.ARROW_RIGHT) {
ev.stopPropagation();
this.seekRef.current?.right();
}
};
protected renderFileSize(): string {
const bytes = this.props.playback.sizeBytes;
if (!bytes) return null;
// Not translated here - we're just presenting the data which should already
// be translated if needed.
return `(${formatBytes(bytes)})`;
}
protected renderComponent(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility
return (
<div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<div className='mx_AudioPlayer_primaryContainer'>
<PlayPauseButton
playback={this.props.playback}
playbackPhase={this.state.playbackPhase}
tabIndex={-1} // prevent tabbing into the button
ref={this.playPauseRef}
/>
<div className='mx_AudioPlayer_mediaInfo'>
<span className='mx_AudioPlayer_mediaName'>
{ this.props.mediaName || _t("Unnamed audio") }
</span>
<div className='mx_AudioPlayer_byline'>
<DurationClock playback={this.props.playback} />
&nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
</div>
</div>
</div>
<div className='mx_AudioPlayer_seek'>
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,70 @@
/*
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 { Playback, PlaybackState } from "../../../audio/Playback";
import { TileShape } from "../rooms/EventTile";
import React, { ReactNode } from "react";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { _t } from "../../../languageHandler";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
mediaName?: string;
tileShape?: TileShape;
}
interface IState {
playbackPhase: PlaybackState;
error?: boolean;
}
@replaceableComponent("views.audio_messages.AudioPlayerBase")
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
playbackPhase: PlaybackState.Decoding, // default assumption
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
// Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow.
this.props.playback.prepare().catch(e => {
console.error("Error processing audio file:", e);
this.setState({ error: true });
});
}
private onPlaybackUpdate = (ev: PlaybackState) => {
this.setState({ playbackPhase: ev });
};
protected abstract renderComponent(): ReactNode;
public render(): ReactNode {
return <>
{ this.renderComponent() }
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
}
}

View file

@ -15,28 +15,30 @@ limitations under the License.
*/
import React from "react";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { formatSeconds } from "../../../DateUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
export interface IProps {
seconds: number;
}
interface IState {
}
/**
* Simply converts seconds into minutes and seconds. Note that hours will not be
* displayed, making it possible to see "82:29".
*/
@replaceableComponent("views.voice_messages.Clock")
export default class Clock extends React.PureComponent<IProps, IState> {
@replaceableComponent("views.audio_messages.Clock")
export default class Clock extends React.Component<IProps> {
public constructor(props) {
super(props);
}
shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor;
}
public render() {
const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0');
const seconds = Math.round(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis
return <span className='mx_Clock'>{minutes}:{seconds}</span>;
return <span className='mx_Clock'>{ formatSeconds(this.props.seconds) }</span>;
}
}

View file

@ -0,0 +1,55 @@
/*
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 { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback } from "../../../audio/Playback";
interface IProps {
playback: Playback;
}
interface IState {
durationSeconds: number;
}
/**
* A clock which shows a clip's maximum duration.
*/
@replaceableComponent("views.audio_messages.DurationClock")
export default class DurationClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
super(props);
this.state = {
// we track the duration on state because we won't really know what the clip duration
// is until the first time update, and as a PureComponent we are trying to dedupe state
// updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
// member property to track "did we get a duration".
durationSeconds: this.props.playback.clockInfo.durationSeconds,
};
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onTimeUpdate = (time: number[]) => {
this.setState({ durationSeconds: time[1] });
};
public render() {
return <Clock seconds={this.state.durationSeconds} />;
}
}

View file

@ -0,0 +1,65 @@
/*
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 { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { MarkedExecution } from "../../../utils/MarkedExecution";
interface IProps {
recorder: VoiceRecording;
}
interface IState {
seconds: number;
}
/**
* A clock for a live recording.
*/
@replaceableComponent("views.audio_messages.LiveRecordingClock")
export default class LiveRecordingClock extends React.PureComponent<IProps, IState> {
private seconds = 0;
private scheduledUpdate = new MarkedExecution(
() => this.updateClock(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props) {
super(props);
this.state = {
seconds: 0,
};
}
componentDidMount() {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
this.seconds = update.timeSeconds;
this.scheduledUpdate.mark();
});
}
private updateClock() {
this.setState({
seconds: this.seconds,
});
}
public render() {
return <Clock seconds={this.state.seconds} />;
}
}

View file

@ -0,0 +1,69 @@
/*
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 { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { MarkedExecution } from "../../../utils/MarkedExecution";
interface IProps {
recorder: VoiceRecording;
}
interface IState {
waveform: number[];
}
/**
* A waveform which shows the waveform of a live recording
*/
@replaceableComponent("views.audio_messages.LiveRecordingWaveform")
export default class LiveRecordingWaveform extends React.PureComponent<IProps, IState> {
public static defaultProps = {
progress: 1,
};
private waveform: number[] = [];
private scheduledUpdate = new MarkedExecution(
() => this.updateWaveform(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props) {
super(props);
this.state = {
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
};
}
componentDidMount() {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
this.scheduledUpdate.mark();
});
}
private updateWaveform() {
this.setState({ waveform: this.waveform });
}
public render() {
return <Waveform relHeights={this.state.waveform} />;
}
}

View file

@ -0,0 +1,69 @@
/*
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, { ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../audio/Playback";
import classNames from "classnames";
// omitted props are handled by render function
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "title" | "onClick" | "disabled"> {
// Playback instance to manipulate. Cannot change during the component lifecycle.
playback: Playback;
// The playback phase to render. Able to change during the component lifecycle.
playbackPhase: PlaybackState;
}
/**
* Displays a play/pause button (activating the play/pause function of the recorder)
* to be displayed in reference to a recording.
*/
@replaceableComponent("views.audio_messages.PlayPauseButton")
export default class PlayPauseButton extends React.PureComponent<IProps> {
public constructor(props) {
super(props);
}
private onClick = () => {
// noinspection JSIgnoredPromiseFromCall
this.toggleState();
};
public async toggleState() {
await this.props.playback.toggle();
}
public render(): ReactNode {
const { playback, playbackPhase, ...restProps } = this.props;
const isPlaying = playback.isPlaying;
const isDisabled = playbackPhase === PlaybackState.Decoding;
const classes = classNames('mx_PlayPauseButton', {
'mx_PlayPauseButton_play': !isPlaying,
'mx_PlayPauseButton_pause': isPlaying,
'mx_PlayPauseButton_disabled': isDisabled,
});
return <AccessibleTooltipButton
className={classes}
title={isPlaying ? _t("Pause") : _t("Play")}
onClick={this.onClick}
disabled={isDisabled}
{...restProps}
/>;
}
}

View file

@ -0,0 +1,80 @@
/*
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 { replaceableComponent } from "../../../utils/replaceableComponent";
import Clock from "./Clock";
import { Playback, PlaybackState } from "../../../audio/Playback";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
interface IProps {
playback: Playback;
// The default number of seconds to show when the playback has completed or
// has not started. Not used during playback, even when paused. Defaults to
// clip duration length.
defaultDisplaySeconds?: number;
}
interface IState {
seconds: number;
durationSeconds: number;
playbackPhase: PlaybackState;
}
/**
* A clock for a playback of a recording.
*/
@replaceableComponent("views.audio_messages.PlaybackClock")
export default class PlaybackClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
super(props);
this.state = {
seconds: this.props.playback.clockInfo.timeSeconds,
// we track the duration on state because we won't really know what the clip duration
// is until the first time update, and as a PureComponent we are trying to dedupe state
// updates as much as possible. This is just the easiest way to avoid a forceUpdate() or
// member property to track "did we get a duration".
durationSeconds: this.props.playback.clockInfo.durationSeconds,
playbackPhase: PlaybackState.Stopped, // assume not started, so full clock
};
this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate);
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onPlaybackUpdate = (ev: PlaybackState) => {
// Convert Decoding -> Stopped because we don't care about the distinction here
if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped;
this.setState({ playbackPhase: ev });
};
private onTimeUpdate = (time: number[]) => {
this.setState({ seconds: time[0], durationSeconds: time[1] });
};
public render() {
let seconds = this.state.seconds;
if (this.state.playbackPhase === PlaybackState.Stopped) {
if (Number.isFinite(this.props.defaultDisplaySeconds)) {
seconds = this.props.defaultDisplaySeconds;
} else {
seconds = this.state.durationSeconds;
}
}
return <Clock seconds={seconds} />;
}
}

View file

@ -0,0 +1,68 @@
/*
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 { replaceableComponent } from "../../../utils/replaceableComponent";
import { arraySeed, arrayTrimFill } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
import { percentageOf } from "../../../utils/numbers";
interface IProps {
playback: Playback;
}
interface IState {
heights: number[];
progress: number;
}
/**
* A waveform which shows the waveform of a previously recorded recording
*/
@replaceableComponent("views.audio_messages.PlaybackWaveform")
export default class PlaybackWaveform extends React.PureComponent<IProps, IState> {
public constructor(props) {
super(props);
this.state = {
heights: this.toHeights(this.props.playback.waveform),
progress: 0, // default no progress
};
this.props.playback.waveformData.onUpdate(this.onWaveformUpdate);
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private toHeights(waveform: number[]) {
const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed);
}
private onWaveformUpdate = (waveform: number[]) => {
this.setState({ heights: this.toHeights(waveform) });
};
private onTimeUpdate = (time: number[]) => {
// Track percentages to a general precision to avoid over-waking the component.
const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3));
this.setState({ progress });
};
public render() {
return <Waveform relHeights={this.state.heights} progress={this.state.progress} />;
}
}

View file

@ -0,0 +1,43 @@
/*
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, { ReactNode } from "react";
import PlayPauseButton from "./PlayPauseButton";
import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform";
import AudioPlayerBase from "./AudioPlayerBase";
@replaceableComponent("views.audio_messages.RecordingPlayback")
export default class RecordingPlayback extends AudioPlayerBase {
private get isWaveformable(): boolean {
return this.props.tileShape !== TileShape.Notif
&& this.props.tileShape !== TileShape.FileGrid
&& this.props.tileShape !== TileShape.Pinned;
}
protected renderComponent(): ReactNode {
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return (
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
<PlaybackClock playback={this.props.playback} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>
);
}
}

View file

@ -0,0 +1,112 @@
/*
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 { Playback, PlaybackState } from "../../../audio/Playback";
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MarkedExecution } from "../../../utils/MarkedExecution";
import { percentageOf } from "../../../utils/numbers";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
// Tab index for the underlying component. Useful if the seek bar is in a managed state.
// Defaults to zero.
tabIndex?: number;
playbackPhase: PlaybackState;
}
interface IState {
percentage: number;
}
interface ISeekCSS extends CSSProperties {
'--fillTo': number;
}
const ARROW_SKIP_SECONDS = 5; // arbitrary
@replaceableComponent("views.audio_messages.SeekBar")
export default class SeekBar extends React.PureComponent<IProps, IState> {
// We use an animation frame request to avoid overly spamming prop updates, even if we aren't
// really using anything demanding on the CSS front.
private animationFrameFn = new MarkedExecution(
() => this.doUpdate(),
() => requestAnimationFrame(() => this.animationFrameFn.trigger()));
public static defaultProps = {
tabIndex: 0,
};
constructor(props: IProps) {
super(props);
this.state = {
percentage: 0,
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark());
}
private doUpdate() {
this.setState({
percentage: percentageOf(
this.props.playback.clockInfo.timeSeconds,
0,
this.props.playback.clockInfo.durationSeconds),
});
}
public left() {
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS);
}
public right() {
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS);
}
private onChange = (ev: ChangeEvent<HTMLInputElement>) => {
// Thankfully, onChange is only called when the user changes the value, not when we
// change the value on the component. We can use this as a reliable "skip to X" function.
//
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds);
};
public render(): ReactNode {
// We use a range input to avoid having to re-invent accessibility handling on
// a custom set of divs.
return <input
type="range"
className='mx_SeekBar'
tabIndex={this.props.tabIndex}
onChange={this.onChange}
min={0}
max={1}
value={this.state.percentage}
step={0.001}
style={{ '--fillTo': this.state.percentage } as ISeekCSS}
disabled={this.props.playbackPhase === PlaybackState.Decoding}
/>;
}
}

View file

@ -0,0 +1,67 @@
/*
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 { replaceableComponent } from "../../../utils/replaceableComponent";
import classNames from "classnames";
import { CSSProperties } from "react";
interface WaveformCSSProperties extends CSSProperties {
'--barHeight': number;
}
interface IProps {
relHeights: number[]; // relative heights (0-1)
progress: number; // percent complete, 0-1, default 100%
}
interface IState {
}
/**
* A simple waveform component. This renders bars (centered vertically) for each
* height provided in the component properties. Updating the properties will update
* the rendered waveform.
*
* For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be
* "filled", as a demonstration of the progress property.
*/
@replaceableComponent("views.audio_messages.Waveform")
export default class Waveform extends React.PureComponent<IProps, IState> {
public static defaultProps = {
progress: 1,
};
public render() {
return <div className='mx_Waveform'>
{ this.props.relHeights.map((h, i) => {
const progress = this.props.progress;
const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0;
const classes = classNames({
'mx_Waveform_bar': true,
'mx_Waveform_bar_100pct': isCompleteBar,
});
return <span
key={i}
style={{
"--barHeight": h,
} as WaveformCSSProperties}
className={classes}
/>;
}) }
</div>;
}
}

View file

@ -15,11 +15,11 @@ limitations under the License.
*/
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthBody")
export default class AuthBody extends React.PureComponent {
render() {
public render(): React.ReactNode {
return <div className="mx_AuthBody">
{ this.props.children }
</div>;

View file

@ -18,11 +18,11 @@ limitations under the License.
import { _t } from '../../../languageHandler';
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthFooter")
export default class AuthFooter extends React.Component {
render() {
public render(): React.ReactNode {
return (
<div className="mx_AuthFooter">
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a>

View file

@ -16,20 +16,17 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AuthHeaderLogo from "./AuthHeaderLogo";
import LanguageSelector from "./LanguageSelector";
interface IProps {
disableLanguageSelector?: boolean;
}
@replaceableComponent("views.auth.AuthHeader")
export default class AuthHeader extends React.Component {
static propTypes = {
disableLanguageSelector: PropTypes.bool,
};
render() {
const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo');
const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector');
export default class AuthHeader extends React.Component<IProps> {
public render(): React.ReactNode {
return (
<div className="mx_AuthHeader">
<AuthHeaderLogo />

View file

@ -15,11 +15,11 @@ limitations under the License.
*/
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.AuthHeaderLogo")
export default class AuthHeaderLogo extends React.PureComponent {
render() {
public render(): React.ReactNode {
return <div className="mx_AuthHeaderLogo">
Matrix
</div>;

View file

@ -17,18 +17,16 @@ limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AuthFooter from "./AuthFooter";
@replaceableComponent("views.auth.AuthPage")
export default class AuthPage extends React.PureComponent {
render() {
const AuthFooter = sdk.getComponent('auth.AuthFooter');
public render(): React.ReactNode {
return (
<div className="mx_AuthPage">
<div className="mx_AuthPage_modal">
{this.props.children}
{ this.props.children }
</div>
<AuthFooter />
</div>

View file

@ -14,67 +14,75 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import React, { createRef } from 'react';
import { _t } from '../../../languageHandler';
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
const DIV_ID = 'mx_recaptcha';
interface ICaptchaFormProps {
sitePublicKey: string;
onCaptchaResponse: (response: string) => void;
}
interface ICaptchaFormState {
errorText?: string;
}
/**
* A pure UI component which displays a captcha form.
*/
@replaceableComponent("views.auth.CaptchaForm")
export default class CaptchaForm extends React.Component {
static propTypes = {
sitePublicKey: PropTypes.string,
// called with the captcha response
onCaptchaResponse: PropTypes.func,
};
export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICaptchaFormState> {
static defaultProps = {
onCaptchaResponse: () => {},
};
constructor(props) {
private captchaWidgetId?: string;
private recaptchaContainer = createRef<HTMLDivElement>();
constructor(props: ICaptchaFormProps) {
super(props);
this.state = {
errorText: null,
errorText: undefined,
};
this._captchaWidgetId = null;
this._recaptchaContainer = createRef();
CountlyAnalytics.instance.track("onboarding_grecaptcha_begin");
}
componentDidMount() {
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
// so we do this instead.
if (global.grecaptcha) {
if (this.isRecaptchaReady()) {
// already loaded
this._onCaptchaLoaded();
this.onCaptchaLoaded();
} else {
console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
window.mxOnRecaptchaLoaded = () => { this.onCaptchaLoaded(); };
const scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit`,
'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit`,
);
this._recaptchaContainer.current.appendChild(scriptTag);
this.recaptchaContainer.current.appendChild(scriptTag);
}
}
componentWillUnmount() {
this._resetRecaptcha();
this.resetRecaptcha();
}
_renderRecaptcha(divId) {
if (!global.grecaptcha) {
// Borrowed directly from: https://github.com/codeep/react-recaptcha-google/commit/e118fa5670fa268426969323b2e7fe77698376ba
private isRecaptchaReady(): boolean {
return typeof window !== "undefined" &&
typeof global.grecaptcha !== "undefined" &&
typeof global.grecaptcha.render === 'function';
}
private renderRecaptcha(divId: string) {
if (!this.isRecaptchaReady()) {
console.error("grecaptcha not loaded!");
throw new Error("Recaptcha did not load successfully");
}
@ -84,26 +92,26 @@ export default class CaptchaForm extends React.Component {
console.error("No public key for recaptcha!");
throw new Error(
"This server has not supplied enough information for Recaptcha "
+ "authentication");
+ "authentication");
}
console.info("Rendering to %s", divId);
this._captchaWidgetId = global.grecaptcha.render(divId, {
this.captchaWidgetId = global.grecaptcha.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,
});
}
_resetRecaptcha() {
if (this._captchaWidgetId !== null) {
global.grecaptcha.reset(this._captchaWidgetId);
private resetRecaptcha() {
if (this.captchaWidgetId) {
global?.grecaptcha?.reset(this.captchaWidgetId);
}
}
_onCaptchaLoaded() {
private onCaptchaLoaded() {
console.log("Loaded recaptcha script.");
try {
this._renderRecaptcha(DIV_ID);
this.renderRecaptcha(DIV_ID);
// clear error if re-rendered
this.setState({
errorText: null,
@ -128,10 +136,10 @@ export default class CaptchaForm extends React.Component {
}
return (
<div ref={this._recaptchaContainer}>
<p>{_t(
<div ref={this.recaptchaContainer}>
<p>{ _t(
"This homeserver would like to make sure you are not a robot.",
)}</p>
) }</p>
<div id={DIV_ID} />
{ error }
</div>

View file

@ -15,11 +15,11 @@ limitations under the License.
*/
import React from 'react';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.auth.CompleteSecurityBody")
export default class CompleteSecurityBody extends React.PureComponent {
render() {
public render(): React.ReactNode {
return <div className="mx_CompleteSecurityBody">
{ this.props.children }
</div>;

View file

@ -15,21 +15,19 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import {COUNTRIES, getEmojiFlag} from '../../../phonenumber';
import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from '../../../phonenumber';
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Dropdown from "../elements/Dropdown";
const COUNTRIES_BY_ISO2 = {};
for (const c of COUNTRIES) {
COUNTRIES_BY_ISO2[c.iso2] = c;
}
function countryMatchesSearchQuery(query, country) {
function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDefinition): boolean {
// Remove '+' if present (when searching for a prefix)
if (query[0] === '+') {
query = query.slice(1);
@ -41,15 +39,26 @@ function countryMatchesSearchQuery(query, country) {
return false;
}
@replaceableComponent("views.auth.CountryDropdown")
export default class CountryDropdown extends React.Component {
constructor(props) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this._onOptionChange = this._onOptionChange.bind(this);
this._getShortOption = this._getShortOption.bind(this);
interface IProps {
value?: string;
onOptionChange: (country: PhoneNumberCountryDefinition) => void;
isSmall: boolean; // if isSmall, show +44 in the selected value
showPrefix: boolean;
className?: string;
disabled?: boolean;
}
let defaultCountry = COUNTRIES[0];
interface IState {
searchQuery: string;
defaultCountry: PhoneNumberCountryDefinition;
}
@replaceableComponent("views.auth.CountryDropdown")
export default class CountryDropdown extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0];
const defaultCountryCode = SdkConfig.get()["defaultCountryCode"];
if (defaultCountryCode) {
const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase());
@ -62,7 +71,7 @@ export default class CountryDropdown extends React.Component {
};
}
componentDidMount() {
public componentDidMount(): void {
if (!this.props.value) {
// If no value is given, we start with the default
// country selected, but our parent component
@ -71,21 +80,21 @@ export default class CountryDropdown extends React.Component {
}
}
_onSearchChange(search) {
private onSearchChange = (search: string): void => {
this.setState({
searchQuery: search,
});
}
};
_onOptionChange(iso2) {
private onOptionChange = (iso2: string): void => {
this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]);
}
};
_flagImgForIso2(iso2) {
private flagImgForIso2(iso2: string): React.ReactNode {
return <div className="mx_Dropdown_option_emoji">{ getEmojiFlag(iso2) }</div>;
}
_getShortOption(iso2) {
private getShortOption = (iso2: string): React.ReactNode => {
if (!this.props.isSmall) {
return undefined;
}
@ -94,14 +103,12 @@ export default class CountryDropdown extends React.Component {
countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix;
}
return <span className="mx_CountryDropdown_shortOption">
{ this._flagImgForIso2(iso2) }
{ this.flagImgForIso2(iso2) }
{ countryPrefix }
</span>;
}
render() {
const Dropdown = sdk.getComponent('elements.Dropdown');
};
public render(): React.ReactNode {
let displayedCountries;
if (this.state.searchQuery) {
displayedCountries = COUNTRIES.filter(
@ -124,7 +131,7 @@ export default class CountryDropdown extends React.Component {
const options = displayedCountries.map((country) => {
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this._flagImgForIso2(country.iso2) }
{ this.flagImgForIso2(country.iso2) }
{ _t(country.name) } (+{ country.prefix })
</div>;
});
@ -136,10 +143,10 @@ export default class CountryDropdown extends React.Component {
return <Dropdown
id="mx_CountryDropdown"
className={this.props.className + " mx_CountryDropdown"}
onOptionChange={this._onOptionChange}
onSearchChange={this._onSearchChange}
onOptionChange={this.onOptionChange}
onSearchChange={this.onSearchChange}
menuWidth={298}
getShortOption={this._getShortOption}
getShortOption={this.getShortOption}
value={value}
searchEnabled={true}
disabled={this.props.disabled}
@ -149,13 +156,3 @@ export default class CountryDropdown extends React.Component {
</Dropdown>;
}
}
CountryDropdown.propTypes = {
className: PropTypes.string,
isSmall: PropTypes.bool,
// if isSmall, show +44 in the selected value
showPrefix: PropTypes.bool,
onOptionChange: PropTypes.func.isRequired,
value: PropTypes.string,
disabled: PropTypes.bool,
};

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,17 +14,20 @@ 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 { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { LocalisedPolicy, Policies } from '../../../Terms';
import Field from '../elements/Field';
import CaptchaForm from "./CaptchaForm";
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -41,7 +42,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
* one HS whilst beign a guest on another).
* loginType: the login type of the auth stage being attempted
* authSessionId: session id from the server
* clientSecret: The client secret in use for ID server auth sessions
* clientSecret: The client secret in use for identity server auth sessions
* stageParams: params from the server for the stage being attempted
* errorText: error message from a previous attempt to authenticate
* submitAuthDict: a function which will be called with the new auth dict
@ -54,8 +55,8 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
* Defined keys for stages are:
* m.login.email.identity:
* * emailSid: string representing the sid of the active
* verification session from the ID server, or
* null if no session is active.
* verification session from the identity server,
* or null if no session is active.
* fail: a function which should be called with an error object if an
* error occurred during the auth stage. This will cause the auth
* session to be failed and the process to go back to the start.
@ -76,34 +77,43 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
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 +125,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,14 +133,13 @@ export class PasswordAuthEntry extends React.Component {
};
render() {
const passwordBoxClass = classnames({
const passwordBoxClass = classNames({
"error": this.props.errorText,
});
let submitButtonOrSpinner;
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
submitButtonOrSpinner = <Loader />;
submitButtonOrSpinner = <Spinner />;
} else {
submitButtonOrSpinner = (
<input type="submit"
@ -150,12 +159,10 @@ export class PasswordAuthEntry extends React.Component {
);
}
const Field = sdk.getComponent('elements.Field');
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,51 +170,49 @@ 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,
});
};
render() {
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
return <Spinner />;
}
let errorText = this.props.errorText;
const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm");
let sitePublicKey;
if (!this.props.stageParams || !this.props.stageParams.public_key) {
errorText = _t(
@ -230,7 +235,7 @@ export class RecaptchaAuthEntry extends React.Component {
return (
<div>
<CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this._onCaptchaResponse}
onCaptchaResponse={this.onCaptchaResponse}
/>
{ errorSection }
</div>
@ -238,18 +243,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 +309,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 = {
@ -306,16 +324,15 @@ export class TermsAuthEntry extends React.Component {
CountlyAnalytics.instance.track("onboarding_terms_begin");
}
componentDidMount() {
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];
@ -323,10 +340,10 @@ export class TermsAuthEntry extends React.Component {
newToggles[policy.id] = checked;
}
this.setState({"toggledPolicies": newToggles});
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,17 +351,16 @@ 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")});
this.setState({ errorText: _t("Please review and accept all of the homeserver's policies") });
}
};
render() {
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
return <Spinner />;
}
const checkboxes = [];
@ -356,7 +372,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>,
);
@ -374,13 +390,15 @@ export class TermsAuthEntry extends React.Component {
let submitButton;
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
submitButton = <button
className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit}
disabled={!allChecked}>{ _t("Accept") }</button>;
}
return (
<div>
<p>{_t("Please review and accept the policies of this homeserver:")}</p>
<p>{ _t("Please review and accept the policies of this homeserver:") }</p>
{ checkboxes }
{ errorSection }
{ submitButton }
@ -389,21 +407,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 +442,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,66 +452,73 @@ 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.setState({ requestingToken: true });
this.requestMsisdnToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
this.setState({ requestingToken: false });
});
}
/*
* 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 +528,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
@ -539,11 +561,10 @@ export class MsisdnAuthEntry extends React.Component {
render() {
if (this.state.requestingToken) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
return <Spinner />;
} else {
const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({
const submitClasses = classNames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_GeneralButton: true,
});
@ -558,25 +579,27 @@ 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}
aria-label={ _t("Code")}
onChange={this.onTokenChange}
aria-label={_t("Code")}
/>
<br />
<input type="submit" value={_t("Submit")}
<input
type="submit"
value={_t("Submit")}
className={submitClasses}
disabled={!enableSubmit}
/>
</form>
{errorSection}
{ errorSection }
</div>
</div>
);
@ -584,40 +607,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 +648,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.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
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({});
};
@ -672,21 +695,21 @@ export class SSOAuthEntry extends React.Component {
<AccessibleButton
onClick={this.props.onCancel}
kind={this.props.continueKind ? (this.props.continueKind + '_outline') : 'primary_outline'}
>{_t("Cancel")}</AccessibleButton>
>{ _t("Cancel") }</AccessibleButton>
);
if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) {
continueButton = (
<AccessibleButton
onClick={this.onStartAuthClick}
kind={this.props.continueKind || 'primary'}
>{this.props.continueText || _t("Single Sign On")}</AccessibleButton>
>{ this.props.continueText || _t("Single Sign On") }</AccessibleButton>
);
} else {
continueButton = (
<AccessibleButton
onClick={this.onConfirmClick}
kind={this.props.continueKind || 'primary'}
>{this.props.continueText || _t("Confirm")}</AccessibleButton>
>{ this.props.continueText || _t("Confirm") }</AccessibleButton>
);
}
@ -708,54 +731,45 @@ export class SSOAuthEntry extends React.Component {
return <React.Fragment>
{ errorSection }
<div className="mx_InteractiveAuthEntryComponents_sso_buttons">
{cancelButton}
{continueButton}
{ cancelButton }
{ continueButton }
</div>
</React.Fragment>;
}
}
@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 +777,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 +800,50 @@ export class FallbackAuthEntry extends React.Component {
}
return (
<div>
<a href="" ref={this._fallbackButton} onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
{errorSection}
<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;
}
}
return FallbackAuthEntry;
export interface IStageComponentProps extends IAuthEntryProps {
clientSecret?: string;
stageParams?: Record<string, any>;
inputs?: IInputs;
stageState?: IStageStatus;
showContinue?: boolean;
continueText?: string;
continueKind?: string;
fail?(e: Error): void;
setEmailSid?(sid: string): void;
onCancel?(): void;
}
export interface IStageComponent extends React.ComponentClass<React.PropsWithRef<IStageComponentProps>> {
tryContinue?(): void;
attemptFailed?(): void;
focus?(): void;
}
export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent {
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;
}
}

View file

@ -15,24 +15,26 @@ limitations under the License.
*/
import SdkConfig from "../../../SdkConfig";
import {getCurrentLanguage} from "../../../languageHandler";
import { getCurrentLanguage } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import PlatformPeg from "../../../PlatformPeg";
import * as sdk from '../../../index';
import React from 'react';
import {SettingLevel} from "../../../settings/SettingLevel";
import { SettingLevel } from "../../../settings/SettingLevel";
import LanguageDropdown from "../elements/LanguageDropdown";
function onChange(newLang) {
function onChange(newLang: string): void {
if (getCurrentLanguage() !== newLang) {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
}
}
export default function LanguageSelector({disabled}) {
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
interface IProps {
disabled?: boolean;
}
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
export default function LanguageSelector({ disabled }: IProps): JSX.Element {
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
return <LanguageDropdown
className="mx_AuthBody_language"
onOptionChange={onChange}

View file

@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {PureComponent, RefCallback, RefObject} from "react";
import React, { PureComponent, RefCallback, RefObject } from "react";
import classNames from "classnames";
import zxcvbn from "zxcvbn";
import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler";
import Field, {IInputProps} from "../elements/Field";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { _t, _td } from "../../../languageHandler";
import Field, { IInputProps } from "../elements/Field";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends Omit<IInputProps, "onValidate"> {
autoFocus?: boolean;

View file

@ -19,14 +19,14 @@ import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -52,8 +52,8 @@ interface IProps {
interface IState {
fieldValid: Partial<Record<LoginField, boolean>>;
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
password: "",
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone;
password: "";
}
enum LoginField {
@ -166,7 +166,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
};
private onPasswordChanged = ev => {
this.setState({password: ev.target.value});
this.setState({ password: ev.target.value });
};
private async verifyFieldsBeforeSubmit() {
@ -322,7 +322,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
}
};
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
const classes = {
@ -416,7 +416,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
kind="link"
onClick={this.onForgotPasswordClick}
>
{_t("Forgot password?")}
{ _t("Forgot password?") }
</AccessibleButton>;
}
@ -441,16 +441,16 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
disabled={this.props.disableSubmit}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{_t('Username')}
{ _t('Username') }
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{_t('Email address')}
{ _t('Email address') }
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{_t('Phone')}
{ _t('Phone') }
</option>
</Field>
</div>
@ -460,8 +460,8 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return (
<div>
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
{ loginType }
{ loginField }
<Field
className={pwFieldClass}
type="password"
@ -474,7 +474,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
onValidate={this.onPasswordValidate}
ref={field => this[LoginField.Password] = field}
/>
{forgotPasswordJsx}
{ forgotPasswordJsx }
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}

View file

@ -17,7 +17,6 @@ limitations under the License.
import React from 'react';
import * as sdk from '../../../index';
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
@ -25,12 +24,13 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics";
import Field from '../elements/Field';
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import CountryDropdown from "./CountryDropdown";
enum RegistrationField {
Email = "field_email",
@ -471,7 +471,6 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
if (!this.showPhoneNumber()) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
@ -538,15 +537,15 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
<div>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
{this.renderUsername()}
{ this.renderUsername() }
</div>
<div className="mx_AuthBody_fieldRow">
{this.renderPassword()}
{this.renderPasswordConfirm()}
{ this.renderPassword() }
{ this.renderPasswordConfirm() }
</div>
<div className="mx_AuthBody_fieldRow">
{this.renderEmail()}
{this.renderPhoneNumber()}
{ this.renderEmail() }
{ this.renderPhoneNumber() }
</div>
{ emailHelperText }
{ registerButton }

View file

@ -17,29 +17,34 @@ limitations under the License.
import React from 'react';
import classNames from "classnames";
import * as sdk from '../../../index';
import * as sdk from "../../../index";
import SdkConfig from '../../../SdkConfig';
import AuthPage from "./AuthPage";
import {_td} from "../../../languageHandler";
import { _td } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import { UIFeature } from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import LanguageSelector from "./LanguageSelector";
// translatable strings for Welcome pages
_td("Sign in with SSO");
interface IProps {
}
@replaceableComponent("views.auth.Welcome")
export default class Welcome extends React.PureComponent {
constructor(props) {
export default class Welcome extends React.PureComponent<IProps> {
constructor(props: IProps) {
super(props);
CountlyAnalytics.instance.track("onboarding_welcome");
}
render() {
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');
public render(): React.ReactNode {
// FIXME: Using an import will result in wrench-element-tests failures
const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage");
const pagesConfig = SdkConfig.get().embeddedPages;
let pageUrl = null;

View file

@ -17,15 +17,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useCallback, useContext, useEffect, useState} from 'react';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
import {ResizeMethod} from "../../../Avatar";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units";
import { _t } from '../../../languageHandler';
interface IProps {
@ -44,12 +46,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) {
@ -62,8 +64,14 @@ const calculateUrls = (url, urls) => {
return Array.from(new Set(_urls));
};
const useImageUrl = ({url, urls}): [string, () => void] => {
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls));
const useImageUrl = ({ url, urls }): [string, () => void] => {
// 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 +79,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
@ -107,7 +115,7 @@ const BaseAvatar = (props: IProps) => {
...otherProps
} = props;
const [imageUrl, onError] = useImageUrl({url, urls});
const [imageUrl, onError] = useImageUrl({ url, urls });
if (!imageUrl && defaultToInitialLetter) {
const initialLetter = AvatarLogic.getInitialLetter(name);
@ -179,7 +187,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
title={title}
alt={_t("Avatar")}
inputRef={inputRef}
{...otherProps} />
);
@ -193,7 +202,8 @@ const BaseAvatar = (props: IProps) => {
width: toPx(width),
height: toPx(height),
}}
title={title} alt=""
title={title}
alt=""
ref={inputRef}
{...otherProps} />
);

View file

@ -20,25 +20,24 @@ 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";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import {isPresenceEnabled} from "../../../utils/presence";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {_t} from "../../../languageHandler";
import { isPresenceEnabled } from "../../../utils/presence";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import TextWithTooltip from "../elements/TextWithTooltip";
import DMRoomMap from "../../../utils/DMRoomMap";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
interface IProps {
room: Room;
avatarSize: number;
tag: TagID;
displayBadge?: boolean;
forceCount?: boolean;
oobData?: object;
oobData?: IOOBData;
viewAvatarOnClick?: boolean;
}
@ -121,7 +120,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 });
}
}
};
@ -129,7 +131,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
if (this.isUnmounted) return;
const newIcon = this.getPresenceIcon();
if (newIcon !== this.state.icon) this.setState({icon: newIcon});
if (newIcon !== this.state.icon) this.setState({ icon: newIcon });
};
private getPresenceIcon(): Icon {
@ -203,8 +205,8 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
oobData={this.props.oobData}
viewAvatarOnClick={this.props.viewAvatarOnClick}
/>
{icon}
{badge}
{ icon }
{ badge }
</div>;
}
}

View file

@ -15,10 +15,11 @@ limitations under the License.
*/
import React from 'react';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import BaseAvatar from './BaseAvatar';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
export interface IProps {
groupId?: string;
@ -51,7 +52,7 @@ export default class GroupAvatar extends React.Component<IProps> {
// extract the props we use from props so we can pass any others through
// should consider adding this as a global rule in js-sdk?
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props;
const { groupId, groupAvatarUrl, groupName, ...otherProps } = this.props;
return (
<BaseAvatar

View file

@ -16,14 +16,14 @@ limitations under the License.
*/
import React from 'react';
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import { Action } from "../../../dispatcher/actions";
import BaseAvatar from "./BaseAvatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember;
@ -36,6 +36,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean;
title?: string;
style?: any;
}
interface IState {
@ -68,8 +69,8 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
let imageUrl = null;
if (props.member.getMxcAvatarUrl()) {
imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.width,
props.height,
props.resizeMethod,
);
}
@ -89,7 +90,7 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
}
render() {
let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props;
let { member, fallbackUserId, onClick, viewUserOnClick, ...otherProps } = this.props;
const userId = member ? member.userId : fallbackUserId;
if (viewUserOnClick) {
@ -102,8 +103,12 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
}
return (
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
idName={userId} url={this.state.imageUrl} onClick={onClick} />
<BaseAvatar {...otherProps}
name={this.state.name}
title={this.state.title}
idName={userId}
url={this.state.imageUrl}
onClick={onClick} />
);
}
}

View file

@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {_t} from "../../../languageHandler";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component {
@ -145,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
isExpanded={this.state.menuDisplayed}
label={_t("User Status")}
>
{avatar}
{ avatar }
</ContextMenuButton>
{ contextMenu }

View file

@ -13,29 +13,35 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ComponentProps} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import classNames from "classnames";
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import DMRoomMap from "../../../utils/DMRoomMap";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import { IOOBData } from '../../../stores/ThreepidInviteStore';
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
room?: Room;
// TODO: type when js-sdk has types
oobData?: any;
oobData?: IOOBData & {
roomId?: string;
};
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
className?: string;
onClick?(): void;
}
@ -93,8 +99,8 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
let oobAvatar = null;
if (props.oobData.avatarUrl) {
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.width,
props.height,
props.resizeMethod,
);
}
@ -109,12 +115,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
private static getRoomAvatarUrl(props: IProps): string {
if (!props.room) return null;
return Avatar.avatarUrlForRoom(
props.room,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
);
return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod);
}
private onRoomAvatarClick = () => {
@ -133,14 +134,21 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
};
public render() {
const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props;
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const roomName = room ? room.name : oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
return (
<BaseAvatar {...otherProps}
<BaseAvatar
{...otherProps}
className={classNames(className, {
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
})}
name={roomName}
idName={room ? room.roomId : null}
idName={idName}
urls={this.state.urls}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
/>

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {ComponentProps} from 'react';
import React, { ComponentProps } from 'react';
import classNames from 'classnames';
import {IApp} from "../../../stores/WidgetStore";
import BaseAvatar, {BaseAvatarType} from "./BaseAvatar";
import {mediaFromMxc} from "../../../customisations/Media";
import { IApp } from "../../../stores/WidgetStore";
import BaseAvatar, { BaseAvatarType } from "./BaseAvatar";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
app: IApp;
@ -49,7 +49,7 @@ const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 2
width={width}
height={height}
/>
)
);
};
export default WidgetAvatar;

View file

@ -0,0 +1,118 @@
/*
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";
import SettingsFlag from "../elements/SettingsFlag";
// XXX: Keep this around for re-use in future Betas
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, extraSettings } = 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 className="mx_BetaCard_columns">
<div>
<h3 className="mx_BetaCard_title">
{ titleOverride || _t(title) }
<BetaPill />
</h3>
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
<div className="mx_BetaCard_buttons">
{ feedbackButton }
<AccessibleButton
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
kind={feedbackButton ? "primary_outline" : "primary"}
>
{ value ? _t("Leave the beta") : _t("Join the beta") }
</AccessibleButton>
</div>
{ disclaimer && <div className="mx_BetaCard_disclaimer">
{ disclaimer(value) }
</div> }
</div>
<img src={image} alt="" />
</div>
{ extraSettings && value && <div className="mx_BetaCard_relatedSettings">
{ extraSettings.map(key => (
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
)) }
</div> }
</div>;
};
export default BetaCard;

View file

@ -22,7 +22,7 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import CallHandler from '../../../CallHandler';
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
import Modal from '../../../Modal';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends IContextMenuProps {
call: MatrixCall;
@ -42,21 +42,21 @@ export default class CallContextMenu extends React.Component<IProps> {
onHoldClick = () => {
this.props.call.setRemoteOnHold(true);
this.props.onFinished();
}
};
onUnholdClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
}
};
onTransferClick = () => {
Modal.createTrackedDialog(
'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call },
/*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true,
);
this.props.onFinished();
}
};
render() {
const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold");
@ -65,15 +65,15 @@ export default class CallContextMenu extends React.Component<IProps> {
let transferItem;
if (this.props.call.opponentCanBeTransferred()) {
transferItem = <MenuItem className="mx_CallContextMenu_item" onClick={this.onTransferClick}>
{_t("Transfer")}
{ _t("Transfer") }
</MenuItem>;
}
return <ContextMenu {...this.props}>
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
{holdUnholdCaption}
{ holdUnholdCaption }
</MenuItem>
{transferItem}
{ transferItem }
</ContextMenu>;
}
}

View file

@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import * as React from "react";
import { createRef } from "react";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Dialpad from '../voip/DialPad';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import Field from "../elements/Field";
import DialPad from '../voip/DialPad';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends IContextMenuProps {
call: MatrixCall;
@ -31,30 +33,62 @@ interface IState {
@replaceableComponent("views.context_menus.DialpadContextMenu")
export default class DialpadContextMenu extends React.Component<IProps, IState> {
private numberEntryFieldRef: React.RefObject<Field> = createRef();
constructor(props) {
super(props);
this.state = {
value: '',
}
};
}
onDigitPress = (digit) => {
onDigitPress = (digit: string, ev: ButtonEvent) => {
this.props.call.sendDtmfDigit(digit);
this.setState({value: this.state.value + digit});
}
this.setState({ value: this.state.value + digit });
// Keep the number field focused so that keyboard entry is still available
// However, don't focus if this wasn't the result of directly clicking on the button,
// i.e someone using keyboard navigation.
if (ev.type === "click") {
this.numberEntryFieldRef.current?.focus();
}
};
onCancelClick = () => {
this.props.onFinished();
};
onKeyDown = (ev) => {
// Prevent Backspace and Delete keys from functioning in the entry field
if (ev.code === "Backspace" || ev.code === "Delete") {
ev.preventDefault();
}
};
onChange = (ev) => {
this.setState({ value: ev.target.value });
};
render() {
return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header">
<div className="mx_DialPadContextMenuWrapper">
<div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
<AccessibleButton className="mx_DialPadContextMenu_cancel" onClick={this.onCancelClick} />
</div>
<div className="mx_DialPadContextMenu_header">
<Field
ref={this.numberEntryFieldRef}
className="mx_DialPadContextMenu_dialled"
value={this.state.value}
autoFocus={true}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
/>
</div>
<div className="mx_DialPadContextMenu_dialPad">
<DialPad onDigitPress={this.onDigitPress} hasDial={false} />
</div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div>
</div>
<div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
</div>
</ContextMenu>;
}

View file

@ -16,14 +16,13 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
/*
* This component can be used to display generic HTML content in a contextual
* menu.
*/
@replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component {
static propTypes = {

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.GenericTextContextMenu")
export default class GenericTextContextMenu extends React.Component {

View file

@ -20,10 +20,10 @@ import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import {Group} from 'matrix-js-sdk/src/models/group';
import { Group } from 'matrix-js-sdk/src/models/group';
import GroupStore from "../../../stores/GroupStore";
import {MenuItem} from "../../structures/ContextMenu";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { MenuItem } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.GroupInviteTileContextMenu")
export default class GroupInviteTileContextMenu extends React.Component {

View file

@ -64,8 +64,8 @@ export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
label={label}
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{label}</span>
{active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" />}
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
</MenuItemRadio>;
};
@ -85,30 +85,34 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
label={label}
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{label}</span>
{active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" />}
<span className="mx_IconizedContextMenu_label">{ label }</span>
<span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
</MenuItemCheckbox>;
};
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({label, iconClassName, ...props}) => {
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
return <MenuItem {...props} label={label}>
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
<span className="mx_IconizedContextMenu_label">{label}</span>
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ children }
</MenuItem>;
};
export const IconizedContextMenuOptionList: React.FC<IOptionListProps> = ({first, red, className, children}) => {
export const IconizedContextMenuOptionList: React.FC<IOptionListProps> = ({ first, red, className, children }) => {
const classes = classNames("mx_IconizedContextMenu_optionList", className, {
mx_IconizedContextMenu_optionList_notFirst: !first,
mx_IconizedContextMenu_optionList_red: red,
});
return <div className={classes}>
{children}
{ children }
</div>;
};
const IconizedContextMenu: React.FC<IProps> = ({className, children, compact, ...props}) => {
const IconizedContextMenu: React.FC<IProps> = ({ className, children, compact, ...props }) => {
const classes = classNames("mx_IconizedContextMenu", className, {
mx_IconizedContextMenu_compact: compact,
});

View file

@ -1,476 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
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";
function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
}
@replaceableComponent("views.context_menus.MessageContextMenu")
export default class MessageContextMenu extends React.Component {
static propTypes = {
/* the MatrixEvent associated with the context menu */
mxEvent: PropTypes.object.isRequired,
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
eventTileOps: PropTypes.object,
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
collapseReplyThread: PropTypes.func,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func,
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
onCloseDialog: PropTypes.func,
};
state = {
canRedact: false,
canPin: false,
};
componentDidMount() {
MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
this._checkPermissions();
}
componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
}
}
_checkPermissions = () => {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
// 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
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);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
this.setState({canRedact, canPin});
};
_isPinned() {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}
onResendClick = () => {
Resend.resend(this.props.mxEvent);
this.closeMenu();
};
onResendEditClick = () => {
Resend.resend(this.props.mxEvent.replacingEvent());
this.closeMenu();
};
onResendRedactionClick = () => {
Resend.resend(this.props.mxEvent.localRedactionEvent());
this.closeMenu();
};
onResendReactionsClick = () => {
for (const reaction of this._getUnsentReactions()) {
Resend.resend(reaction);
}
this.closeMenu();
};
onReportEventClick = () => {
const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog");
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
mxEvent: this.props.mxEvent,
}, 'mx_Dialog_reportEvent');
this.closeMenu();
};
onViewSourceClick = () => {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
mxEvent: this.props.mxEvent,
}, 'mx_Dialog_viewsource');
this.closeMenu();
};
onRedactClick = () => {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: async (proceed, reason) => {
if (!proceed) return;
const cli = MatrixClientPeg.get();
try {
if (this.props.onCloseDialog) this.props.onCloseDialog();
await cli.redactEvent(
this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(),
undefined,
reason ? { reason } : {},
);
} catch (e) {
const code = e.errcode || e.statusCode;
// only show the dialog if failing for something other than a network error
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
// detached queue and we show the room status bar to allow retry
if (typeof code !== "undefined") {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// display error message stating you couldn't delete this.
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
title: _t('Error'),
description: _t('You cannot delete this message. (%(code)s)', {code}),
});
}
}
},
}, 'mx_Dialog_confirmredact');
this.closeMenu();
};
onCancelSendClick = () => {
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const pendingReactions = this._getPendingReactions();
if (editEvent && canCancel(editEvent.status)) {
Resend.removeFromQueue(editEvent);
}
if (redactEvent && canCancel(redactEvent.status)) {
Resend.removeFromQueue(redactEvent);
}
if (pendingReactions.length) {
for (const reaction of pendingReactions) {
Resend.removeFromQueue(reaction);
}
}
if (canCancel(mxEvent.status)) {
Resend.removeFromQueue(this.props.mxEvent);
}
this.closeMenu();
};
onForwardClick = () => {
if (this.props.onCloseDialog) this.props.onCloseDialog();
dis.dispatch({
action: 'forward_event',
event: this.props.mxEvent,
});
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();
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
});
this.closeMenu();
};
closeMenu = () => {
if (this.props.onFinished) this.props.onFinished();
};
onUnhidePreviewClick = () => {
if (this.props.eventTileOps) {
this.props.eventTileOps.unhideWidget();
}
this.closeMenu();
};
onQuoteClick = () => {
dis.dispatch({
action: 'quote',
event: this.props.mxEvent,
});
this.closeMenu();
};
onPermalinkClick = (e: Event) => {
e.preventDefault();
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
};
onCollapseReplyThreadClick = () => {
this.props.collapseReplyThread();
this.closeMenu();
};
_getReactions(filter) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId();
return room.getPendingEvents().filter(e => {
const relation = e.getRelation();
return relation &&
relation.rel_type === "m.annotation" &&
relation.event_id === eventId &&
filter(e);
});
}
_getPendingReactions() {
return this._getReactions(e => canCancel(e.status));
}
_getUnsentReactions() {
return this._getReactions(e => e.status === EventStatus.NOT_SENT);
}
render() {
const cli = MatrixClientPeg.get();
const me = cli.getUserId();
const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const unsentReactionsCount = this._getUnsentReactions().length;
const pendingReactionsCount = this._getPendingReactions().length;
const allowCancel = canCancel(mxEvent.status) ||
canCancel(editStatus) ||
canCancel(redactStatus) ||
pendingReactionsCount !== 0;
let resendButton;
let resendEditButton;
let resendReactionsButton;
let resendRedactionButton;
let redactButton;
let cancelButton;
let forwardButton;
let pinButton;
let unhidePreviewButton;
let externalURLButton;
let quoteButton;
let collapseReplyThread;
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) {
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</MenuItem>
);
}
if (editStatus === EventStatus.NOT_SENT) {
resendEditButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendEditClick}>
{ _t('Resend edit') }
</MenuItem>
);
}
if (unsentReactionsCount !== 0) {
resendReactionsButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
{ _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) }
</MenuItem>
);
}
}
if (redactStatus === EventStatus.NOT_SENT) {
resendRedactionButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onResendRedactionClick}>
{ _t('Resend removal') }
</MenuItem>
);
}
if (isSent && this.state.canRedact) {
redactButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
{ _t('Remove') }
</MenuItem>
);
}
if (allowCancel) {
cancelButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
</MenuItem>
);
}
if (isContentActionable(mxEvent)) {
forwardButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
</MenuItem>
);
if (this.state.canPin) {
pinButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</MenuItem>
);
}
}
const viewSourceButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onViewSourceClick}>
{ _t('View Source') }
</MenuItem>
);
if (this.props.eventTileOps) {
if (this.props.eventTileOps.isWidgetHidden()) {
unhidePreviewButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onUnhidePreviewClick}>
{ _t('Unhide Preview') }
</MenuItem>
);
}
}
let permalink;
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
const permalinkButton = (
<MenuItem
element="a"
className="mx_MessageContextMenu_field"
onClick={this.onPermalinkClick}
href={permalink}
target="_blank"
rel="noreferrer noopener"
>
{ mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message'
? _t('Share Permalink') : _t('Share Message') }
</MenuItem>
);
if (this.props.eventTileOps) { // this event is rendered using TextualBody
quoteButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onQuoteClick}>
{ _t('Quote') }
</MenuItem>
);
}
// Bridges can provide a 'external_url' to link back to the source.
if (
typeof(mxEvent.event.content.external_url) === "string" &&
isUrlPermitted(mxEvent.event.content.external_url)
) {
externalURLButton = (
<MenuItem
element="a"
className="mx_MessageContextMenu_field"
target="_blank"
rel="noreferrer noopener"
onClick={this.closeMenu}
href={mxEvent.event.content.external_url}
>
{ _t('Source URL') }
</MenuItem>
);
}
if (this.props.collapseReplyThread) {
collapseReplyThread = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
{ _t('Collapse Reply Thread') }
</MenuItem>
);
}
let reportEventButton;
if (mxEvent.getSender() !== me) {
reportEventButton = (
<MenuItem className="mx_MessageContextMenu_field" onClick={this.onReportEventClick}>
{ _t('Report Content') }
</MenuItem>
);
}
return (
<div className="mx_MessageContextMenu">
{ resendButton }
{ resendEditButton }
{ resendReactionsButton }
{ resendRedactionButton }
{ redactButton }
{ cancelButton }
{ forwardButton }
{ pinButton }
{ viewSourceButton }
{ unhidePreviewButton }
{ permalinkButton }
{ quoteButton }
{ externalURLButton }
{ collapseReplyThread }
{ reportEventButton }
</div>
);
}
}

View file

@ -0,0 +1,440 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
import ForwardDialog from "../dialogs/ForwardDialog";
import { Action } from "../../../dispatcher/actions";
import ReportEventDialog from '../dialogs/ReportEventDialog';
import ViewSource from '../../structures/ViewSource';
import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog';
import ErrorDialog from '../dialogs/ErrorDialog';
import ShareDialog from '../dialogs/ShareDialog';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
}
export interface IEventTileOps {
isWidgetHidden(): boolean;
unhideWidget(): void;
}
export interface IOperableEventTile {
getEventTileOps(): IEventTileOps;
}
interface IProps {
/* the MatrixEvent associated with the context menu */
mxEvent: MatrixEvent;
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
eventTileOps?: IEventTileOps;
permalinkCreator?: RoomPermalinkCreator;
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
collapseReplyThread?(): void;
/* callback called when the menu is dismissed */
onFinished(): void;
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
onCloseDialog?(): void;
}
interface IState {
canRedact: boolean;
canPin: boolean;
}
@replaceableComponent("views.context_menus.MessageContextMenu")
export default class MessageContextMenu extends React.Component<IProps, IState> {
state = {
canRedact: false,
canPin: false,
};
componentDidMount() {
MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions);
this.checkPermissions();
}
componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener('RoomMember.powerLevel', this.checkPermissions);
}
}
private checkPermissions = (): void => {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
// 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
&& 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;
this.setState({ canRedact, canPin });
};
private isPinned(): boolean {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
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());
}
private onResendReactionsClick = (): void => {
for (const reaction of this.getUnsentReactions()) {
Resend.resend(reaction);
}
this.closeMenu();
};
private onReportEventClick = (): void => {
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
mxEvent: this.props.mxEvent,
}, 'mx_Dialog_reportEvent');
this.closeMenu();
};
private onViewSourceClick = (): void => {
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
mxEvent: this.props.mxEvent,
}, 'mx_Dialog_viewsource');
this.closeMenu();
};
private onRedactClick = (): void => {
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: async (proceed: boolean, reason?: string) => {
if (!proceed) return;
const cli = MatrixClientPeg.get();
try {
this.props.onCloseDialog?.();
await cli.redactEvent(
this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(),
undefined,
reason ? { reason } : {},
);
} catch (e) {
const code = e.errcode || e.statusCode;
// only show the dialog if failing for something other than a network error
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
// detached queue and we show the room status bar to allow retry
if (typeof code !== "undefined") {
// display error message stating you couldn't delete this.
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
title: _t('Error'),
description: _t('You cannot delete this message. (%(code)s)', { code }),
});
}
}
},
}, 'mx_Dialog_confirmredact');
this.closeMenu();
};
private onForwardClick = (): void => {
Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
matrixClient: MatrixClientPeg.get(),
event: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
};
private onPinClick = (): void => {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId();
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().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();
};
private closeMenu = (): void => {
this.props.onFinished();
};
private onUnhidePreviewClick = (): void => {
this.props.eventTileOps?.unhideWidget();
this.closeMenu();
};
private onQuoteClick = (): void => {
dis.dispatch({
action: Action.ComposerInsert,
event: this.props.mxEvent,
});
this.closeMenu();
};
private onPermalinkClick = (e: React.MouseEvent): void => {
e.preventDefault();
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
};
private onCollapseReplyThreadClick = (): void => {
this.props.collapseReplyThread();
this.closeMenu();
};
private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId();
return room.getPendingEvents().filter(e => {
const relation = e.getRelation();
return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e);
});
}
private getPendingReactions(): MatrixEvent[] {
return this.getReactions(e => canCancel(e.status));
}
private getUnsentReactions(): MatrixEvent[] {
return this.getReactions(e => e.status === EventStatus.NOT_SENT);
}
render() {
const cli = MatrixClientPeg.get();
const me = cli.getUserId();
const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status;
const unsentReactionsCount = this.getUnsentReactions().length;
let resendReactionsButton: JSX.Element;
let redactButton: JSX.Element;
let forwardButton: JSX.Element;
let pinButton: JSX.Element;
let unhidePreviewButton: JSX.Element;
let externalURLButton: JSX.Element;
let quoteButton: JSX.Element;
let collapseReplyThread: JSX.Element;
let redactItemList: JSX.Element;
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) {
if (unsentReactionsCount !== 0) {
resendReactionsButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconResend"
label={_t('Resend %(unsentCount)s reaction(s)', { unsentCount: unsentReactionsCount })}
onClick={this.onResendReactionsClick}
/>
);
}
}
if (isSent && this.state.canRedact) {
redactButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconRedact"
label={_t("Remove")}
onClick={this.onRedactClick}
/>
);
}
if (isContentActionable(mxEvent)) {
forwardButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconForward"
label={_t("Forward")}
onClick={this.onForwardClick}
/>
);
if (this.state.canPin) {
pinButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPin"
label={this.isPinned() ? _t('Unpin') : _t('Pin')}
onClick={this.onPinClick}
/>
);
}
}
const viewSourceButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconSource"
label={_t("View source")}
onClick={this.onViewSourceClick}
/>
);
if (this.props.eventTileOps) {
if (this.props.eventTileOps.isWidgetHidden()) {
unhidePreviewButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconUnhidePreview"
label={_t("Show preview")}
onClick={this.onUnhidePreviewClick}
/>
);
}
}
let permalink;
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
const permalinkButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPermalink"
onClick={this.onPermalinkClick}
label={_t('Share')}
element="a"
{
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
...{
href: permalink,
target: "_blank",
rel: "noreferrer noopener",
}
}
/>
);
if (this.props.eventTileOps) { // this event is rendered using TextualBody
quoteButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconQuote"
label={_t("Quote")}
onClick={this.onQuoteClick}
/>
);
}
// Bridges can provide a 'external_url' to link back to the source.
if (typeof (mxEvent.getContent().external_url) === "string" &&
isUrlPermitted(mxEvent.getContent().external_url)
) {
externalURLButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconLink"
onClick={this.closeMenu}
label={_t('Source URL')}
element="a"
{
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
...{
target: "_blank",
rel: "noreferrer noopener",
href: mxEvent.getContent().external_url,
}
}
/>
);
}
if (this.props.collapseReplyThread) {
collapseReplyThread = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconCollapse"
label={_t("Collapse reply thread")}
onClick={this.onCollapseReplyThreadClick}
/>
);
}
let reportEventButton: JSX.Element;
if (mxEvent.getSender() !== me) {
reportEventButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconReport"
label={_t("Report")}
onClick={this.onReportEventClick}
/>
);
}
const commonItemsList = (
<IconizedContextMenuOptionList>
{ quoteButton }
{ forwardButton }
{ pinButton }
{ permalinkButton }
{ reportEventButton }
{ externalURLButton }
{ unhidePreviewButton }
{ viewSourceButton }
{ resendReactionsButton }
{ collapseReplyThread }
</IconizedContextMenuOptionList>
);
if (redactButton) {
redactItemList = (
<IconizedContextMenuOptionList red>
{ redactButton }
</IconizedContextMenuOptionList>
);
}
return (
<IconizedContextMenu
{...this.props}
className="mx_MessageContextMenu"
compact={true}
>
{ commonItemsList }
{ redactItemList }
</IconizedContextMenu>
);
}
}

View file

@ -0,0 +1,216 @@
/*
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, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
IProps as IContextMenuProps,
} from "../../structures/ContextMenu";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { _t } from "../../../languageHandler";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { BetaPill } from "../beta/BetaCard";
interface IProps extends IContextMenuProps {
space: Room;
}
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
let inviteOption;
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(space);
onFinished();
};
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(space);
onFinished();
};
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={onSettingsClick}
/>
);
} else {
const onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
leaveSpace(space);
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(space);
onFinished();
};
const onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(space);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewSubspace(space);
onFinished();
};
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space },
});
onFinished();
};
const onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
});
onFinished();
};
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
};
export default SpaceContextMenu;

View file

@ -17,10 +17,10 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.StatusMessageContextMenu")
export default class StatusMessageContextMenu extends React.Component {
@ -99,20 +99,22 @@ export default class StatusMessageContextMenu extends React.Component {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick}
>
<span>{_t("Clear status")}</span>
<span>{ _t("Clear status") }</span>
</AccessibleButton>;
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit}
>
<span>{_t("Update status")}</span>
<span>{ _t("Update status") }</span>
</AccessibleButton>;
}
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} onClick={this._onSubmit}
actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message}
onClick={this._onSubmit}
>
<span>{_t("Set status")}</span>
<span>{ _t("Set status") }</span>
</AccessibleButton>;
}
@ -121,17 +123,24 @@ export default class StatusMessageContextMenu extends React.Component {
spinner = <Spinner w="24" h="24" />;
}
const form = <form className="mx_StatusMessageContextMenu_form"
autoComplete="off" onSubmit={this._onSubmit}
const form = <form
className="mx_StatusMessageContextMenu_form"
autoComplete="off"
onSubmit={this._onSubmit}
>
<input type="text" className="mx_StatusMessageContextMenu_message"
key="message" placeholder={_t("Set a new status...")}
autoFocus={true} maxLength="60" value={this.state.message}
<input
type="text"
className="mx_StatusMessageContextMenu_message"
key="message"
placeholder={_t("Set a new status...")}
autoFocus={true}
maxLength="60"
value={this.state.message}
onChange={this._onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">
{actionButton}
{spinner}
{ actionButton }
{ spinner }
</div>
</form>;

View file

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

View file

@ -14,22 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext} from "react";
import {MatrixCapabilities} from "matrix-widget-api";
import React, { useContext } from "react";
import { MatrixCapabilities } from "matrix-widget-api";
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
import {ChevronFace} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import {IApp} from "../../../stores/WidgetStore";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { ChevronFace } from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import { IApp } from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import RoomContext from "../../../contexts/RoomContext";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog";
import ErrorDialog from "../dialogs/ErrorDialog";
import {WidgetType} from "../../../widgets/WidgetType";
import { WidgetType } from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream";
@ -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,11 +49,12 @@ const WidgetContextMenu: React.FC<IProps> = ({
app,
userWidget,
onDeleteClick,
onEditClick,
showUnpin,
...props
}) => {
const cli = useContext(MatrixClientContext);
const {room, roomId} = useContext(RoomContext);
const { room, roomId } = useContext(RoomContext);
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId);
@ -73,7 +76,8 @@ const WidgetContextMenu: React.FC<IProps> = ({
onFinished();
};
streamAudioStreamButton = <IconizedContextMenuOption
onClick={onStreamAudioClick} label={_t("Start audio stream")}
onClick={onStreamAudioClick}
label={_t("Start audio stream")}
/>;
}
@ -89,12 +93,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 +124,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

@ -0,0 +1,67 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
interface IProps {
space: Room;
onCreateSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Add existing space")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new space instead?") }</div>
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
{ _t("Create a new space") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for spaces")}
spacesRenderer={defaultSpacesRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default AddExistingSubspaceDialog;

View file

@ -14,196 +14,437 @@ 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";
import { Room } from "matrix-js-sdk/src/models/room";
import { sleep } from "matrix-js-sdk/src/utils";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {_t} from '../../../languageHandler';
import {IDialogProps} from "./IDialogProps";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import {getDisplayAliasForRoom} from "../../../Rooms";
import { getDisplayAliasForRoom } from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {allSettled} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
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 DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
interface IProps {
space: Room;
onCreateRoomClick(cli: MatrixClient, space: Room): void;
onCreateRoomClick(): void;
onAddSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const Entry = ({ room, checked, onChange }) => {
return <div 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} />
</div>;
export const Entry = ({ room, checked, onChange }) => {
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;
filterPlaceholder: string;
emptySelectionButton?: ReactNode;
onFinished(added: boolean): void;
roomsRenderer?(
rooms: Room[],
selectedToAdd: Set<Room>,
onChange: undefined | ((checked: boolean, room: Room) => void),
truncateAt: number,
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
): ReactNode;
spacesRenderer?(
spaces: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
dmsRenderer?(
dms: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space,
footerPrompt,
emptySelectionButton,
filterPlaceholder,
roomsRenderer,
dmsRenderer,
spacesRenderer,
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 spaces = SpaceStore.instance.getSpaces().filter(s => {
return !existingSubspacesSet.has(s) // not already in space
&& space !== s // not the top-level space
&& selectedSpace !== s // not the selected space
&& s.name.toLowerCase().includes(lcQuery); // contains query
});
const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]);
const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]);
const existingRooms = SpaceStore.instance.getChildRooms(space.roomId);
const existingRoomsSet = new Set(existingRooms);
const rooms = cli.getVisibleRooms().filter(room => {
return !existingRoomsSet.has(room) // not already in space
&& !room.isSpaceRoom() // not a space itself
&& room.name.toLowerCase().includes(lcQuery) // contains query
&& !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM
});
const [spaces, rooms, dms] = useMemo(() => {
let rooms = visibleRooms;
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
let spaceOptionSection;
if (existingSubspacesSet.size > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
if (lcQuery) {
const matcher = new QueryMatcher<Room>(visibleRooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
});
spaceOptionSection = (
<Dropdown
id="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
}}
value={selectedSpace.roomId}
label={_t("Space selection")}
>
{ options }
</Dropdown>
);
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);
}
}
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;
}
}
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 {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
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 title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
<div>
<h1>{ _t("Add existing rooms") }</h1>
{ spaceOptionSection }
</div>
</React.Fragment>;
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
} : null;
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
}
name={text}
presenceState="online"
suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)}
/>
);
}
let noResults = true;
if ((roomsRenderer && rooms.length > 0) ||
(dmsRenderer && dms.length > 0) ||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
) {
noResults = false;
}
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={ _t("Filter your rooms and spaces") }
placeholder={filterPlaceholder}
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>
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 && roomsRenderer ? (
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
) : 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>
{ spaces.length > 0 && spacesRenderer ? (
spacesRenderer(spaces, selectedToAdd, onChange)
) : null }
{ spaces.length + rooms.length < 1 ? <span className="mx_AddExistingToSpaceDialog_noResults">
{ dms.length > 0 && dmsRenderer ? (
dmsRenderer(dms, selectedToAdd, onChange)
) : null }
{ noResults ? <span className="mx_AddExistingToSpace_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 className="mx_AddExistingToSpace_footer">
{ footer }
</div>
</div>;
};
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
);
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
{ spaces.map(space => {
return <Entry
key={space.roomId}
room={space}
checked={selectedToAdd.has(space)}
onChange={onChange ? (checked) => {
onChange(checked, space);
} : null}
/>;
}) }
</div>
);
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
<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: boolean) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
);
interface ISubspaceSelectorProps {
title: string;
space: Room;
value: Room;
onChange(space: Room): void;
}
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
const options = useMemo(() => {
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
})];
}, [space]);
let body;
if (options.length > 1) {
body = (
<Dropdown
id="mx_SpaceSelectDropdown"
className="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
onChange(options.find(space => space.roomId === key) || space);
}}
value={value.roomId}
label={_t("Space selection")}
>
{ options.map((space) => {
const classes = classNames({
mx_SubspaceSelector_dropdownOptionActive: space === value,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
}) }
</Dropdown>
);
} else {
body = (
<div className="mx_SubspaceSelector_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>
);
}
return <div className="mx_SubspaceSelector">
<RoomAvatar room={value} height={40} width={40} />
<div>
<h1>{ title }</h1>
{ body }
</div>
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Add existing rooms")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton
kind="link"
onClick={() => {
onCreateRoomClick();
onFinished();
}}
>
{ _t("Create a new room") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for rooms")}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={() => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Spaces") }</h3>
<AccessibleButton
kind="link"
onClick={() => {
onAddSubspaceClick();
onFinished();
}}
>
{ _t("Adding spaces has moved.") }
</AccessibleButton>
</div>
)}
dmsRenderer={defaultDmsRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>;
};

View file

@ -17,23 +17,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import React, { createRef } from 'react';
import { sleep } from "matrix-js-sdk/src/utils";
import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import { AddressType, addressTypes, getAddressType, IUserAddress } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils';
import { abbreviateUrl } from '../../../utils/UrlUtils';
import {sleep} from "../../../utils/promise";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AddressSelector from '../elements/AddressSelector';
import AddressTile from '../elements/AddressTile';
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -44,29 +46,64 @@ const addressTypeName = {
'email': _td("email address"),
};
@replaceableComponent("views.dialogs.AddressPickerDialog")
export default class AddressPickerDialog extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.node,
// Extra node inserted after picker input, dropdown and errors
extraNode: PropTypes.node,
value: PropTypes.string,
placeholder: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
roomId: PropTypes.string,
button: PropTypes.string,
focus: PropTypes.bool,
validAddressTypes: PropTypes.arrayOf(PropTypes.oneOf(addressTypes)),
onFinished: PropTypes.func.isRequired,
groupId: PropTypes.string,
// The type of entity to search for. Default: 'user'.
pickerType: PropTypes.oneOf(['user', 'room']),
// Whether the current user should be included in the addresses returned. Only
// applicable when pickerType is `user`. Default: false.
includeSelf: PropTypes.bool,
};
interface IResult {
user_id: string; // eslint-disable-line camelcase
room_id?: string; // eslint-disable-line camelcase
name?: string;
display_name?: string; // eslint-disable-line camelcase
avatar_url?: string;// eslint-disable-line camelcase
}
static defaultProps = {
interface IProps {
title: string;
description?: JSX.Element;
// Extra node inserted after picker input, dropdown and errors
extraNode?: JSX.Element;
value?: string;
placeholder?: ((validAddressTypes: any) => string) | string;
roomId?: string;
button?: string;
focus?: boolean;
validAddressTypes?: AddressType[];
onFinished: (success: boolean, list?: IUserAddress[]) => void;
groupId?: string;
// The type of entity to search for. Default: 'user'.
pickerType?: 'user' | 'room';
// Whether the current user should be included in the addresses returned. Only
// applicable when pickerType is `user`. Default: false.
includeSelf?: boolean;
}
interface IState {
// Whether to show an error message because of an invalid address
invalidAddressError: boolean;
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: IUserAddress[];
// Whether a search is ongoing
busy: boolean;
// An error message generated during the user directory search
searchError: string;
// Whether the server supports the user_directory API
serverSupportsUserDirectory: boolean;
// The query being searched for
query: string;
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: IUserAddress[];
// List of address types initialised from props, but may change while the
// dialog is open and represents the supported list of address types at this time.
validAddressTypes: AddressType[];
}
@replaceableComponent("views.dialogs.AddressPickerDialog")
export default class AddressPickerDialog extends React.Component<IProps, IState> {
private textinput = createRef<HTMLTextAreaElement>();
private addressSelector = createRef<AddressSelector>();
private queryChangedDebouncer: number;
private cancelThreepidLookup: () => void;
static defaultProps: Partial<IProps> = {
value: "",
focus: true,
validAddressTypes: addressTypes,
@ -74,36 +111,23 @@ export default class AddressPickerDialog extends React.Component {
includeSelf: false,
};
constructor(props) {
constructor(props: IProps) {
super(props);
this._textinput = createRef();
let validAddressTypes = this.props.validAddressTypes;
// Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user
if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes("email")) {
validAddressTypes = validAddressTypes.filter(type => type !== "email");
if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes(AddressType.Email)) {
validAddressTypes = validAddressTypes.filter(type => type !== AddressType.Email);
}
this.state = {
// Whether to show an error message because of an invalid address
invalidAddressError: false,
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: [],
// Whether a search is ongoing
busy: false,
// An error message generated during the user directory search
searchError: null,
// Whether the server supports the user_directory API
serverSupportsUserDirectory: true,
// The query being searched for
query: "",
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: [],
// List of address types initialised from props, but may change while the
// dialog is open and represents the supported list of address types at this time.
validAddressTypes,
};
}
@ -111,11 +135,11 @@ export default class AddressPickerDialog extends React.Component {
componentDidMount() {
if (this.props.focus) {
// Set the cursor at the end of the text input
this._textinput.current.value = this.props.value;
this.textinput.current.value = this.props.value;
}
}
getPlaceholder() {
private getPlaceholder(): string {
const { placeholder } = this.props;
if (typeof placeholder === "string") {
return placeholder;
@ -124,23 +148,23 @@ export default class AddressPickerDialog extends React.Component {
return placeholder(this.state.validAddressTypes);
}
onButtonClick = () => {
private onButtonClick = (): void => {
let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address
// If there is and it's valid add it to the local selectedList
if (this._textinput.current.value !== '') {
selectedList = this._addAddressesToList([this._textinput.current.value]);
if (this.textinput.current.value !== '') {
selectedList = this.addAddressesToList([this.textinput.current.value]);
if (selectedList === null) return;
}
this.props.onFinished(true, selectedList);
};
onCancel = () => {
private onCancel = (): void => {
this.props.onFinished(false);
};
onKeyDown = e => {
const textInput = this._textinput.current ? this._textinput.current.value : undefined;
private onKeyDown = (e: React.KeyboardEvent): void => {
const textInput = this.textinput.current ? this.textinput.current.value : undefined;
if (e.key === Key.ESCAPE) {
e.stopPropagation();
@ -149,15 +173,15 @@ export default class AddressPickerDialog extends React.Component {
} else if (e.key === Key.ARROW_UP) {
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionUp();
if (this.addressSelector.current) this.addressSelector.current.moveSelectionUp();
} else if (e.key === Key.ARROW_DOWN) {
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionDown();
if (this.addressSelector.current) this.addressSelector.current.moveSelectionDown();
} else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) {
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.chooseSelection();
if (this.addressSelector.current) this.addressSelector.current.chooseSelection();
} else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) {
e.stopPropagation();
e.preventDefault();
@ -169,17 +193,17 @@ export default class AddressPickerDialog extends React.Component {
// if there's nothing in the input box, submit the form
this.onButtonClick();
} else {
this._addAddressesToList([textInput]);
this.addAddressesToList([textInput]);
}
} else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) {
e.stopPropagation();
e.preventDefault();
this._addAddressesToList([textInput]);
this.addAddressesToList([textInput]);
}
};
onQueryChanged = ev => {
const query = ev.target.value;
private onQueryChanged = (ev: React.ChangeEvent): void => {
const query = (ev.target as HTMLTextAreaElement).value;
if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer);
}
@ -188,17 +212,17 @@ export default class AddressPickerDialog extends React.Component {
this.queryChangedDebouncer = setTimeout(() => {
if (this.props.pickerType === 'user') {
if (this.props.groupId) {
this._doNaiveGroupSearch(query);
this.doNaiveGroupSearch(query);
} else if (this.state.serverSupportsUserDirectory) {
this._doUserDirectorySearch(query);
this.doUserDirectorySearch(query);
} else {
this._doLocalSearch(query);
this.doLocalSearch(query);
}
} else if (this.props.pickerType === 'room') {
if (this.props.groupId) {
this._doNaiveGroupRoomSearch(query);
this.doNaiveGroupRoomSearch(query);
} else {
this._doRoomSearch(query);
this.doRoomSearch(query);
}
} else {
console.error('Unknown pickerType', this.props.pickerType);
@ -213,7 +237,7 @@ export default class AddressPickerDialog extends React.Component {
}
};
onDismissed = index => () => {
private onDismissed = (index: number) => () => {
const selectedList = this.state.selectedList.slice();
selectedList.splice(index, 1);
this.setState({
@ -221,25 +245,21 @@ export default class AddressPickerDialog extends React.Component {
suggestedList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
};
onClick = index => () => {
this.onSelected(index);
};
onSelected = index => {
private onSelected = (index: number): void => {
const selectedList = this.state.selectedList.slice();
selectedList.push(this._getFilteredSuggestions()[index]);
selectedList.push(this.getFilteredSuggestions()[index]);
this.setState({
selectedList,
suggestedList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
};
_doNaiveGroupSearch(query) {
private doNaiveGroupSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase();
this.setState({
busy: true,
@ -260,7 +280,7 @@ export default class AddressPickerDialog extends React.Component {
display_name: u.displayname,
});
});
this._processResults(results, query);
this.processResults(results, query);
}).catch((err) => {
console.error('Error whilst searching group rooms: ', err);
this.setState({
@ -273,7 +293,7 @@ export default class AddressPickerDialog extends React.Component {
});
}
_doNaiveGroupRoomSearch(query) {
private doNaiveGroupRoomSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase();
const results = [];
GroupStore.getGroupRooms(this.props.groupId).forEach((r) => {
@ -289,13 +309,13 @@ export default class AddressPickerDialog extends React.Component {
name: r.name || r.canonical_alias,
});
});
this._processResults(results, query);
this.processResults(results, query);
this.setState({
busy: false,
});
}
_doRoomSearch(query) {
private doRoomSearch(query: string): void {
const lowerCaseQuery = query.toLowerCase();
const rooms = MatrixClientPeg.get().getRooms();
const results = [];
@ -346,13 +366,13 @@ export default class AddressPickerDialog extends React.Component {
return a.rank - b.rank;
});
this._processResults(sortedResults, query);
this.processResults(sortedResults, query);
this.setState({
busy: false,
});
}
_doUserDirectorySearch(query) {
private doUserDirectorySearch(query: string): void {
this.setState({
busy: true,
query,
@ -366,7 +386,7 @@ export default class AddressPickerDialog extends React.Component {
if (this.state.query !== query) {
return;
}
this._processResults(resp.results, query);
this.processResults(resp.results, query);
}).catch((err) => {
console.error('Error whilst searching user directory: ', err);
this.setState({
@ -377,7 +397,7 @@ export default class AddressPickerDialog extends React.Component {
serverSupportsUserDirectory: false,
});
// Do a local search immediately
this._doLocalSearch(query);
this.doLocalSearch(query);
}
}).then(() => {
this.setState({
@ -386,7 +406,7 @@ export default class AddressPickerDialog extends React.Component {
});
}
_doLocalSearch(query) {
private doLocalSearch(query: string): void {
this.setState({
query,
searchError: null,
@ -407,10 +427,10 @@ export default class AddressPickerDialog extends React.Component {
avatar_url: user.avatarUrl,
});
});
this._processResults(results, query);
this.processResults(results, query);
}
_processResults(results, query) {
private processResults(results: IResult[], query: string): void {
const suggestedList = [];
results.forEach((result) => {
if (result.room_id) {
@ -457,7 +477,7 @@ export default class AddressPickerDialog extends React.Component {
const addrType = getAddressType(query);
if (this.state.validAddressTypes.includes(addrType)) {
if (addrType === 'email' && !Email.looksValid(query)) {
this.setState({searchError: _t("That doesn't look like a valid email address")});
this.setState({ searchError: _t("That doesn't look like a valid email address") });
return;
}
suggestedList.unshift({
@ -465,27 +485,27 @@ export default class AddressPickerDialog extends React.Component {
address: query,
isKnown: false,
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
if (addrType === 'email') {
this._lookupThreepid(addrType, query);
this.lookupThreepid(addrType, query);
}
}
this.setState({
suggestedList,
invalidAddressError: false,
}, () => {
if (this.addressSelector) this.addressSelector.moveSelectionTop();
if (this.addressSelector.current) this.addressSelector.current.moveSelectionTop();
});
}
_addAddressesToList(addressTexts) {
private addAddressesToList(addressTexts: string[]): IUserAddress[] {
const selectedList = this.state.selectedList.slice();
let hasError = false;
addressTexts.forEach((addressText) => {
addressText = addressText.trim();
const addrType = getAddressType(addressText);
const addrObj = {
const addrObj: IUserAddress = {
addressType: addrType,
address: addressText,
isKnown: false,
@ -504,7 +524,6 @@ export default class AddressPickerDialog extends React.Component {
const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) {
addrObj.displayName = room.name;
addrObj.avatarMxc = room.avatarUrl;
addrObj.isKnown = true;
}
}
@ -518,17 +537,17 @@ export default class AddressPickerDialog extends React.Component {
query: "",
invalidAddressError: hasError ? true : this.state.invalidAddressError,
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (this.cancelThreepidLookup) this.cancelThreepidLookup();
return hasError ? null : selectedList;
}
async _lookupThreepid(medium, address) {
private async lookupThreepid(medium: AddressType, address: string): Promise<string> {
let cancelled = false;
// Note that we can't safely remove this after we're done
// because we don't know that it's the same one, so we just
// leave it: it's replacing the old one each time so it's
// not like they leak.
this._cancelThreepidLookup = function() {
this.cancelThreepidLookup = function() {
cancelled = true;
};
@ -570,29 +589,29 @@ export default class AddressPickerDialog extends React.Component {
}
}
_getFilteredSuggestions() {
private getFilteredSuggestions(): IUserAddress[] {
// map addressType => set of addresses to avoid O(n*m) operation
const selectedAddresses = {};
this.state.selectedList.forEach(({address, addressType}) => {
this.state.selectedList.forEach(({ address, addressType }) => {
if (!selectedAddresses[addressType]) selectedAddresses[addressType] = new Set();
selectedAddresses[addressType].add(address);
});
// Filter out any addresses in the above already selected addresses (matching both type and address)
return this.state.suggestedList.filter(({address, addressType}) => {
return this.state.suggestedList.filter(({ address, addressType }) => {
return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address));
});
}
_onPaste = e => {
private onPaste = (e: React.ClipboardEvent): void => {
// Prevent the text being pasted into the textarea
e.preventDefault();
const text = e.clipboardData.getData("text");
// Process it as a list of addresses to add instead
this._addAddressesToList(text.split(/[\s,]+/));
this.addAddressesToList(text.split(/[\s,]+/));
};
onUseDefaultIdentityServerClick = e => {
private onUseDefaultIdentityServerClick = (e: React.MouseEvent): void => {
e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms.
@ -601,33 +620,27 @@ export default class AddressPickerDialog extends React.Component {
// Add email as a valid address type.
const { validAddressTypes } = this.state;
validAddressTypes.push('email');
validAddressTypes.push(AddressType.Email);
this.setState({ validAddressTypes });
};
onManageSettingsClick = e => {
private onManageSettingsClick = (e: React.MouseEvent): void => {
e.preventDefault();
dis.fire(Action.ViewUserSettings);
this.onCancel();
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
let inputLabel;
if (this.props.description) {
inputLabel = <div className="mx_AddressPickerDialog_label">
<label htmlFor="textinput">{this.props.description}</label>
<label htmlFor="textinput">{ this.props.description }</label>
</div>;
}
const query = [];
// create the invite list
if (this.state.selectedList.length > 0) {
const AddressTile = sdk.getComponent("elements.AddressTile");
for (let i = 0; i < this.state.selectedList.length; i++) {
query.push(
<AddressTile
@ -644,19 +657,19 @@ export default class AddressPickerDialog extends React.Component {
query.push(
<textarea
key={this.state.selectedList.length}
onPaste={this._onPaste}
rows="1"
onPaste={this.onPaste}
rows={1}
id="textinput"
ref={this._textinput}
ref={this.textinput}
className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged}
placeholder={this.getPlaceholder()}
defaultValue={this.props.value}
autoFocus={this.props.focus}>
</textarea>,
autoFocus={this.props.focus}
/>,
);
const filteredSuggestedList = this._getFilteredSuggestions();
const filteredSuggestedList = this.getFilteredSuggestions();
let error;
let addressSelector;
@ -675,7 +688,7 @@ export default class AddressPickerDialog extends React.Component {
error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>;
} else {
addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
<AddressSelector ref={this.addressSelector}
addressList={filteredSuggestedList}
showAddress={this.props.pickerType === 'user'}
onSelected={this.onSelected}
@ -686,11 +699,11 @@ export default class AddressPickerDialog extends React.Component {
let identityServer;
// If picker cannot currently accept e-mail but should be able to
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email')
&& this.props.validAddressTypes.includes('email')) {
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes(AddressType.Email)
&& this.props.validAddressTypes.includes(AddressType.Email)) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t(
"Use an identity server to invite by email. " +
"<default>Use the default (%(defaultIdentityServerName)s)</default> " +
"or manage in <settings>Settings</settings>.",
@ -698,25 +711,29 @@ export default class AddressPickerDialog extends React.Component {
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>;
) }</div>;
} else {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
identityServer = <div className="mx_AddressPickerDialog_identityServer">{ _t(
"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>;
) }</div>;
}
}
return (
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}>
{inputLabel}
<BaseDialog
className="mx_AddressPickerDialog"
onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished}
title={this.props.title}
>
{ inputLabel }
<div className="mx_Dialog_content">
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
{ error }

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