Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/a11y/focus-lock-ctx-menu
Conflicts: src/components/views/spaces/SpaceCreateMenu.tsx
This commit is contained in:
@@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
|
||||
style={style}
|
||||
className={["mx_AutoHideScrollbar", className].join(" ")}
|
||||
onWheel={onWheel}
|
||||
tabIndex={tabIndex}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order by default.
|
||||
tabIndex={tabIndex ?? -1}
|
||||
>
|
||||
{ children }
|
||||
</div>);
|
||||
|
||||
44
src/components/structures/BackdropPanel.tsx
Normal file
44
src/components/structures/BackdropPanel.tsx
Normal 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;
|
||||
186
src/components/structures/CallEventGrouper.ts
Normal file
186
src/components/structures/CallEventGrouper.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ 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 FocusLock from "react-focus-lock";
|
||||
@@ -47,7 +47,7 @@ function getOrCreateContainer(): HTMLDivElement {
|
||||
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
|
||||
const ARIA_MENU_ITEM_SELECTOR = '[role^="menuitem"], [role^="menuitemcheckbox"], [role^="menuitemradio"]';
|
||||
|
||||
interface IPosition {
|
||||
export interface IPosition {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
@@ -82,6 +82,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
|
||||
@@ -213,6 +217,11 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onClick = (ev: React.MouseEvent) => {
|
||||
// Don't allow clicks to escape the context menu wrapper
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// don't let keyboard handling escape the context menu
|
||||
ev.stopPropagation();
|
||||
@@ -309,10 +318,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||
|
||||
const menuClasses = classNames({
|
||||
'mx_ContextualMenu': true,
|
||||
'mx_ContextualMenu_left': !hasChevron && position.left,
|
||||
'mx_ContextualMenu_right': !hasChevron && position.right,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
|
||||
/**
|
||||
* In some cases we may get the number of 0, which still means that we're supposed to properly
|
||||
* add the specific position class, but as it was falsy things didn't work as intended.
|
||||
* In addition, defensively check for counter cases where we may get more than one value,
|
||||
* even if we shouldn't.
|
||||
*/
|
||||
'mx_ContextualMenu_left': !hasChevron && position.left !== undefined && !position.right,
|
||||
'mx_ContextualMenu_right': !hasChevron && position.right !== undefined && !position.left,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top !== undefined && !position.bottom,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom !== undefined && !position.top,
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
|
||||
@@ -364,6 +379,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
||||
style={{ ...position, ...wrapperStyle }}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onClick={this.onClick}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
>
|
||||
<div
|
||||
@@ -383,21 +399,41 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ToRightOf = {
|
||||
left: number;
|
||||
top: number;
|
||||
chevronOffset: number;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
||||
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
|
||||
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12): ToRightOf => {
|
||||
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 };
|
||||
};
|
||||
|
||||
export type AboveLeftOf = IPosition & {
|
||||
chevronFace: ChevronFace;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
|
||||
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => {
|
||||
export const aboveLeftOf = (
|
||||
elementRect: DOMRect,
|
||||
chevronFace = ChevronFace.None,
|
||||
vPadding = 0,
|
||||
): AboveLeftOf => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
@@ -454,10 +490,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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -17,7 +17,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import request from 'browser-request';
|
||||
import { _t } from '../../languageHandler';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
@@ -26,38 +25,43 @@ import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import classnames from 'classnames';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
|
||||
export default class EmbeddedPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// URL to request embedded page content from
|
||||
url: PropTypes.string,
|
||||
// Class name prefix to apply for a given instance
|
||||
className: PropTypes.string,
|
||||
// Whether to wrap the page in a scrollbar
|
||||
scrollbar: PropTypes.bool,
|
||||
// Map of keys to replace with values, e.g {$placeholder: "value"}
|
||||
replaceMap: PropTypes.object,
|
||||
};
|
||||
interface IProps {
|
||||
// URL to request embedded page content from
|
||||
url?: string;
|
||||
// Class name prefix to apply for a given instance
|
||||
className?: string;
|
||||
// Whether to wrap the page in a scrollbar
|
||||
scrollbar?: boolean;
|
||||
// Map of keys to replace with values, e.g {$placeholder: "value"}
|
||||
replaceMap?: Map<string, string>;
|
||||
}
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
interface IState {
|
||||
page: string;
|
||||
}
|
||||
|
||||
constructor(props, context) {
|
||||
export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
private unmounted = false;
|
||||
private dispatcherRef: string = null;
|
||||
|
||||
constructor(props: IProps, context: typeof MatrixClientContext) {
|
||||
super(props, context);
|
||||
|
||||
this._dispatcherRef = null;
|
||||
|
||||
this.state = {
|
||||
page: '',
|
||||
};
|
||||
}
|
||||
|
||||
translate(s) {
|
||||
protected translate(s: string): string {
|
||||
// default implementation - skins may wish to extend this
|
||||
return sanitizeHtml(_t(s));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unmounted = false;
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
|
||||
if (!this.props.url) {
|
||||
return;
|
||||
@@ -70,7 +74,7 @@ export default class EmbeddedPage extends React.PureComponent {
|
||||
request(
|
||||
{ method: "GET", url: this.props.url },
|
||||
(err, response, body) => {
|
||||
if (this._unmounted) {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,22 +96,22 @@ export default class EmbeddedPage extends React.PureComponent {
|
||||
},
|
||||
);
|
||||
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef);
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
if (this.dispatcherRef !== null) dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = (payload) => {
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
// HACK: Workaround for the context's MatrixClient not being set up at render time.
|
||||
if (payload.action === 'client_started') {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
// HACK: Workaround for the context's MatrixClient not updating.
|
||||
const client = this.context || MatrixClientPeg.get();
|
||||
const isGuest = client ? client.isGuest() : true;
|
||||
@@ -120,16 +124,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>;
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@ import React from 'react';
|
||||
|
||||
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 * as sdk from '../../index';
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import EventIndexPeg from "../../indexing/EventIndexPeg";
|
||||
import { _t } from '../../languageHandler';
|
||||
@@ -33,6 +33,10 @@ import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuild
|
||||
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;
|
||||
@@ -129,7 +133,7 @@ class FilePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchFileEventsServer(room: Room): Promise<void> {
|
||||
public async fetchFileEventsServer(room: Room): Promise<EventTimelineSet> {
|
||||
const client = MatrixClientPeg.get();
|
||||
|
||||
const filter = new Filter(client.credentials.userId);
|
||||
@@ -153,7 +157,11 @@ class FilePanel extends React.Component<IProps, IState> {
|
||||
return timelineSet;
|
||||
}
|
||||
|
||||
private onPaginationRequest = (timelineWindow: TimelineWindow, direction: string, limit: number): void => {
|
||||
private onPaginationRequest = (
|
||||
timelineWindow: TimelineWindow,
|
||||
direction: Direction,
|
||||
limit: number,
|
||||
): Promise<boolean> => {
|
||||
const client = MatrixClientPeg.get();
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const roomId = this.props.roomId;
|
||||
@@ -232,12 +240,10 @@ class FilePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -257,11 +263,12 @@ class FilePanel extends React.Component<IProps, IState> {
|
||||
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>
|
||||
);
|
||||
@@ -272,7 +279,7 @@ class FilePanel extends React.Component<IProps, IState> {
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
>
|
||||
<Loader />
|
||||
<Spinner />
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,20 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("structures.GenericErrorPage")
|
||||
export default class GenericErrorPage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
title: PropTypes.object.isRequired, // jsx for title
|
||||
message: PropTypes.object.isRequired, // jsx to display
|
||||
};
|
||||
interface IProps {
|
||||
title: React.ReactNode;
|
||||
message: React.ReactNode;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.GenericErrorPage")
|
||||
export default class GenericErrorPage extends React.PureComponent<IProps> {
|
||||
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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -30,22 +31,43 @@ import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import UserTagTile from "../views/elements/UserTagTile";
|
||||
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;
|
||||
}
|
||||
@@ -56,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;
|
||||
@@ -82,18 +106,18 @@ class GroupFilterPanel extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
onClick = e => {
|
||||
private onClick = e => {
|
||||
// only dispatch if its not a no-op
|
||||
if (this.state.selectedTags.length > 0) {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
}
|
||||
};
|
||||
|
||||
onClearFilterClick = ev => {
|
||||
private onClearFilterClick = ev => {
|
||||
dis.dispatch({ action: 'deselect_tags' });
|
||||
};
|
||||
|
||||
renderGlobalIcon() {
|
||||
private renderGlobalIcon() {
|
||||
if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null;
|
||||
|
||||
return (
|
||||
@@ -104,7 +128,7 @@ class GroupFilterPanel extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
public render() {
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
|
||||
@@ -122,19 +146,13 @@ 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">
|
||||
{ betaDot }
|
||||
</ActionButton>
|
||||
className="mx_TagTile mx_TagTile_plus"
|
||||
/>
|
||||
);
|
||||
|
||||
if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
|
||||
@@ -147,7 +165,7 @@ 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"
|
||||
onClick={this.onClick}
|
||||
@@ -36,11 +36,14 @@ import FlairStore from '../../stores/FlairStore';
|
||||
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks";
|
||||
import { Group } from "matrix-js-sdk/src/models/group";
|
||||
import { sleep } from "../../utils/promise";
|
||||
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 { 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>
|
||||
@@ -222,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>;
|
||||
}
|
||||
@@ -399,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 = {
|
||||
@@ -422,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() {
|
||||
@@ -807,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,
|
||||
@@ -819,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>
|
||||
@@ -843,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.") }
|
||||
|
||||
{ text }
|
||||
|
||||
{ _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() }
|
||||
@@ -1185,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'
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -1199,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>
|
||||
@@ -1238,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}>
|
||||
@@ -1269,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>,
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -14,34 +14,39 @@ 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, { createRef } from "react";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
|
||||
// by the parent element.
|
||||
trackHorizontalOverflow?: boolean;
|
||||
|
||||
// If true, when the user tries to use their mouse wheel in the component it will
|
||||
// scroll horizontally rather than vertically. This should only be used on components
|
||||
// with no vertical scroll opportunity.
|
||||
verticalScrollsHorizontally?: boolean;
|
||||
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
leftIndicatorOffset: number | string;
|
||||
rightIndicatorOffset: number | string;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.IndicatorScrollbar")
|
||||
export default class IndicatorScrollbar extends React.Component {
|
||||
static propTypes = {
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
|
||||
// by the parent element.
|
||||
trackHorizontalOverflow: PropTypes.bool,
|
||||
export default class IndicatorScrollbar extends React.Component<IProps, IState> {
|
||||
private autoHideScrollbar = createRef<AutoHideScrollbar>();
|
||||
private scrollElement: HTMLDivElement;
|
||||
private likelyTrackpadUser: boolean = null;
|
||||
private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
|
||||
|
||||
// If true, when the user tries to use their mouse wheel in the component it will
|
||||
// scroll horizontally rather than vertically. This should only be used on components
|
||||
// with no vertical scroll opportunity.
|
||||
verticalScrollsHorizontally: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this._collectScroller = this._collectScroller.bind(this);
|
||||
this._collectScrollerComponent = this._collectScrollerComponent.bind(this);
|
||||
this.checkOverflow = this.checkOverflow.bind(this);
|
||||
this._scrollElement = null;
|
||||
this._autoHideScrollbar = null;
|
||||
this._likelyTrackpadUser = null;
|
||||
this._checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
|
||||
|
||||
this.state = {
|
||||
leftIndicatorOffset: 0,
|
||||
@@ -49,30 +54,19 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
moveToOrigin() {
|
||||
if (!this._scrollElement) return;
|
||||
|
||||
this._scrollElement.scrollLeft = 0;
|
||||
this._scrollElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
_collectScroller(scroller) {
|
||||
if (scroller && !this._scrollElement) {
|
||||
this._scrollElement = scroller;
|
||||
private collectScroller = (scroller: HTMLDivElement): void => {
|
||||
if (scroller && !this.scrollElement) {
|
||||
this.scrollElement = scroller;
|
||||
// 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.scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
|
||||
this.checkOverflow();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_collectScrollerComponent(autoHideScrollbar) {
|
||||
this._autoHideScrollbar = autoHideScrollbar;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const prevLen = prevProps && prevProps.children && prevProps.children.length || 0;
|
||||
const curLen = this.props.children && this.props.children.length || 0;
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
const prevLen = React.Children.count(prevProps.children);
|
||||
const curLen = React.Children.count(this.props.children);
|
||||
// check overflow only if amount of children changes.
|
||||
// if we don't guard here, we end up with an infinite
|
||||
// render > componentDidUpdate > checkOverflow > setState > render loop
|
||||
@@ -81,62 +75,58 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
this.checkOverflow();
|
||||
}
|
||||
|
||||
checkOverflow() {
|
||||
const hasTopOverflow = this._scrollElement.scrollTop > 0;
|
||||
const hasBottomOverflow = this._scrollElement.scrollHeight >
|
||||
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
|
||||
const hasLeftOverflow = this._scrollElement.scrollLeft > 0;
|
||||
const hasRightOverflow = this._scrollElement.scrollWidth >
|
||||
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth);
|
||||
private checkOverflow = (): void => {
|
||||
const hasTopOverflow = this.scrollElement.scrollTop > 0;
|
||||
const hasBottomOverflow = this.scrollElement.scrollHeight >
|
||||
(this.scrollElement.scrollTop + this.scrollElement.clientHeight);
|
||||
const hasLeftOverflow = this.scrollElement.scrollLeft > 0;
|
||||
const hasRightOverflow = this.scrollElement.scrollWidth >
|
||||
(this.scrollElement.scrollLeft + this.scrollElement.clientWidth);
|
||||
|
||||
if (hasTopOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
|
||||
}
|
||||
if (hasBottomOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
|
||||
}
|
||||
if (hasLeftOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
|
||||
}
|
||||
if (hasRightOverflow) {
|
||||
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
|
||||
} else {
|
||||
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
|
||||
}
|
||||
|
||||
if (this.props.trackHorizontalOverflow) {
|
||||
this.setState({
|
||||
// Offset from absolute position of the container
|
||||
leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0',
|
||||
leftIndicatorOffset: hasLeftOverflow ? `${this.scrollElement.scrollLeft}px` : '0',
|
||||
|
||||
// Negative because we're coming from the right
|
||||
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0',
|
||||
rightIndicatorOffset: hasRightOverflow ? `-${this.scrollElement.scrollLeft}px` : '0',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getScrollTop() {
|
||||
return this._autoHideScrollbar.getScrollTop();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._scrollElement) {
|
||||
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
|
||||
public componentWillUnmount(): void {
|
||||
if (this.scrollElement) {
|
||||
this.scrollElement.removeEventListener("scroll", this.checkOverflow);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseWheel = (e) => {
|
||||
if (this.props.verticalScrollsHorizontally && this._scrollElement) {
|
||||
private onMouseWheel = (e: React.WheelEvent): void => {
|
||||
if (this.props.verticalScrollsHorizontally && this.scrollElement) {
|
||||
// xyThreshold is the amount of horizontal motion required for the component to
|
||||
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in
|
||||
// strange ways. Should be positive.
|
||||
@@ -150,19 +140,19 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
// for at least the next 1 minute.
|
||||
const now = new Date().getTime();
|
||||
if (Math.abs(e.deltaX) > 0) {
|
||||
this._likelyTrackpadUser = true;
|
||||
this._checkAgainForTrackpad = now + (1 * 60 * 1000);
|
||||
this.likelyTrackpadUser = true;
|
||||
this.checkAgainForTrackpad = now + (1 * 60 * 1000);
|
||||
} else {
|
||||
// if we haven't seen any horizontal scrolling for a while, assume
|
||||
// the user might have plugged in a mousewheel
|
||||
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) {
|
||||
this._likelyTrackpadUser = false;
|
||||
if (this.likelyTrackpadUser && now >= this.checkAgainForTrackpad) {
|
||||
this.likelyTrackpadUser = false;
|
||||
}
|
||||
}
|
||||
|
||||
// don't mess with the horizontal scroll for trackpad users
|
||||
// See https://github.com/vector-im/element-web/issues/10005
|
||||
if (this._likelyTrackpadUser) {
|
||||
if (this.likelyTrackpadUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,13 +168,13 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
|
||||
this._scrollElement.scrollLeft += val * yRetention;
|
||||
this.scrollElement.scrollLeft += val * yRetention;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
public render(): JSX.Element {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
|
||||
|
||||
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
|
||||
@@ -195,8 +185,8 @@ export default class IndicatorScrollbar extends React.Component {
|
||||
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
|
||||
|
||||
return (<AutoHideScrollbar
|
||||
ref={this._collectScrollerComponent}
|
||||
wrappedRef={this._collectScroller}
|
||||
ref={this.autoHideScrollbar}
|
||||
wrappedRef={this.collectScroller}
|
||||
onWheel={this.onMouseWheel}
|
||||
{...otherProps}
|
||||
>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
300
src/components/structures/InteractiveAuth.tsx
Normal file
300
src/components/structures/InteractiveAuth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ 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";
|
||||
@@ -33,15 +31,12 @@ 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 { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
@@ -53,7 +48,6 @@ interface IProps {
|
||||
|
||||
interface IState {
|
||||
showBreadcrumbs: boolean;
|
||||
showGroupFilterPanel: boolean;
|
||||
activeSpace?: Room;
|
||||
}
|
||||
|
||||
@@ -70,8 +64,6 @@ const cssClasses = [
|
||||
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;
|
||||
|
||||
@@ -80,22 +72,16 @@ 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") });
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -104,11 +90,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
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);
|
||||
UIStore.instance.stopTrackingElementDimensions("ListContainer");
|
||||
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
|
||||
@@ -149,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;
|
||||
@@ -392,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>
|
||||
@@ -429,30 +392,22 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
onSelectRoom={this.selectRoom}
|
||||
/>
|
||||
|
||||
{dialPadButton}
|
||||
{ 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={this.props.resizeNotifier}
|
||||
@@ -476,11 +431,10 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
|
||||
return (
|
||||
<div className={containerClasses} ref={this.ref}>
|
||||
{leftLeftPanel}
|
||||
<aside className="mx_LeftPanel_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchDialExplore()}
|
||||
{this.renderBreadcrumbs()}
|
||||
{ this.renderHeader() }
|
||||
{ this.renderSearchDialExplore() }
|
||||
{ this.renderBreadcrumbs() }
|
||||
<RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
|
||||
<div className="mx_LeftPanel_roomListWrapper">
|
||||
<div
|
||||
@@ -490,7 +444,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{roomList}
|
||||
{ roomList }
|
||||
</div>
|
||||
</div>
|
||||
{ !this.props.isMinimized && <LeftPanelWidget /> }
|
||||
|
||||
@@ -76,7 +76,6 @@ const LeftPanelWidget: React.FC = () => {
|
||||
<AppTile
|
||||
app={app}
|
||||
fullWidth
|
||||
show
|
||||
showMenubar={false}
|
||||
userWidget
|
||||
userId={cli.getUserId()}
|
||||
@@ -115,7 +114,7 @@ const LeftPanelWidget: React.FC = () => {
|
||||
aria-expanded={expanded}
|
||||
aria-level={1}
|
||||
onClick={() => {
|
||||
setExpanded(e => !e);
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<span className={classNames({
|
||||
@@ -125,15 +124,15 @@ const LeftPanelWidget: React.FC = () => {
|
||||
<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>
|
||||
|
||||
|
||||
115
src/components/structures/LegacyCommunityPreview.tsx
Normal file
115
src/components/structures/LegacyCommunityPreview.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
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 MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import { IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
|
||||
interface IProps {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
const onSwapClick = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Preferences,
|
||||
});
|
||||
};
|
||||
|
||||
// XXX: temporary community migration component, reuses SpaceRoomView & SpacePreview classes for simplicity
|
||||
const LegacyCommunityPreview = ({ groupId }: IProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
|
||||
|
||||
if (!groupSummary) {
|
||||
return <main className="mx_SpaceRoomView">
|
||||
<div className="mx_MainSplit">
|
||||
<div className="mx_SpaceRoomView_preview">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
</main>;
|
||||
}
|
||||
|
||||
let visibilitySection: JSX.Element;
|
||||
if (groupSummary.profile.is_public) {
|
||||
visibilitySection = <span className="mx_SpaceRoomView_info_public">
|
||||
{ _t("Public community") }
|
||||
</span>;
|
||||
} else {
|
||||
visibilitySection = <span className="mx_SpaceRoomView_info_private">
|
||||
{ _t("Private community") }
|
||||
</span>;
|
||||
}
|
||||
|
||||
return <main className="mx_SpaceRoomView">
|
||||
<ErrorBoundary>
|
||||
<div className="mx_MainSplit">
|
||||
<div className="mx_SpaceRoomView_preview">
|
||||
<GroupAvatar
|
||||
groupId={groupId}
|
||||
groupName={groupSummary.profile.name}
|
||||
groupAvatarUrl={groupSummary.profile.avatar_url}
|
||||
height={80}
|
||||
width={80}
|
||||
resizeMethod='crop'
|
||||
/>
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
{ groupSummary.profile.name }
|
||||
</h1>
|
||||
<div className="mx_SpaceRoomView_info">
|
||||
{ visibilitySection }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_preview_topic" ref={e => e && linkifyElement(e)}>
|
||||
{ groupSummary.profile.short_description }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
|
||||
{ groupSummary.user?.membership === "join"
|
||||
? _t("To view %(communityName)s, swap to communities in your <a>preferences</a>", {
|
||||
communityName: groupSummary.profile.name,
|
||||
}, {
|
||||
a: sub => (
|
||||
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
|
||||
),
|
||||
})
|
||||
: _t("To join %(communityName)s, swap to communities in your <a>preferences</a>", {
|
||||
communityName: groupSummary.profile.name,
|
||||
}, {
|
||||
a: sub => (
|
||||
<AccessibleButton onClick={onSwapClick} kind="link">{ sub }</AccessibleButton>
|
||||
),
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</main>;
|
||||
};
|
||||
|
||||
export default LegacyCommunityPreview;
|
||||
@@ -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,14 +15,13 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
|
||||
import { Key } from '../../Keyboard';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import MediaDeviceHandler from '../../MediaDeviceHandler';
|
||||
import { fixupColorFonts } from '../../utils/FontManager';
|
||||
import * as sdk from '../../index';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { IMatrixClientCreds } from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
@@ -56,9 +53,23 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
|
||||
import { IOpts } from "../../createRoom";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
|
||||
import 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";
|
||||
import LegacyCommunityPreview from "./LegacyCommunityPreview";
|
||||
|
||||
// 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,12 +85,14 @@ 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?: IOOBData;
|
||||
currentRoomId: string;
|
||||
@@ -120,6 +133,7 @@ interface IState {
|
||||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
activeCalls: Array<MatrixCall>;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,22 +149,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) {
|
||||
@@ -161,7 +166,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
|
||||
usageLimitDismissed: false,
|
||||
activeCalls: [],
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
@@ -173,13 +178,14 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
|
||||
this._roomView = React.createRef();
|
||||
this._resizeContainer = React.createRef();
|
||||
this.resizeHandler = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this._onNativeKeyDown, false);
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
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);
|
||||
@@ -194,64 +200,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);
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
|
||||
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();
|
||||
}
|
||||
|
||||
private onCallsChanged = () => {
|
||||
this.setState({
|
||||
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
|
||||
});
|
||||
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) {
|
||||
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" });
|
||||
}
|
||||
},
|
||||
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,15 +294,15 @@ 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" });
|
||||
}
|
||||
@@ -302,16 +334,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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -321,7 +353,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
});
|
||||
};
|
||||
|
||||
_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 +373,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
_updateServerNoticeEvents = async () => {
|
||||
private updateServerNoticeEvents = async () => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (!serverNoticeList) return [];
|
||||
|
||||
@@ -373,7 +405,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 +414,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 +426,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 +452,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 +477,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 +566,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,19 +592,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);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -604,18 +630,26 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
|
||||
break;
|
||||
case PageTypes.GroupView:
|
||||
pageElement = <GroupView
|
||||
groupId={this.props.currentGroupId}
|
||||
isNew={this.props.currentGroupIsNew}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
pageElement = <LegacyCommunityPreview groupId={this.props.currentGroupId} />;
|
||||
} else {
|
||||
pageElement = <GroupView
|
||||
groupId={this.props.currentGroupId}
|
||||
isNew={this.props.currentGroupIsNew}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>;
|
||||
}
|
||||
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 (
|
||||
@@ -626,26 +660,55 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
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 />
|
||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null }
|
||||
<LeftPanel
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
<ResizeHandle />
|
||||
{ pageElement }
|
||||
<div 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}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<CallContainer />
|
||||
<NonUrgentToastContainer />
|
||||
<HostSignupContainer />
|
||||
{audioFeedArraysForCalls}
|
||||
{ audioFeedArraysForCalls }
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,25 +16,35 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { NumberSize, Resizable } from 're-resizable';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import { Direction } from "re-resizable/lib/resizer";
|
||||
|
||||
interface IProps {
|
||||
resizeNotifier: ResizeNotifier;
|
||||
collapsedRhs?: boolean;
|
||||
panel?: JSX.Element;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.MainSplit")
|
||||
export default class MainSplit extends React.Component {
|
||||
_onResizeStart = () => {
|
||||
export default class MainSplit extends React.Component<IProps> {
|
||||
private onResizeStart = (): void => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
};
|
||||
|
||||
_onResize = () => {
|
||||
private onResize = (): void => {
|
||||
this.props.resizeNotifier.notifyRightHandleResized();
|
||||
};
|
||||
|
||||
_onResizeStop = (event, direction, refToElement, delta) => {
|
||||
private onResizeStop = (
|
||||
event: MouseEvent | TouchEvent, direction: Direction, elementRef: HTMLElement, delta: NumberSize,
|
||||
): void => {
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width);
|
||||
window.localStorage.setItem("mx_rhs_size", (this.loadSidePanelSize().width + delta.width).toString());
|
||||
};
|
||||
|
||||
_loadSidePanelSize() {
|
||||
private loadSidePanelSize(): {height: string | number, width: number} {
|
||||
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
|
||||
|
||||
if (isNaN(rhsSize)) {
|
||||
@@ -47,7 +57,7 @@ export default class MainSplit extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
const bodyView = React.Children.only(this.props.children);
|
||||
const panelView = this.props.panel;
|
||||
|
||||
@@ -56,7 +66,7 @@ export default class MainSplit extends React.Component {
|
||||
let children;
|
||||
if (hasResizer) {
|
||||
children = <Resizable
|
||||
defaultSize={this._loadSidePanelSize()}
|
||||
defaultSize={this.loadSidePanelSize()}
|
||||
minWidth={264}
|
||||
maxWidth="50%"
|
||||
enable={{
|
||||
@@ -69,9 +79,9 @@ export default class MainSplit extends React.Component {
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
onResizeStart={this._onResizeStart}
|
||||
onResize={this._onResize}
|
||||
onResizeStop={this._onResizeStop}
|
||||
onResizeStart={this.onResizeStart}
|
||||
onResize={this.onResize}
|
||||
onResizeStop={this.onResizeStop}
|
||||
className="mx_RightPanel_ResizeWrapper"
|
||||
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
|
||||
>
|
||||
@@ -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,14 +36,13 @@ import dis from "../../dispatcher/dispatcher";
|
||||
import Notifier from '../../Notifier';
|
||||
|
||||
import Modal from "../../Modal";
|
||||
import * as sdk from '../../index';
|
||||
import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite';
|
||||
import * as Rooms from '../../Rooms';
|
||||
import linkifyMatrix from "../../linkify-matrix";
|
||||
import * as Lifecycle from '../../Lifecycle';
|
||||
// LifecycleStore is not used but does listen to and dispatch actions
|
||||
import '../../stores/LifecycleStore';
|
||||
import PageTypes from '../../PageTypes';
|
||||
import PageType from '../../PageTypes';
|
||||
|
||||
import createRoom, { IOpts } from "../../createRoom";
|
||||
import { _t, _td, getCurrentLanguage } from '../../languageHandler';
|
||||
@@ -55,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";
|
||||
@@ -84,9 +84,33 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
|
||||
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";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
@@ -121,7 +145,7 @@ export enum Views {
|
||||
SOFT_LOGOUT,
|
||||
}
|
||||
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"];
|
||||
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
|
||||
|
||||
// Actions that are redirected through the onboarding process prior to being
|
||||
// re-dispatched. NOTE: some actions are non-trivial and would require
|
||||
@@ -135,7 +159,7 @@ const ONBOARDING_FLOW_STARTERS = [
|
||||
|
||||
interface IScreen {
|
||||
screen: string;
|
||||
params?: object;
|
||||
params?: QueryDict;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
@@ -155,14 +179,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
|
||||
@@ -170,7 +199,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 {
|
||||
@@ -178,7 +207,7 @@ interface IState {
|
||||
view: Views;
|
||||
// What the LoggedInView would be showing if visible
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type?: PageTypes;
|
||||
page_type?: PageType;
|
||||
// The ID of the room we're viewing. This is either populated directly
|
||||
// in the case where we view a room by ID or by RoomView when it resolves
|
||||
// what ID an alias points at.
|
||||
@@ -228,7 +257,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
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;
|
||||
@@ -273,7 +302,7 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +391,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() {
|
||||
@@ -406,7 +441,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();
|
||||
@@ -418,9 +453,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;
|
||||
}
|
||||
}
|
||||
@@ -518,7 +554,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() &&
|
||||
@@ -539,7 +574,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
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
|
||||
@@ -605,6 +640,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'),
|
||||
@@ -612,8 +650,7 @@ 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();
|
||||
@@ -649,7 +686,6 @@ 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 },
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
@@ -662,11 +698,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
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: {
|
||||
@@ -676,7 +713,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);
|
||||
@@ -687,7 +723,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
break;
|
||||
}
|
||||
case 'view_my_groups':
|
||||
this.setPage(PageTypes.MyGroups);
|
||||
this.setPage(PageType.MyGroups);
|
||||
this.notifyNewScreen('groups');
|
||||
break;
|
||||
case 'view_group':
|
||||
@@ -720,7 +756,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
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) {
|
||||
if (this.state.page_type === PageType.MyGroups) {
|
||||
dis.dispatch({ action: 'view_last_screen' });
|
||||
} else {
|
||||
dis.dispatch({ action: 'view_my_groups' });
|
||||
@@ -806,7 +842,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private setPage(pageType: string) {
|
||||
private setPage(pageType: PageType) {
|
||||
this.setState({
|
||||
page_type: pageType,
|
||||
});
|
||||
@@ -859,12 +895,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.focusComposer = true;
|
||||
|
||||
if (roomInfo.room_alias) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`Switching to room alias ${roomInfo.room_alias} at event ` +
|
||||
roomInfo.event_id,
|
||||
);
|
||||
} else {
|
||||
console.log(`Switching to room id ${roomInfo.room_id} at event ` +
|
||||
logger.log(`Switching to room id ${roomInfo.room_id} at event ` +
|
||||
roomInfo.event_id,
|
||||
);
|
||||
}
|
||||
@@ -913,7 +949,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.setState({
|
||||
view: Views.LOGGED_IN,
|
||||
currentRoomId: roomInfo.room_id || null,
|
||||
page_type: PageTypes.RoomView,
|
||||
page_type: PageType.RoomView,
|
||||
threepidInvite: roomInfo.threepid_invite,
|
||||
roomOobData: roomInfo.oob_data,
|
||||
ready: true,
|
||||
@@ -941,7 +977,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
currentGroupId: groupId,
|
||||
currentGroupIsNew: payload.group_is_new,
|
||||
});
|
||||
this.setPage(PageTypes.GroupView);
|
||||
this.setPage(PageType.GroupView);
|
||||
this.notifyNewScreen('group/' + groupId);
|
||||
}
|
||||
|
||||
@@ -982,8 +1018,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.setStateForNewView({
|
||||
view: Views.LOGGED_IN,
|
||||
justRegistered,
|
||||
currentRoomId: null,
|
||||
});
|
||||
this.setPage(PageTypes.HomePage);
|
||||
this.setPage(PageType.HomePage);
|
||||
this.notifyNewScreen('home');
|
||||
ThemeController.isLogin = false;
|
||||
this.themeWatcher.recheck();
|
||||
@@ -1001,7 +1038,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
this.notifyNewScreen('user/' + userId);
|
||||
this.setState({ currentUserId: userId });
|
||||
this.setPage(PageTypes.UserView);
|
||||
this.setPage(PageType.UserView);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1018,7 +1055,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
|
||||
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
|
||||
defaultPublic,
|
||||
defaultName,
|
||||
@@ -1080,7 +1116,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
||||
private leaveRoomWarnings(roomId: string) {
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
|
||||
const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
|
||||
// Show a warning if there are additional complications.
|
||||
const warnings = [];
|
||||
|
||||
@@ -1088,7 +1124,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
if (memberCount === 1) {
|
||||
warnings.push((
|
||||
<span className="warning" key="only_member_warning">
|
||||
{' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ ' '/* 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>
|
||||
@@ -1103,7 +1139,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
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,11 +1151,10 @@ 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 = SettingsStore.getValue("feature_spaces") && 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: (
|
||||
@@ -1132,7 +1167,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
: _t(
|
||||
"Are you sure you want to leave the room '%(roomName)s'?",
|
||||
{ roomName: roomToLeave.name },
|
||||
)}
|
||||
) }
|
||||
{ warnings }
|
||||
</span>
|
||||
),
|
||||
@@ -1142,8 +1177,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({
|
||||
@@ -1176,6 +1210,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1364,7 +1409,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
// such as when laptops unsleep.
|
||||
// https://github.com/vector-im/element-web/issues/3307#issuecomment-282895568
|
||||
cli.setCanResetTimelineCallback((roomId) => {
|
||||
console.log("Request to reset timeline in room ", roomId, " viewing:", this.state.currentRoomId);
|
||||
logger.log("Request to reset timeline in room ", roomId, " viewing:", this.state.currentRoomId);
|
||||
if (roomId !== this.state.currentRoomId) {
|
||||
// It is safe to remove events from rooms we are not viewing.
|
||||
return true;
|
||||
@@ -1410,7 +1455,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
showNotificationsToast(false);
|
||||
}
|
||||
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
this.setState({
|
||||
ready: true,
|
||||
});
|
||||
@@ -1438,7 +1483,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>
|
||||
@@ -1547,8 +1591,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',
|
||||
@@ -1558,7 +1600,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);
|
||||
@@ -1568,7 +1609,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
title: _t("Verification requested"),
|
||||
icon: "verification",
|
||||
props: { request },
|
||||
component: sdk.getComponent("toasts.VerificationRequestToast"),
|
||||
component: VerificationRequestToast,
|
||||
priority: 90,
|
||||
});
|
||||
}
|
||||
@@ -1674,7 +1715,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
const type = screen === "start_sso" ? "sso" : "cas";
|
||||
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
|
||||
} else if (screen === 'groups') {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
@@ -1761,11 +1802,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
subAction: params.action,
|
||||
});
|
||||
} else if (screen.indexOf('group/') === 0) {
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = screen.substring(6);
|
||||
|
||||
// TODO: Check valid group ID
|
||||
@@ -1835,13 +1871,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
dis.dispatch({ action: 'timeline_resize' });
|
||||
}
|
||||
|
||||
onRoomCreated(roomId: string) {
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: roomId,
|
||||
});
|
||||
}
|
||||
|
||||
onRegisterClick = () => {
|
||||
this.showScreen("register");
|
||||
};
|
||||
@@ -1865,15 +1894,10 @@ 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' });
|
||||
return;
|
||||
}
|
||||
if (!cli) return;
|
||||
|
||||
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
|
||||
dis.dispatch({ action: 'message_sent' });
|
||||
}, (err) => {
|
||||
dis.dispatch({ action: 'message_send_failed' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1923,7 +1947,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
this.setState({ serverConfig });
|
||||
};
|
||||
|
||||
private makeRegistrationUrl = (params: {[key: string]: string}) => {
|
||||
private makeRegistrationUrl = (params: QueryDict) => {
|
||||
if (this.props.startingFragmentQueryParams.referrer) {
|
||||
params.referrer = this.props.startingFragmentQueryParams.referrer;
|
||||
}
|
||||
@@ -1976,21 +2000,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}
|
||||
@@ -2011,43 +2032,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
|
||||
@@ -2066,7 +2081,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}
|
||||
@@ -2077,7 +2091,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}
|
||||
@@ -2088,12 +2101,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}
|
||||
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}
|
||||
@@ -2105,9 +2117,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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import DMRoomMap from "../../utils/DMRoomMap";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import defaultDispatcher from '../../dispatcher/dispatcher';
|
||||
import CallEventGrouper from "./CallEventGrouper";
|
||||
import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
|
||||
import ScrollPanel, { IScrollState } from "./ScrollPanel";
|
||||
import EventListSummary from '../views/elements/EventListSummary';
|
||||
@@ -47,14 +48,25 @@ import Spinner from "../views/elements/Spinner";
|
||||
import TileErrorBoundary from '../views/messages/TileErrorBoundary';
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
||||
import { logger } from 'matrix-js-sdk/src/logger';
|
||||
import { Action } from '../../dispatcher/actions';
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
||||
const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
|
||||
const groupedEvents = [
|
||||
EventType.RoomMember,
|
||||
EventType.RoomThirdPartyInvite,
|
||||
EventType.RoomServerAcl,
|
||||
EventType.RoomPinnedEvents,
|
||||
];
|
||||
|
||||
// check if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean {
|
||||
export function shouldFormContinuation(
|
||||
prevEvent: MatrixEvent,
|
||||
mxEvent: MatrixEvent,
|
||||
showHiddenEvents: boolean,
|
||||
): boolean {
|
||||
// sanity check inputs
|
||||
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
|
||||
// check if within the max continuation period
|
||||
@@ -74,7 +86,7 @@ function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): b
|
||||
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
|
||||
|
||||
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
|
||||
if (!haveTileForEvent(prevEvent)) return false;
|
||||
if (!haveTileForEvent(prevEvent, showHiddenEvents)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -163,6 +175,8 @@ interface IProps {
|
||||
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
|
||||
|
||||
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
|
||||
|
||||
hideThreadedMessages?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -228,6 +242,11 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
private readonly showTypingNotificationsWatcherRef: string;
|
||||
private eventNodes: Record<string, HTMLElement>;
|
||||
|
||||
// A map of <callId, CallEventGrouper>
|
||||
private callEventGroupers = new Map<string, CallEventGrouper>();
|
||||
|
||||
private membersCount = 0;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
@@ -239,7 +258,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
// Cache hidden events setting on mount since Settings is expensive to
|
||||
// query, and we check this in a hot code path.
|
||||
// query, and we check this in a hot code path. This is also cached in
|
||||
// our RoomContext, however we still need a fallback for roomless MessagePanels.
|
||||
this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline");
|
||||
|
||||
this.showTypingNotificationsWatcherRef =
|
||||
@@ -247,11 +267,17 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.calculateRoomMembersCount();
|
||||
this.props.room?.on("RoomState.members", this.calculateRoomMembersCount);
|
||||
if (SettingsStore.getValue("feature_thread")) {
|
||||
this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain());
|
||||
}
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted = false;
|
||||
this.props.room?.off("RoomState.members", this.calculateRoomMembersCount);
|
||||
SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef);
|
||||
}
|
||||
|
||||
@@ -263,8 +289,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
ghostReadMarkers,
|
||||
});
|
||||
}
|
||||
|
||||
const pendingEditItem = this.pendingEditItem;
|
||||
if (!this.props.editState && this.props.room && pendingEditItem) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.EditEvent,
|
||||
event: this.props.room.findEventById(pendingEditItem),
|
||||
timelineRenderingType: this.context.timelineRenderingType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private calculateRoomMembersCount = (): void => {
|
||||
this.membersCount = this.props.room?.getMembers().length || 0;
|
||||
};
|
||||
|
||||
private onShowTypingNotificationsChange = (): void => {
|
||||
this.setState({
|
||||
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
|
||||
@@ -399,23 +438,35 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
return !this.isMounted;
|
||||
};
|
||||
|
||||
private get showHiddenEvents(): boolean {
|
||||
return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline;
|
||||
}
|
||||
|
||||
// TODO: Implement granular (per-room) hide options
|
||||
public shouldShowEvent(mxEv: MatrixEvent): boolean {
|
||||
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) {
|
||||
if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) {
|
||||
return false; // ignored = no show (only happens if the ignore happens after an event was received)
|
||||
}
|
||||
|
||||
if (this.showHiddenEventsInTimeline) {
|
||||
if (this.showHiddenEvents) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
if (!haveTileForEvent(mxEv, this.showHiddenEvents)) {
|
||||
return false; // no tile = no show
|
||||
}
|
||||
|
||||
// Always show highlighted event
|
||||
if (this.props.highlightedEventId === mxEv.getId()) return true;
|
||||
|
||||
// Checking if the message has a "parentEventId" as we do not
|
||||
// want to hide the root event of the thread
|
||||
if (mxEv.replyInThread && mxEv.parentEventId
|
||||
&& this.props.hideThreadedMessages
|
||||
&& SettingsStore.getValue("feature_thread")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldHideEvent(mxEv, this.context);
|
||||
}
|
||||
|
||||
@@ -510,10 +561,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
return { nextEvent, nextTile };
|
||||
}
|
||||
|
||||
private get roomHasPendingEdit(): string {
|
||||
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`);
|
||||
private get pendingEditItem(): string | undefined {
|
||||
try {
|
||||
return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getEventTiles(): ReactNode[] {
|
||||
this.eventNodes = {};
|
||||
|
||||
@@ -567,9 +622,23 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
const last = (mxEv === lastShownEvent);
|
||||
const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i);
|
||||
|
||||
if (
|
||||
mxEv.getType().indexOf("m.call.") === 0 ||
|
||||
mxEv.getType().indexOf("org.matrix.call.") === 0
|
||||
) {
|
||||
const callId = mxEv.getContent().call_id;
|
||||
if (this.callEventGroupers.has(callId)) {
|
||||
this.callEventGroupers.get(callId).add(mxEv);
|
||||
} else {
|
||||
const callEventGrouper = new CallEventGrouper();
|
||||
callEventGrouper.add(mxEv);
|
||||
this.callEventGroupers.set(callId, callEventGrouper);
|
||||
}
|
||||
}
|
||||
|
||||
if (grouper) {
|
||||
if (grouper.shouldGroup(mxEv)) {
|
||||
grouper.add(mxEv);
|
||||
grouper.add(mxEv, this.showHiddenEvents);
|
||||
continue;
|
||||
} else {
|
||||
// not part of group, so get the group tiles, close the
|
||||
@@ -582,7 +651,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
|
||||
for (const Grouper of groupers) {
|
||||
if (Grouper.canStartGroup(this, mxEv)) {
|
||||
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile);
|
||||
grouper = new Grouper(
|
||||
this,
|
||||
mxEv,
|
||||
prevEvent,
|
||||
lastShownEvent,
|
||||
this.props.layout,
|
||||
nextEvent,
|
||||
nextTile,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!grouper) {
|
||||
@@ -601,13 +678,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.props.editState && this.roomHasPendingEdit) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "edit_event",
|
||||
event: this.props.room.findEventById(this.roomHasPendingEdit),
|
||||
});
|
||||
}
|
||||
|
||||
if (grouper) {
|
||||
ret.push(...grouper.getTiles());
|
||||
}
|
||||
@@ -644,12 +714,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
let willWantDateSeparator = false;
|
||||
if (nextEvent) {
|
||||
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEvent.getDate() || new Date());
|
||||
let lastInSection = true;
|
||||
if (nextEventWithTile) {
|
||||
willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEventWithTile.getDate() || new Date());
|
||||
lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEventWithTile.getSender();
|
||||
}
|
||||
|
||||
// is this a continuation of the previous message?
|
||||
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv);
|
||||
const continuation = !wantsDateSeparator &&
|
||||
shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents);
|
||||
|
||||
const eventId = mxEv.getId();
|
||||
const highlight = (eventId === this.props.highlightedEventId);
|
||||
@@ -680,6 +753,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
// it's successful: we received it.
|
||||
isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId();
|
||||
|
||||
const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id);
|
||||
// use txnId as key if available so that we don't remount during sending
|
||||
ret.push(
|
||||
<TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
|
||||
@@ -702,7 +776,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
isTwelveHour={this.props.isTwelveHour}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
last={last}
|
||||
lastInSection={willWantDateSeparator}
|
||||
lastInSection={lastInSection}
|
||||
lastSuccessful={isLastSuccessful}
|
||||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
@@ -710,6 +784,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
||||
layout={this.props.layout}
|
||||
enableFlair={this.props.enableFlair}
|
||||
showReadReceipts={this.props.showReadReceipts}
|
||||
callEventGrouper={callEventGrouper}
|
||||
hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble}
|
||||
/>
|
||||
</TileErrorBoundary>,
|
||||
);
|
||||
@@ -939,6 +1015,7 @@ abstract class BaseGrouper {
|
||||
public readonly event: MatrixEvent,
|
||||
public readonly prevEvent: MatrixEvent,
|
||||
public readonly lastShownEvent: MatrixEvent,
|
||||
protected readonly layout: Layout,
|
||||
public readonly nextEvent?: MatrixEvent,
|
||||
public readonly nextEventTile?: MatrixEvent,
|
||||
) {
|
||||
@@ -946,7 +1023,7 @@ abstract class BaseGrouper {
|
||||
}
|
||||
|
||||
public abstract shouldGroup(ev: MatrixEvent): boolean;
|
||||
public abstract add(ev: MatrixEvent): void;
|
||||
public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void;
|
||||
public abstract getTiles(): ReactNode[];
|
||||
public abstract getNewPrevEvent(): MatrixEvent;
|
||||
}
|
||||
@@ -1065,6 +1142,7 @@ class CreationGrouper extends BaseGrouper {
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={[ev.sender]}
|
||||
summaryText={summaryText}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
@@ -1092,10 +1170,11 @@ class RedactionGrouper extends BaseGrouper {
|
||||
ev: MatrixEvent,
|
||||
prevEvent: MatrixEvent,
|
||||
lastShownEvent: MatrixEvent,
|
||||
layout: Layout,
|
||||
nextEvent: MatrixEvent,
|
||||
nextEventTile: MatrixEvent,
|
||||
) {
|
||||
super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile);
|
||||
super(panel, ev, prevEvent, lastShownEvent, layout, nextEvent, nextEventTile);
|
||||
this.events = [ev];
|
||||
}
|
||||
|
||||
@@ -1160,6 +1239,7 @@ class RedactionGrouper extends BaseGrouper {
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={Array.from(senders)}
|
||||
summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</EventListSummary>,
|
||||
@@ -1180,7 +1260,7 @@ class RedactionGrouper extends BaseGrouper {
|
||||
// Wrap consecutive member events in a ListSummary, ignore if redacted
|
||||
class MemberGrouper extends BaseGrouper {
|
||||
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
|
||||
return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
|
||||
return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -1188,8 +1268,9 @@ class MemberGrouper extends BaseGrouper {
|
||||
public readonly event: MatrixEvent,
|
||||
public readonly prevEvent: MatrixEvent,
|
||||
public readonly lastShownEvent: MatrixEvent,
|
||||
protected readonly layout: Layout,
|
||||
) {
|
||||
super(panel, event, prevEvent, lastShownEvent);
|
||||
super(panel, event, prevEvent, lastShownEvent, layout);
|
||||
this.events = [event];
|
||||
}
|
||||
|
||||
@@ -1197,13 +1278,13 @@ class MemberGrouper extends BaseGrouper {
|
||||
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
|
||||
return false;
|
||||
}
|
||||
return membershipTypes.includes(ev.getType() as EventType);
|
||||
return groupedEvents.includes(ev.getType() as EventType);
|
||||
}
|
||||
|
||||
public add(ev: MatrixEvent): void {
|
||||
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
|
||||
if (ev.getType() === EventType.RoomMember) {
|
||||
// We can ignore any events that don't actually have a message to display
|
||||
if (!hasText(ev)) return;
|
||||
if (!hasText(ev, showHiddenEvents)) return;
|
||||
}
|
||||
this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
|
||||
ev.getId(),
|
||||
@@ -1264,6 +1345,7 @@ class MemberGrouper extends BaseGrouper {
|
||||
events={this.events}
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
startExpanded={highlightInMels}
|
||||
layout={this.layout}
|
||||
>
|
||||
{ eventTiles }
|
||||
</MemberEventListSummary>,
|
||||
|
||||
@@ -25,7 +25,6 @@ import AccessibleButton from '../views/elements/AccessibleButton';
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import BetaCard from "../views/beta/BetaCard";
|
||||
|
||||
@replaceableComponent("structures.MyGroups")
|
||||
export default class MyGroups extends React.Component {
|
||||
@@ -109,8 +108,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,7 +119,7 @@ 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}>
|
||||
<img src={require("../../../res/img/icons-create-room.svg")} width="50" height="50" />
|
||||
</AccessibleButton>
|
||||
@@ -137,9 +135,8 @@ 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 }
|
||||
|
||||
@@ -51,14 +51,14 @@ export default class NonUrgentToastContainer extends React.PureComponent<IProps,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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;
|
||||
@@ -35,8 +36,8 @@ interface IProps {
|
||||
export default class NotificationPanel extends React.PureComponent<IProps> {
|
||||
render() {
|
||||
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
|
||||
<h2>{_t('You’re all caught up')}</h2>
|
||||
<p>{_t('You have no visible notifications.')}</p>
|
||||
<h2>{ _t('You’re all caught up') }</h2>
|
||||
<p>{ _t('You have no visible notifications.') }</p>
|
||||
</div>);
|
||||
|
||||
let content;
|
||||
@@ -52,6 +53,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
|
||||
tileShape={TileShape.Notif}
|
||||
empty={emptyState}
|
||||
alwaysShowTimestamps={true}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
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";
|
||||
@@ -44,16 +44,24 @@ 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';
|
||||
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
|
||||
|
||||
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
|
||||
member?: RoomMember; // used if we know the room member ahead of opening the panel
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
e2eStatus?: E2EStatus;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -91,10 +99,10 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
|
||||
// Helper function to split out the logic for getPhaseFromProps() and the constructor
|
||||
// as both are called at the same time in the constructor.
|
||||
private getUserForPanel() {
|
||||
private getUserForPanel(): RoomMember {
|
||||
if (this.state && this.state.member) return this.state.member;
|
||||
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
|
||||
return this.props.user || lastParams['member'];
|
||||
return this.props.member || lastParams['member'];
|
||||
}
|
||||
|
||||
// gets the current phase from the props and also maybe the store
|
||||
@@ -107,7 +115,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
return RightPanelPhases.GroupMemberList;
|
||||
}
|
||||
return rps.groupPanelPhase;
|
||||
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
|
||||
} else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom()
|
||||
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
|
||||
) {
|
||||
return RightPanelPhases.SpaceMemberList;
|
||||
@@ -135,14 +143,14 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
return rps.roomPanelPhase;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
const cli = this.context;
|
||||
cli.on("RoomState.members", this.onRoomStateMember);
|
||||
this.initGroupStore(this.props.groupId);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
if (this.context) {
|
||||
this.context.removeListener("RoomState.members", this.onRoomStateMember);
|
||||
@@ -151,7 +159,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
|
||||
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
|
||||
public UNSAFE_componentWillReceiveProps(newProps: IProps): void { // eslint-disable-line
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this.unregisterGroupStore();
|
||||
this.initGroupStore(newProps.groupId);
|
||||
@@ -173,7 +181,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
});
|
||||
};
|
||||
|
||||
private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
|
||||
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => {
|
||||
if (!this.props.room || member.roomId !== this.props.room.roomId) {
|
||||
return;
|
||||
}
|
||||
@@ -188,6 +196,15 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
const isChangingRoom = payload.action === 'view_room' && payload.room_id !== this.props.room.roomId;
|
||||
const isViewingThread = this.state.phase === RightPanelPhases.ThreadView;
|
||||
if (isChangingRoom && isViewingThread) {
|
||||
dis.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.ThreadPanel,
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.action === Action.AfterRightPanelPhaseChange) {
|
||||
this.setState({
|
||||
phase: payload.phase,
|
||||
@@ -207,7 +224,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
// 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.
|
||||
if (this.props.user) {
|
||||
if (this.props.member) {
|
||||
// If we have a user prop then we're displaying a user from the 'user' page type
|
||||
// in LoggedInView, so need to change the page type to close the panel (we switch
|
||||
// to the home page which is not obviously the correct thing to do, but I'm not sure
|
||||
@@ -263,7 +280,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
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}
|
||||
@@ -307,6 +324,23 @@ export default class RightPanel extends React.Component<IProps, IState> {
|
||||
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;
|
||||
|
||||
@@ -16,6 +16,9 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -25,7 +28,7 @@ import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
|
||||
import Analytics from '../../Analytics';
|
||||
import { ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols } 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";
|
||||
@@ -40,14 +43,17 @@ import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
|
||||
import NetworkDropdown from "../views/directory/NetworkDropdown";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -57,46 +63,23 @@ interface IProps extends IDialogProps {
|
||||
}
|
||||
|
||||
interface IState {
|
||||
publicRooms: IRoom[];
|
||||
publicRooms: IPublicRoomsChunkRoom[];
|
||||
loading: boolean;
|
||||
protocolsLoading: boolean;
|
||||
error?: string;
|
||||
instanceId: string | symbol;
|
||||
instanceId: string;
|
||||
roomServer: string;
|
||||
filterString: string;
|
||||
selectedCommunityId?: string;
|
||||
communityName?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IRoom {
|
||||
room_id: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
topic?: string;
|
||||
canonical_alias?: string;
|
||||
aliases?: string[];
|
||||
world_readable: boolean;
|
||||
guest_can_join: boolean;
|
||||
num_joined_members: number;
|
||||
}
|
||||
|
||||
interface IPublicRoomsRequest {
|
||||
limit?: number;
|
||||
since?: string;
|
||||
server?: string;
|
||||
filter?: object;
|
||||
include_all_networks?: boolean;
|
||||
third_party_instance_id?: string;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
@replaceableComponent("structures.RoomDirectory")
|
||||
export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
private readonly startTime: number;
|
||||
private unmounted = false;
|
||||
private nextBatch: string = null;
|
||||
private filterTimeout: NodeJS.Timeout;
|
||||
private filterTimeout: number;
|
||||
private protocols: Protocols;
|
||||
|
||||
constructor(props) {
|
||||
@@ -116,6 +99,36 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
} else if (!selectedCommunityId) {
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
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}`);
|
||||
@@ -150,8 +163,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
instanceId: undefined,
|
||||
roomServer: MatrixClientPeg.getHomeserverName(),
|
||||
instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
|
||||
roomServer: localStorage.getItem(LAST_SERVER_KEY),
|
||||
filterString: this.props.initialText || "",
|
||||
selectedCommunityId,
|
||||
communityName: null,
|
||||
@@ -219,7 +232,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const nextBatch = this.nextBatch;
|
||||
const opts: IPublicRoomsRequest = { limit: 20 };
|
||||
const opts: IRoomDirectoryOptions = { limit: 20 };
|
||||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
}
|
||||
@@ -292,7 +305,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
private removeFromDirectory(room: IRoom) {
|
||||
private removeFromDirectory(room: IPublicRoomsChunkRoom) {
|
||||
const alias = getDisplayAliasForRoom(room);
|
||||
const name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
@@ -312,7 +325,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
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);
|
||||
@@ -334,7 +347,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
|
||||
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();
|
||||
@@ -342,7 +355,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onOptionChange = (server: string, instanceId?: string | symbol) => {
|
||||
private onOptionChange = (server: string, instanceId?: string) => {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
@@ -360,6 +373,14 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
private onFillRequest = (backwards: boolean) => {
|
||||
@@ -439,17 +460,17 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, false, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onViewClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
|
||||
private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => {
|
||||
this.showRoom(room, null, true);
|
||||
ev.stopPropagation();
|
||||
};
|
||||
@@ -467,7 +488,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
this.showRoom(null, alias, autoJoin);
|
||||
}
|
||||
|
||||
private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
|
||||
this.onFinished();
|
||||
const payload: ActionPayload = {
|
||||
action: 'view_room',
|
||||
@@ -516,7 +537,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
dis.dispatch(payload);
|
||||
}
|
||||
|
||||
private createRoomCells(room: IRoom) {
|
||||
private createRoomCells(room: IPublicRoomsChunkRoom) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const clientRoom = client.getRoom(room.room_id);
|
||||
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
|
||||
@@ -568,7 +589,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
|
||||
return [
|
||||
<div
|
||||
key={ `${room.room_id}_avatar` }
|
||||
key={`${room.room_id}_avatar`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomAvatar"
|
||||
>
|
||||
@@ -582,7 +603,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
/>
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_description` }
|
||||
key={`${room.room_id}_description`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_roomDescription"
|
||||
>
|
||||
@@ -605,14 +626,14 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_memberCount` }
|
||||
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` }
|
||||
key={`${room.room_id}_preview`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
className="mx_RoomDirectory_preview"
|
||||
@@ -620,7 +641,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
{ previewButton }
|
||||
</div>,
|
||||
<div
|
||||
key={ `${room.room_id}_join` }
|
||||
key={`${room.room_id}_join`}
|
||||
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
|
||||
className="mx_RoomDirectory_join"
|
||||
>
|
||||
@@ -775,7 +796,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
showJoinButton={showJoinButton}
|
||||
initialText={this.props.initialText}
|
||||
/>
|
||||
{dropdown}
|
||||
{ dropdown }
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
@@ -793,16 +814,16 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
}) : _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>
|
||||
@@ -812,6 +833,6 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function getDisplayAliasForRoom(room: IRoom) {
|
||||
return room.canonical_alias || room.aliases?.[0] || "";
|
||||
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
|
||||
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
|
||||
}
|
||||
|
||||
@@ -131,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:
|
||||
@@ -209,9 +209,9 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{icon}
|
||||
{input}
|
||||
{clearButton}
|
||||
{ icon }
|
||||
{ input }
|
||||
{ clearButton }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,95 +15,110 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t, _td } from '../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import Resend from '../../Resend';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
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 { EventStatus, MatrixEvent } 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";
|
||||
import { SyncState } from "matrix-js-sdk/src/sync.api";
|
||||
import { ISyncStateData } from "matrix-js-sdk/src/sync";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
const STATUS_BAR_EXPANDED_LARGE = 2;
|
||||
|
||||
export function getUnsentMessages(room) {
|
||||
export function getUnsentMessages(room: Room): MatrixEvent[] {
|
||||
if (!room) { return []; }
|
||||
return room.getPendingEvents().filter(function(ev) {
|
||||
return ev.status === EventStatus.NOT_SENT;
|
||||
});
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// the room this statusbar is representing.
|
||||
room: Room;
|
||||
|
||||
// true if the room is being peeked at. This affects components that shouldn't
|
||||
// logically be shown when peeking, such as a prompt to invite people to a room.
|
||||
isPeeking?: boolean;
|
||||
// callback for when the user clicks on the 'resend all' button in the
|
||||
// 'unsent messages' bar
|
||||
onResendAllClick?: () => void;
|
||||
|
||||
// callback for when the user clicks on the 'cancel all' button in the
|
||||
// 'unsent messages' bar
|
||||
onCancelAllClick?: () => void;
|
||||
|
||||
// callback for when the user clicks on the 'invite others' button in the
|
||||
// 'you are alone' bar
|
||||
onInviteClick?: () => void;
|
||||
|
||||
// callback for when we do something that changes the size of the
|
||||
// status bar. This is used to trigger a re-layout in the parent
|
||||
// component.
|
||||
onResize?: () => void;
|
||||
|
||||
// callback for when the status bar can be hidden from view, as it is
|
||||
// not displaying anything
|
||||
onHidden?: () => void;
|
||||
|
||||
// callback for when the status bar is displaying something and should
|
||||
// be visible
|
||||
onVisible?: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
syncState: SyncState;
|
||||
syncStateData: ISyncStateData;
|
||||
unsentMessages: MatrixEvent[];
|
||||
isResending: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.RoomStatusBar")
|
||||
export default class RoomStatusBar extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// the room this statusbar is representing.
|
||||
room: PropTypes.object.isRequired,
|
||||
export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
|
||||
// true if the room is being peeked at. This affects components that shouldn't
|
||||
// logically be shown when peeking, such as a prompt to invite people to a room.
|
||||
isPeeking: PropTypes.bool,
|
||||
constructor(props: IProps, context: typeof MatrixClientContext) {
|
||||
super(props, context);
|
||||
|
||||
// callback for when the user clicks on the 'resend all' button in the
|
||||
// 'unsent messages' bar
|
||||
onResendAllClick: PropTypes.func,
|
||||
|
||||
// callback for when the user clicks on the 'cancel all' button in the
|
||||
// 'unsent messages' bar
|
||||
onCancelAllClick: PropTypes.func,
|
||||
|
||||
// callback for when the user clicks on the 'invite others' button in the
|
||||
// 'you are alone' bar
|
||||
onInviteClick: PropTypes.func,
|
||||
|
||||
// callback for when we do something that changes the size of the
|
||||
// status bar. This is used to trigger a re-layout in the parent
|
||||
// component.
|
||||
onResize: PropTypes.func,
|
||||
|
||||
// callback for when the status bar can be hidden from view, as it is
|
||||
// not displaying anything
|
||||
onHidden: PropTypes.func,
|
||||
|
||||
// callback for when the status bar is displaying something and should
|
||||
// be visible
|
||||
onVisible: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
syncStateData: MatrixClientPeg.get().getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
isResending: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
|
||||
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
|
||||
|
||||
this._checkSize();
|
||||
this.state = {
|
||||
syncState: this.context.getSyncState(),
|
||||
syncStateData: this.context.getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
isResending: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._checkSize();
|
||||
public componentDidMount(): void {
|
||||
const client = this.context;
|
||||
client.on("sync", this.onSyncStateChange);
|
||||
client.on("Room.localEchoUpdated", this.onRoomLocalEchoUpdated);
|
||||
|
||||
this.checkSize();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentDidUpdate(): void {
|
||||
this.checkSize();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
|
||||
const client = MatrixClientPeg.get();
|
||||
const client = this.context;
|
||||
if (client) {
|
||||
client.removeListener("sync", this.onSyncStateChange);
|
||||
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
|
||||
client.removeListener("Room.localEchoUpdated", this.onRoomLocalEchoUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
onSyncStateChange = (state, prevState, data) => {
|
||||
private onSyncStateChange = (state: SyncState, prevState: SyncState, data: ISyncStateData): void => {
|
||||
if (state === "SYNCING" && prevState === "SYNCING") {
|
||||
return;
|
||||
}
|
||||
@@ -113,20 +128,20 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
});
|
||||
};
|
||||
|
||||
_onResendAllClick = () => {
|
||||
private onResendAllClick = (): void => {
|
||||
Resend.resendUnsentEvents(this.props.room).then(() => {
|
||||
this.setState({ isResending: false });
|
||||
});
|
||||
this.setState({ isResending: true });
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
};
|
||||
|
||||
_onCancelAllClick = () => {
|
||||
private onCancelAllClick = (): void => {
|
||||
Resend.cancelUnsentEvents(this.props.room);
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
};
|
||||
|
||||
_onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => {
|
||||
private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
|
||||
if (room.roomId !== this.props.room.roomId) return;
|
||||
const messages = getUnsentMessages(this.props.room);
|
||||
this.setState({
|
||||
@@ -136,8 +151,8 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
};
|
||||
|
||||
// Check whether current size is greater than 0, if yes call props.onVisible
|
||||
_checkSize() {
|
||||
if (this._getSize()) {
|
||||
private checkSize(): void {
|
||||
if (this.getSize()) {
|
||||
if (this.props.onVisible) this.props.onVisible();
|
||||
} else {
|
||||
if (this.props.onHidden) this.props.onHidden();
|
||||
@@ -147,8 +162,8 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
// We don't need the actual height - just whether it is likely to have
|
||||
// changed - so we use '0' to indicate normal size, and other values to
|
||||
// indicate other sizes.
|
||||
_getSize() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
private getSize(): number {
|
||||
if (this.shouldShowConnectionError()) {
|
||||
return STATUS_BAR_EXPANDED;
|
||||
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return STATUS_BAR_EXPANDED_LARGE;
|
||||
@@ -156,7 +171,7 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
return STATUS_BAR_HIDDEN;
|
||||
}
|
||||
|
||||
_shouldShowConnectionError() {
|
||||
private shouldShowConnectionError(): boolean {
|
||||
// no conn bar trumps the "some not sent" msg since you can't resend without
|
||||
// a connection!
|
||||
// There's one situation in which we don't show this 'no connection' bar, and that's
|
||||
@@ -164,12 +179,12 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
const errorIsMauError = Boolean(
|
||||
this.state.syncStateData &&
|
||||
this.state.syncStateData.error &&
|
||||
this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED',
|
||||
this.state.syncStateData.error.name === 'M_RESOURCE_LIMIT_EXCEEDED',
|
||||
);
|
||||
return this.state.syncState === "ERROR" && !errorIsMauError;
|
||||
}
|
||||
|
||||
_getUnsentMessageContent() {
|
||||
private getUnsentMessageContent(): JSX.Element {
|
||||
const unsentMessages = this.state.unsentMessages;
|
||||
|
||||
let title;
|
||||
@@ -221,18 +236,18 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
}
|
||||
|
||||
let buttonRow = <>
|
||||
<AccessibleButton onClick={this._onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
|
||||
{_t("Delete all")}
|
||||
<AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
|
||||
{ _t("Delete all") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this._onResendAllClick} className="mx_RoomStatusBar_unsentResendAllBtn">
|
||||
{_t("Retry all")}
|
||||
<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>
|
||||
{ /* span for css */ }
|
||||
<span>{ _t("Sending") }</span>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -253,27 +268,31 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_unsentButtonBar">
|
||||
{buttonRow}
|
||||
{ buttonRow }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._shouldShowConnectionError()) {
|
||||
public render(): JSX.Element {
|
||||
if (this.shouldShowConnectionError()) {
|
||||
return (
|
||||
<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="/!\ " />
|
||||
<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.')}
|
||||
{ _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.')}
|
||||
{ _t('Sent messages will be stored until your connection has returned.') }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,7 +302,7 @@ export default class RoomStatusBar extends React.PureComponent {
|
||||
}
|
||||
|
||||
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return this._getUnsentMessageContent();
|
||||
return this.getUnsentMessageContent();
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -25,8 +25,8 @@ import React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { SearchResult } from "matrix-js-sdk/src/models/search-result";
|
||||
import { EventSubscription } from "fbemitter";
|
||||
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
|
||||
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
import { _t } from '../../languageHandler';
|
||||
@@ -34,7 +34,6 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
|
||||
import ResizeNotifier from '../../utils/ResizeNotifier';
|
||||
import ContentMessages from '../../ContentMessages';
|
||||
import Modal from '../../Modal';
|
||||
import * as sdk from '../../index';
|
||||
import CallHandler, { PlaceCallType } from '../../CallHandler';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import * as Rooms from '../../Rooms';
|
||||
@@ -42,15 +41,15 @@ import eventSearch, { searchPagination } from '../../Searching';
|
||||
import MainSplit from './MainSplit';
|
||||
import RightPanel from './RightPanel';
|
||||
import RoomViewStore from '../../stores/RoomViewStore';
|
||||
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
|
||||
import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore';
|
||||
import WidgetEchoStore from '../../stores/WidgetEchoStore';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { Layout } from "../../settings/Layout";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RightPanelStore from "../../stores/RightPanelStore";
|
||||
import { haveTileForEvent } from "../views/rooms/EventTile";
|
||||
import RoomContext from "../../contexts/RoomContext";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import MatrixClientContext, { withMatrixClientHOC, MatrixClientProps } from "../../contexts/MatrixClientContext";
|
||||
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||
@@ -79,9 +78,20 @@ import { objectHasDiff } from "../../utils/objects";
|
||||
import SpaceRoomView from "./SpaceRoomView";
|
||||
import { IOpts } from "../../createRoom";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
||||
import { throttle } from "lodash";
|
||||
import ErrorDialog from '../views/dialogs/ErrorDialog';
|
||||
import SearchResultTile from '../views/rooms/SearchResultTile';
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import UploadBar from './UploadBar';
|
||||
import RoomStatusBar from "./RoomStatusBar";
|
||||
import MessageComposer from '../views/rooms/MessageComposer';
|
||||
import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
|
||||
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function(msg: string) {};
|
||||
@@ -90,10 +100,10 @@ const BROWSER_SUPPORTS_SANDBOX = 'sandbox' in document.createElement('iframe');
|
||||
|
||||
if (DEBUG) {
|
||||
// using bind means that we get to keep useful line numbers in the console
|
||||
debuglog = console.log.bind(console);
|
||||
debuglog = logger.log.bind(console);
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
interface IRoomProps extends MatrixClientProps {
|
||||
threepidInvite: IThreepidInvite;
|
||||
oobData?: IOOBData;
|
||||
|
||||
@@ -104,7 +114,7 @@ interface IProps {
|
||||
onRegistered?(credentials: IMatrixClientCreds): void;
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
export interface IRoomState {
|
||||
room?: Room;
|
||||
roomId?: string;
|
||||
roomAlias?: string;
|
||||
@@ -126,12 +136,7 @@ export interface IState {
|
||||
searching: boolean;
|
||||
searchTerm?: string;
|
||||
searchScope?: SearchScope;
|
||||
searchResults?: XOR<{}, {
|
||||
count: number;
|
||||
highlights: string[];
|
||||
results: SearchResult[];
|
||||
next_batch: string; // eslint-disable-line camelcase
|
||||
}>;
|
||||
searchResults?: XOR<{}, ISearchResults>;
|
||||
searchHighlights?: string[];
|
||||
searchInProgress?: boolean;
|
||||
callState?: CallState;
|
||||
@@ -153,7 +158,6 @@ export interface IState {
|
||||
// used by componentDidUpdate to avoid unnecessary checks
|
||||
atEndOfLiveTimelineInit: boolean;
|
||||
showTopUnreadMessagesBar: boolean;
|
||||
auxPanelMaxHeight?: number;
|
||||
statusBarVisible: boolean;
|
||||
// We load this later by asking the js-sdk to suggest a version for us.
|
||||
// This object is the result of Room#getRecommendedVersion()
|
||||
@@ -163,6 +167,11 @@ export interface IState {
|
||||
canReply: boolean;
|
||||
layout: Layout;
|
||||
lowBandwidth: boolean;
|
||||
alwaysShowTimestamps: boolean;
|
||||
showTwelveHourTimestamps: boolean;
|
||||
readMarkerInViewThresholdMs: number;
|
||||
readMarkerOutOfViewThresholdMs: number;
|
||||
showHiddenEventsInTimeline: boolean;
|
||||
showReadReceipts: boolean;
|
||||
showRedactions: boolean;
|
||||
showJoinLeaves: boolean;
|
||||
@@ -179,10 +188,12 @@ export interface IState {
|
||||
// if it did we don't want the room to be marked as read as soon as it is loaded.
|
||||
wasContextSwitch?: boolean;
|
||||
editState?: EditorStateTransfer;
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
liveTimeline?: EventTimeline;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.RoomView")
|
||||
export default class RoomView extends React.Component<IProps, IState> {
|
||||
export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
private readonly dispatcherRef: string;
|
||||
private readonly roomStoreToken: EventSubscription;
|
||||
private readonly rightPanelStoreToken: EventSubscription;
|
||||
@@ -227,6 +238,11 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
canReply: false,
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
|
||||
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
|
||||
showTwelveHourTimestamps: SettingsStore.getValue("showTwelveHourTimestamps"),
|
||||
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
|
||||
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
|
||||
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
|
||||
showReadReceipts: true,
|
||||
showRedactions: true,
|
||||
showJoinLeaves: true,
|
||||
@@ -234,6 +250,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
showDisplaynameChanges: true,
|
||||
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
|
||||
dragCounter: 0,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
liveTimeline: undefined,
|
||||
};
|
||||
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
@@ -250,7 +268,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.on("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.on("event", this.onEvent);
|
||||
// Start listening for RoomViewStore updates
|
||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
|
||||
@@ -259,11 +276,26 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
|
||||
|
||||
this.settingWatchers = [
|
||||
SettingsStore.watchSetting("layout", null, () =>
|
||||
this.setState({ layout: SettingsStore.getValue("layout") }),
|
||||
SettingsStore.watchSetting("layout", null, (...[,,, value]) =>
|
||||
this.setState({ layout: value as Layout }),
|
||||
),
|
||||
SettingsStore.watchSetting("lowBandwidth", null, () =>
|
||||
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
|
||||
SettingsStore.watchSetting("lowBandwidth", null, (...[,,, value]) =>
|
||||
this.setState({ lowBandwidth: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("alwaysShowTimestamps", null, (...[,,, value]) =>
|
||||
this.setState({ alwaysShowTimestamps: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showTwelveHourTimestamps", null, (...[,,, value]) =>
|
||||
this.setState({ showTwelveHourTimestamps: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("readMarkerInViewThresholdMs", null, (...[,,, value]) =>
|
||||
this.setState({ readMarkerInViewThresholdMs: value as number }),
|
||||
),
|
||||
SettingsStore.watchSetting("readMarkerOutOfViewThresholdMs", null, (...[,,, value]) =>
|
||||
this.setState({ readMarkerOutOfViewThresholdMs: value as number }),
|
||||
),
|
||||
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, (...[,,, value]) =>
|
||||
this.setState({ showHiddenEventsInTimeline: value as boolean }),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -309,7 +341,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
|
||||
const roomId = RoomViewStore.getRoomId();
|
||||
|
||||
const newState: Pick<IState, any> = {
|
||||
const newState: Pick<IRoomState, any> = {
|
||||
roomId,
|
||||
roomAlias: RoomViewStore.getRoomAlias(),
|
||||
roomLoading: RoomViewStore.isRoomLoading(),
|
||||
@@ -330,30 +362,20 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
|
||||
// Add watchers for each of the settings we just looked up
|
||||
this.settingWatchers = this.settingWatchers.concat([
|
||||
SettingsStore.watchSetting("showReadReceipts", null, () =>
|
||||
this.setState({
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showReadReceipts", roomId, (...[,,, value]) =>
|
||||
this.setState({ showReadReceipts: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showRedactions", null, () =>
|
||||
this.setState({
|
||||
showRedactions: SettingsStore.getValue("showRedactions", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showRedactions", roomId, (...[,,, value]) =>
|
||||
this.setState({ showRedactions: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showJoinLeaves", null, () =>
|
||||
this.setState({
|
||||
showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showJoinLeaves", roomId, (...[,,, value]) =>
|
||||
this.setState({ showJoinLeaves: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showAvatarChanges", null, () =>
|
||||
this.setState({
|
||||
showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showAvatarChanges", roomId, (...[,,, value]) =>
|
||||
this.setState({ showAvatarChanges: value as boolean }),
|
||||
),
|
||||
SettingsStore.watchSetting("showDisplaynameChanges", null, () =>
|
||||
this.setState({
|
||||
showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId),
|
||||
}),
|
||||
SettingsStore.watchSetting("showDisplaynameChanges", roomId, (...[,,, value]) =>
|
||||
this.setState({ showDisplaynameChanges: value as boolean }),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -363,7 +385,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
// Temporary logging to diagnose https://github.com/vector-im/element-web/issues/4307
|
||||
console.log(
|
||||
logger.log(
|
||||
'RVS update:',
|
||||
newState.roomId,
|
||||
newState.roomAlias,
|
||||
@@ -546,10 +568,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', this.onPageUnload);
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
|
||||
}
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
@@ -634,13 +652,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
|
||||
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
|
||||
this.context.removeListener("Event.decrypted", this.onEventDecrypted);
|
||||
this.context.removeListener("event", this.onEvent);
|
||||
}
|
||||
|
||||
window.removeEventListener('beforeunload', this.onPageUnload);
|
||||
if (this.props.resizeNotifier) {
|
||||
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
|
||||
}
|
||||
|
||||
// Remove RoomStore listener
|
||||
if (this.roomStoreToken) {
|
||||
@@ -799,7 +813,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
this.onSearchClick();
|
||||
break;
|
||||
|
||||
case "edit_event": {
|
||||
case Action.EditEvent: {
|
||||
// Quit early if we're trying to edit events in wrong rendering context
|
||||
if (payload.timelineRenderingType !== this.state.timelineRenderingType) return;
|
||||
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
|
||||
this.setState({ editState }, () => {
|
||||
if (payload.event) {
|
||||
@@ -811,17 +827,16 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
|
||||
case Action.ComposerInsert: {
|
||||
// re-dispatch to the correct composer
|
||||
if (this.state.editState) {
|
||||
dis.dispatch({
|
||||
...payload,
|
||||
action: "edit_composer_insert",
|
||||
});
|
||||
} else {
|
||||
dis.dispatch({
|
||||
...payload,
|
||||
action: "send_composer_insert",
|
||||
});
|
||||
}
|
||||
dis.dispatch({
|
||||
...payload,
|
||||
action: this.state.editState ? "edit_composer_insert" : "send_composer_insert",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case Action.FocusAComposer: {
|
||||
// re-dispatch to the correct composer
|
||||
dis.fire(this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -835,8 +850,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// ignore events for other rooms
|
||||
if (!room) return;
|
||||
if (!this.state.room || room.roomId != this.state.room.roomId) return;
|
||||
if (!room || room.roomId !== this.state.room?.roomId) return;
|
||||
|
||||
// ignore events from filtered timelines
|
||||
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
|
||||
@@ -857,6 +871,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
// we'll only be showing a spinner.
|
||||
if (this.state.joining) return;
|
||||
|
||||
if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) {
|
||||
this.handleEffects(ev);
|
||||
}
|
||||
|
||||
if (ev.getSender() !== this.context.credentials.userId) {
|
||||
// update unread count when scrolled up
|
||||
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
|
||||
@@ -869,20 +887,14 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev) => {
|
||||
private onEventDecrypted = (ev: MatrixEvent) => {
|
||||
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
|
||||
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private onEvent = (ev) => {
|
||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||
this.handleEffects(ev);
|
||||
};
|
||||
|
||||
private handleEffects = (ev) => {
|
||||
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
|
||||
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
|
||||
|
||||
private handleEffects = (ev: MatrixEvent) => {
|
||||
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
|
||||
if (!notifState.isUnread) return;
|
||||
|
||||
@@ -915,6 +927,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
// called when state.room is first initialised (either at initial load,
|
||||
// after a successful peek, or after we join the room).
|
||||
private onRoomLoaded = (room: Room) => {
|
||||
if (this.unmounted) return;
|
||||
// Attach a widget store listener only when we get a room
|
||||
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
|
||||
this.onWidgetLayoutChange(); // provoke an update
|
||||
@@ -926,12 +939,16 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
this.updateE2EStatus(room);
|
||||
this.updatePermissions(room);
|
||||
this.checkWidgets(room);
|
||||
|
||||
this.setState({
|
||||
liveTimeline: room.getLiveTimeline(),
|
||||
});
|
||||
};
|
||||
|
||||
private async calculateRecommendedVersion(room: Room) {
|
||||
this.setState({
|
||||
upgradeRecommendation: await room.getRecommendedVersion(),
|
||||
});
|
||||
const upgradeRecommendation = await room.getRecommendedVersion();
|
||||
if (this.unmounted) return;
|
||||
this.setState({ upgradeRecommendation });
|
||||
}
|
||||
|
||||
private async loadMembersIfJoined(room: Room) {
|
||||
@@ -1021,23 +1038,19 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
private async updateE2EStatus(room: Room) {
|
||||
if (!this.context.isRoomEncrypted(room.roomId)) {
|
||||
return;
|
||||
}
|
||||
if (!this.context.isCryptoEnabled()) {
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
// a warning for this case.
|
||||
this.setState({
|
||||
e2eStatus: E2EStatus.Warning,
|
||||
});
|
||||
return;
|
||||
if (!this.context.isRoomEncrypted(room.roomId)) return;
|
||||
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
// a warning for this case.
|
||||
let e2eStatus = E2EStatus.Warning;
|
||||
if (this.context.isCryptoEnabled()) {
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
e2eStatus = await shieldStatusForRoom(this.context, room);
|
||||
}
|
||||
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
this.setState({
|
||||
e2eStatus: await shieldStatusForRoom(this.context, room),
|
||||
});
|
||||
if (this.unmounted) return;
|
||||
this.setState({ e2eStatus });
|
||||
}
|
||||
|
||||
private onAccountData = (event: MatrixEvent) => {
|
||||
@@ -1131,7 +1144,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
|
||||
if (this.state.searchResults.next_batch) {
|
||||
debuglog("requesting more search results");
|
||||
const searchPromise = searchPagination(this.state.searchResults);
|
||||
const searchPromise = searchPagination(this.state.searchResults as ISearchResults);
|
||||
return this.handleSearchResult(searchPromise);
|
||||
} else {
|
||||
debuglog("no more search results");
|
||||
@@ -1239,7 +1252,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
ev.dataTransfer.files, this.state.room.roomId, this.context,
|
||||
);
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
|
||||
this.setState({
|
||||
draggingFile: false,
|
||||
@@ -1328,7 +1341,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
searchResults: results,
|
||||
});
|
||||
}, (error) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Search failed", error);
|
||||
Modal.createTrackedDialog('Search failed', '', ErrorDialog, {
|
||||
title: _t("Search failed"),
|
||||
@@ -1344,9 +1356,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
private getSearchResultTiles() {
|
||||
const SearchResultTile = sdk.getComponent('rooms.SearchResultTile');
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
|
||||
// XXX: todo: merge overlapping results somehow?
|
||||
// XXX: why doesn't searching on name work?
|
||||
|
||||
@@ -1394,11 +1403,11 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
// As per the spec, an all rooms search can create this condition,
|
||||
// it happens with Seshat but not Synapse.
|
||||
// It will make the result count not match the displayed count.
|
||||
console.log("Hiding search result from an unknown room", roomId);
|
||||
logger.log("Hiding search result from an unknown room", roomId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!haveTileForEvent(mxEv)) {
|
||||
if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
@@ -1466,7 +1475,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
console.error("Failed to reject invite: %s", error);
|
||||
|
||||
const msg = error.message ? error.message : JSON.stringify(error);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, {
|
||||
title: _t("Failed to reject invite"),
|
||||
description: msg,
|
||||
@@ -1500,7 +1508,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
console.error("Failed to reject invite: %s", error);
|
||||
|
||||
const msg = error.message ? error.message : JSON.stringify(error);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, {
|
||||
title: _t("Failed to reject invite"),
|
||||
description: msg,
|
||||
@@ -1547,7 +1554,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
} else {
|
||||
// Otherwise we have to jump manually
|
||||
this.messagePanel.jumpToLiveTimeline();
|
||||
dis.fire(Action.FocusComposer);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1577,7 +1584,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
// get the current scroll position of the room, so that it can be
|
||||
// restored when we switch back to it.
|
||||
//
|
||||
private getScrollState() {
|
||||
private getScrollState(): ScrollState {
|
||||
const messagePanel = this.messagePanel;
|
||||
if (!messagePanel) return null;
|
||||
|
||||
@@ -1614,28 +1621,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
};
|
||||
}
|
||||
|
||||
private onResize = () => {
|
||||
// It seems flexbox doesn't give us a way to constrain the auxPanel height to have
|
||||
// a minimum of the height of the video element, whilst also capping it from pushing out the page
|
||||
// so we have to do it via JS instead. In this implementation we cap the height by putting
|
||||
// a maxHeight on the underlying remote video tag.
|
||||
|
||||
// header + footer + status + give us at least 120px of scrollback at all times.
|
||||
let auxPanelMaxHeight = UIStore.instance.windowHeight -
|
||||
(54 + // height of RoomHeader
|
||||
36 + // height of the status area
|
||||
51 + // minimum height of the message composer
|
||||
120); // amount of desired scrollback
|
||||
|
||||
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
|
||||
// but it's better than the video going missing entirely
|
||||
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
|
||||
|
||||
if (this.state.auxPanelMaxHeight !== auxPanelMaxHeight) {
|
||||
this.setState({ auxPanelMaxHeight });
|
||||
}
|
||||
};
|
||||
|
||||
private onStatusBarVisible = () => {
|
||||
if (this.unmounted || this.state.statusBarVisible) return;
|
||||
this.setState({ statusBarVisible: true });
|
||||
@@ -1737,7 +1722,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false} error={this.state.roomLoadError}
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
@@ -1753,10 +1739,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
const myMembership = this.state.room.getMyMembership();
|
||||
if (myMembership === "invite"
|
||||
// SpaceRoomView handles invites itself
|
||||
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
|
||||
) {
|
||||
// SpaceRoomView handles invites itself
|
||||
if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) {
|
||||
if (this.state.joining || this.state.rejecting) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -1834,10 +1818,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
let isStatusAreaExpanded = true;
|
||||
|
||||
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
|
||||
const UploadBar = sdk.getComponent('structures.UploadBar');
|
||||
statusBar = <UploadBar room={this.state.room} />;
|
||||
} else if (!this.state.searchResults) {
|
||||
const RoomStatusBar = sdk.getComponent('structures.RoomStatusBar');
|
||||
isStatusAreaExpanded = this.state.statusBarVisible;
|
||||
statusBar = <RoomStatusBar
|
||||
room={this.state.room}
|
||||
@@ -1848,6 +1830,19 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
/>;
|
||||
}
|
||||
|
||||
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
|
||||
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
|
||||
});
|
||||
|
||||
// if statusBar does not exist then statusBarArea is blank and takes up unnecessary space on the screen
|
||||
// show statusBarArea only if statusBar is present
|
||||
const statusBarArea = statusBar && <div className={statusBarAreaClass}>
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line" />
|
||||
{ statusBar }
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const roomVersionRecommendation = this.state.upgradeRecommendation;
|
||||
const showRoomUpgradeBar = (
|
||||
roomVersionRecommendation &&
|
||||
@@ -1867,7 +1862,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)}
|
||||
/>;
|
||||
} else if (showRoomUpgradeBar) {
|
||||
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
|
||||
aux = <RoomUpgradeWarningBar room={this.state.room} />;
|
||||
} else if (myMembership !== "join") {
|
||||
// We do have a room object for this room, but we're not currently in it.
|
||||
// We may have a 3rd party invite to it.
|
||||
@@ -1889,7 +1884,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
room={this.state.room}
|
||||
/>
|
||||
);
|
||||
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
|
||||
if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
{ previewBar }
|
||||
@@ -1903,10 +1898,10 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
className="mx_RoomView_auxPanel_hiddenHighlights"
|
||||
onClick={this.onHiddenHighlightsClick}
|
||||
>
|
||||
{_t(
|
||||
{ _t(
|
||||
"You have %(count)s unread notifications in a prior version of this room.",
|
||||
{ count: hiddenHighlightCount },
|
||||
)}
|
||||
) }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
@@ -1926,11 +1921,8 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
const auxPanel = (
|
||||
<AuxPanel
|
||||
room={this.state.room}
|
||||
fullHeight={false}
|
||||
userId={this.context.credentials.userId}
|
||||
maxHeight={this.state.auxPanelMaxHeight}
|
||||
showApps={this.state.showApps}
|
||||
onResize={this.onResize}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
>
|
||||
{ aux }
|
||||
@@ -1943,12 +1935,9 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
myMembership === 'join' && !this.state.searchResults
|
||||
);
|
||||
if (canSpeak) {
|
||||
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
|
||||
messageComposer =
|
||||
<MessageComposer
|
||||
room={this.state.room}
|
||||
callState={this.state.callState}
|
||||
showApps={this.state.showApps}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
@@ -2021,7 +2010,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
onScroll={this.onMessageListScroll}
|
||||
onUserScroll={this.onUserScroll}
|
||||
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
|
||||
showUrlPreview = {this.state.showUrlPreview}
|
||||
showUrlPreview={this.state.showUrlPreview}
|
||||
className={messagePanelClassNames}
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
|
||||
@@ -2034,7 +2023,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
let topUnreadMessagesBar = null;
|
||||
// Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense
|
||||
if (this.state.showTopUnreadMessagesBar && !this.state.searchResults) {
|
||||
const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar');
|
||||
topUnreadMessagesBar = (
|
||||
<TopUnreadMessagesBar onScrollUpClick={this.jumpToReadMarker} onCloseClick={this.forgetReadMarker} />
|
||||
);
|
||||
@@ -2042,22 +2030,20 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
let jumpToBottom;
|
||||
// Do not show JumpToBottomButton if we have search results showing, it makes no sense
|
||||
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
|
||||
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
|
||||
jumpToBottom = (<JumpToBottomButton
|
||||
highlight={this.state.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
|
||||
numUnreadMessages={this.state.numUnreadMessages}
|
||||
onScrollToBottomClick={this.jumpToLiveTimeline}
|
||||
roomId={this.state.roomId}
|
||||
/>);
|
||||
}
|
||||
|
||||
const statusBarAreaClass = classNames("mx_RoomView_statusArea", {
|
||||
"mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
|
||||
});
|
||||
|
||||
const showRightPanel = this.state.room && this.state.showRightPanel;
|
||||
const rightPanel = showRightPanel
|
||||
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
|
||||
? <RightPanel
|
||||
room={this.state.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)}
|
||||
e2eStatus={this.state.e2eStatus} />
|
||||
: null;
|
||||
|
||||
const timelineClasses = classNames("mx_RoomView_timeline", {
|
||||
@@ -2073,7 +2059,7 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
return (
|
||||
<RoomContext.Provider value={this.state}>
|
||||
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
|
||||
{showChatEffects && this.roomView.current &&
|
||||
{ showChatEffects && this.roomView.current &&
|
||||
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
|
||||
}
|
||||
<ErrorBoundary>
|
||||
@@ -2092,22 +2078,17 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
/>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<div className="mx_RoomView_body">
|
||||
{auxPanel}
|
||||
{ auxPanel }
|
||||
<div className={timelineClasses}>
|
||||
{fileDropTarget}
|
||||
{topUnreadMessagesBar}
|
||||
{jumpToBottom}
|
||||
{messagePanel}
|
||||
{searchResultsPanel}
|
||||
{ fileDropTarget }
|
||||
{ topUnreadMessagesBar }
|
||||
{ jumpToBottom }
|
||||
{ messagePanel }
|
||||
{ searchResultsPanel }
|
||||
</div>
|
||||
<div className={statusBarAreaClass}>
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line" />
|
||||
{statusBar}
|
||||
</div>
|
||||
</div>
|
||||
{previewBar}
|
||||
{messageComposer}
|
||||
{ statusBarArea }
|
||||
{ previewBar }
|
||||
{ messageComposer }
|
||||
</div>
|
||||
</MainSplit>
|
||||
</ErrorBoundary>
|
||||
@@ -2116,3 +2097,6 @@ export default class RoomView extends React.Component<IProps, IState> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView);
|
||||
export default RoomViewWithMatrixClient;
|
||||
|
||||
@@ -21,6 +21,8 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { getKeyBindingsManager, RoomAction } from "../../KeyBindingsManager";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
const DEBUG_SCROLL = false;
|
||||
|
||||
// The amount of extra scroll distance to allow prior to unfilling.
|
||||
@@ -38,7 +40,7 @@ const PAGE_SIZE = 400;
|
||||
let debuglog;
|
||||
if (DEBUG_SCROLL) {
|
||||
// using bind means that we get to keep useful line numbers in the console
|
||||
debuglog = console.log.bind(console, "ScrollPanel debuglog:");
|
||||
debuglog = logger.log.bind(console, "ScrollPanel debuglog:");
|
||||
} else {
|
||||
debuglog = function() {};
|
||||
}
|
||||
@@ -183,11 +185,17 @@ export default class ScrollPanel extends React.Component<IProps> {
|
||||
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: NodeJS.Timeout;
|
||||
private unfillDebouncer: number;
|
||||
private bottomGrowth: number;
|
||||
private pages: number;
|
||||
private heightUpdateInProgress: boolean;
|
||||
@@ -213,7 +221,7 @@ export default class ScrollPanel extends React.Component<IProps> {
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -251,12 +259,12 @@ export default class ScrollPanel extends React.Component<IProps> {
|
||||
|
||||
// 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.
|
||||
public checkScroll = () => {
|
||||
public checkScroll = (isFromPropsUpdate = false) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.restoreSavedScrollState();
|
||||
this.checkFillState();
|
||||
this.checkFillState(0, isFromPropsUpdate);
|
||||
};
|
||||
|
||||
// return true if the content is fully scrolled down right now; else false.
|
||||
@@ -269,8 +277,15 @@ export default class ScrollPanel extends React.Component<IProps> {
|
||||
// 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.
|
||||
// so check difference <= 1;
|
||||
return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1;
|
||||
//
|
||||
// We therefore leave a bit of wiggle-room and assume we're at the
|
||||
// bottom if the unscrolled area is less than one pixel high.
|
||||
//
|
||||
// non-standard DPI settings also seem to have effect here and can
|
||||
// actually lead to scrollTop+clientHeight being *larger* than
|
||||
// scrollHeight. (observed in element-desktop on Ubuntu 20.04)
|
||||
//
|
||||
return sn.scrollHeight - (sn.scrollTop + sn.clientHeight) <= 1;
|
||||
};
|
||||
|
||||
// returns the vertical height in the given direction that can be removed from
|
||||
@@ -319,7 +334,7 @@ export default class ScrollPanel extends React.Component<IProps> {
|
||||
}
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
public checkFillState = async (depth = 0): Promise<void> => {
|
||||
public checkFillState = async (depth = 0, isFromPropsUpdate = false): Promise<void> => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
@@ -355,14 +370,20 @@ export default class ScrollPanel extends React.Component<IProps> {
|
||||
// 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) {
|
||||
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;
|
||||
this.isFillingDueToPropsUpdate = isFromPropsUpdate;
|
||||
}
|
||||
|
||||
const itemlist = this.itemlist.current;
|
||||
@@ -393,11 +414,14 @@ export default class ScrollPanel extends React.Component<IProps> {
|
||||
if (isFirstCall) {
|
||||
debuglog("isFilling: clearing");
|
||||
this.isFilling = false;
|
||||
this.isFillingDueToPropsUpdate = false;
|
||||
}
|
||||
|
||||
if (this.fillRequestWhileRunning) {
|
||||
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
|
||||
this.fillRequestWhileRunning = false;
|
||||
this.checkFillState();
|
||||
this.pendingFillDueToPropsUpdate = false;
|
||||
this.checkFillState(0, refillDueToPropsUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Key } from '../../Keyboard';
|
||||
import dis from '../../dispatcher/dispatcher';
|
||||
import { throttle } from 'lodash';
|
||||
@@ -24,106 +23,116 @@ import AccessibleButton from '../../components/views/elements/AccessibleButton';
|
||||
import classNames from 'classnames';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
onSearch?: (query: string) => void;
|
||||
onCleared?: (source?: string) => void;
|
||||
onKeyDown?: (ev: React.KeyboardEvent) => void;
|
||||
onFocus?: (ev: React.FocusEvent) => void;
|
||||
onBlur?: (ev: React.FocusEvent) => void;
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
blurredPlaceholder?: string;
|
||||
autoFocus?: boolean;
|
||||
initialValue?: string;
|
||||
collapsed?: boolean;
|
||||
|
||||
// If true, the search box will focus and clear itself
|
||||
// on room search focus action (it would be nicer to take
|
||||
// this functionality out, but not obvious how that would work)
|
||||
enableRoomSearchFocus?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
searchTerm: string;
|
||||
blurred: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.SearchBox")
|
||||
export default class SearchBox extends React.Component {
|
||||
static propTypes = {
|
||||
onSearch: PropTypes.func,
|
||||
onCleared: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
autoFocus: PropTypes.bool,
|
||||
initialValue: PropTypes.string,
|
||||
export default class SearchBox extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private search = createRef<HTMLInputElement>();
|
||||
|
||||
// If true, the search box will focus and clear itself
|
||||
// on room search focus action (it would be nicer to take
|
||||
// this functionality out, but not obvious how that would work)
|
||||
enableRoomSearchFocus: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
static defaultProps: Partial<IProps> = {
|
||||
enableRoomSearchFocus: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._search = createRef();
|
||||
|
||||
this.state = {
|
||||
searchTerm: this.props.initialValue || "",
|
||||
searchTerm: props.initialValue || "",
|
||||
blurred: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
onAction = payload => {
|
||||
private onAction = (payload): void => {
|
||||
if (!this.props.enableRoomSearchFocus) return;
|
||||
|
||||
switch (payload.action) {
|
||||
case 'view_room':
|
||||
if (this._search.current && payload.clear_search) {
|
||||
this._clearSearch();
|
||||
if (this.search.current && payload.clear_search) {
|
||||
this.clearSearch();
|
||||
}
|
||||
break;
|
||||
case 'focus_room_filter':
|
||||
if (this._search.current) {
|
||||
this._search.current.focus();
|
||||
if (this.search.current) {
|
||||
this.search.current.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onChange = () => {
|
||||
if (!this._search.current) return;
|
||||
this.setState({ searchTerm: this._search.current.value });
|
||||
private onChange = (): void => {
|
||||
if (!this.search.current) return;
|
||||
this.setState({ searchTerm: this.search.current.value });
|
||||
this.onSearch();
|
||||
};
|
||||
|
||||
onSearch = throttle(() => {
|
||||
this.props.onSearch(this._search.current.value);
|
||||
private onSearch = throttle((): void => {
|
||||
this.props.onSearch(this.search.current.value);
|
||||
}, 200, { trailing: true, leading: true });
|
||||
|
||||
_onKeyDown = ev => {
|
||||
private onKeyDown = (ev: React.KeyboardEvent): void => {
|
||||
switch (ev.key) {
|
||||
case Key.ESCAPE:
|
||||
this._clearSearch("keyboard");
|
||||
this.clearSearch("keyboard");
|
||||
break;
|
||||
}
|
||||
if (this.props.onKeyDown) this.props.onKeyDown(ev);
|
||||
};
|
||||
|
||||
_onFocus = ev => {
|
||||
private onFocus = (ev: React.FocusEvent): void => {
|
||||
this.setState({ blurred: false });
|
||||
ev.target.select();
|
||||
(ev.target as HTMLInputElement).select();
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
};
|
||||
|
||||
_onBlur = ev => {
|
||||
private onBlur = (ev: React.FocusEvent): void => {
|
||||
this.setState({ blurred: true });
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
_clearSearch(source) {
|
||||
this._search.current.value = "";
|
||||
private clearSearch(source?: string): void {
|
||||
this.search.current.value = "";
|
||||
this.onChange();
|
||||
if (this.props.onCleared) {
|
||||
this.props.onCleared(source);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
// check for collapsed here and
|
||||
// not at parent so we keep
|
||||
// searchTerm in our state
|
||||
@@ -136,8 +145,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
|
||||
@@ -151,14 +160,14 @@ export default class SearchBox extends React.Component {
|
||||
<input
|
||||
key="searchfield"
|
||||
type="text"
|
||||
ref={this._search}
|
||||
ref={this.search}
|
||||
className={"mx_textinput_icon mx_textinput_search " + className}
|
||||
value={ this.state.searchTerm }
|
||||
onFocus={ this._onFocus }
|
||||
onChange={ this.onChange }
|
||||
onKeyDown={ this._onKeyDown }
|
||||
onBlur={this._onBlur}
|
||||
placeholder={ placeholder }
|
||||
value={this.state.searchTerm}
|
||||
onFocus={this.onFocus}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onBlur={this.onBlur}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
autoFocus={this.props.autoFocus}
|
||||
/>
|
||||
732
src/components/structures/SpaceHierarchy.tsx
Normal file
732
src/components/structures/SpaceHierarchy.tsx
Normal file
@@ -0,0 +1,732 @@
|
||||
/*
|
||||
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";
|
||||
import { useEventEmitterState } from "../../hooks/useEventEmitter";
|
||||
import { IOOBData } from "../../stores/ThreepidInviteStore";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(
|
||||
cli: MatrixClient,
|
||||
hierarchy: RoomHierarchy,
|
||||
roomId: string,
|
||||
autoJoin?: boolean,
|
||||
roomType?: RoomType,
|
||||
): void;
|
||||
}
|
||||
|
||||
interface ITileProps {
|
||||
room: IHierarchyRoom;
|
||||
suggested?: boolean;
|
||||
selected?: boolean;
|
||||
numChildRooms?: number;
|
||||
hasPermissions?: boolean;
|
||||
onViewRoomClick(autoJoin: boolean, roomType: RoomType): 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 joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name);
|
||||
const name = joinedRoomName || 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, room.room_type as RoomType);
|
||||
};
|
||||
const onJoinClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick(true, room.room_type as RoomType);
|
||||
};
|
||||
|
||||
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,
|
||||
roomType?: RoomType,
|
||||
) => {
|
||||
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"),
|
||||
roomType,
|
||||
} as IOOBData,
|
||||
});
|
||||
};
|
||||
|
||||
interface IHierarchyLevelProps {
|
||||
root: IHierarchyRoom;
|
||||
roomSet: Set<IHierarchyRoom>;
|
||||
hierarchy: RoomHierarchy;
|
||||
parents: Set<string>;
|
||||
selectedMap?: Map<string, Set<string>>;
|
||||
onViewRoomClick(roomId: string, autoJoin: boolean, roomType?: RoomType): 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, roomType) => {
|
||||
onViewRoomClick(room.room_id, autoJoin, roomType);
|
||||
}}
|
||||
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, roomType) => {
|
||||
onViewRoomClick(space.room_id, autoJoin, roomType);
|
||||
}}
|
||||
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 (loading || !hierarchy.canLoadMore || hierarchy.noSupport) return;
|
||||
|
||||
setLoading(true);
|
||||
await hierarchy.load(pageSize);
|
||||
setRooms(hierarchy.rooms);
|
||||
setLoading(false);
|
||||
}, [loading, 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;
|
||||
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, roomType) => {
|
||||
showRoom(cli, hierarchy, roomId, autoJoin, roomType);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
} else if (!hierarchy.canLoadMore) {
|
||||
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>;
|
||||
}
|
||||
|
||||
let loader: JSX.Element;
|
||||
if (hierarchy.canLoadMore) {
|
||||
loader = <div ref={loaderRef}>
|
||||
<Spinner />
|
||||
</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;
|
||||
@@ -1,670 +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, { ReactNode, useMemo, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import classNames from "classnames";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import { EnhancedMap } from "../../utils/maps";
|
||||
import 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";
|
||||
import { getChildOrder } from "../../stores/SpaceStore";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
refreshToken?: any;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
||||
/* 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 cli = MatrixClientPeg.get();
|
||||
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
|
||||
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0]
|
||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
|
||||
const 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">
|
||||
{ _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 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_SpaceRoomDirectory_roomTile_name">
|
||||
{ name }
|
||||
{ suggestedSection }
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx_SpaceRoomDirectory_roomTile_info"
|
||||
ref={e => e && linkifyElement(e)}
|
||||
onClick={ev => {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
if ((ev.target as HTMLElement).tagName === "A") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ description }
|
||||
</div>
|
||||
<div className="mx_SpaceRoomDirectory_actions">
|
||||
{ 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 children = Array.from(relations.get(spaceId)?.values() || []);
|
||||
const sortedChildren = sortBy(children, ev => {
|
||||
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
|
||||
return getChildOrder(ev.content.order, null, ev.state_key);
|
||||
});
|
||||
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
|
||||
const roomId = ev.state_key;
|
||||
if (!rooms.has(roomId)) return result;
|
||||
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.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
|
||||
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
|
||||
selected={selectedMap?.get(spaceId)?.has(roomId)}
|
||||
onViewRoomClick={(autoJoin) => {
|
||||
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): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// 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 [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations];
|
||||
} catch (e) {
|
||||
console.error(e); // TODO
|
||||
return [e];
|
||||
}
|
||||
}, [space, refreshToken], [undefined]);
|
||||
};
|
||||
|
||||
export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
||||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
refreshToken,
|
||||
additionalButtons,
|
||||
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 [summaryError, 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);
|
||||
|
||||
if (summaryError) {
|
||||
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
|
||||
}
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||
|
||||
let countsStr;
|
||||
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 manageButtons;
|
||||
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
manageButtons = <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).delete(childId);
|
||||
if (parentChildMap.get(parentId).size > 0) {
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
} else {
|
||||
parentChildMap.delete(parentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
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 }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
||||
{ results }
|
||||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
// TODO loading state/error state
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={ _t("Search names and descriptions") }
|
||||
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] : "");
|
||||
}
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import React, { RefObject, useContext, useRef, useState } from "react";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventSubscription } from "fbemitter";
|
||||
|
||||
@@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, 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 } from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import {
|
||||
AddExistingToSpace,
|
||||
defaultDmsRenderer,
|
||||
defaultRoomsRenderer,
|
||||
defaultSpacesRenderer,
|
||||
} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
@@ -62,12 +72,15 @@ import IconizedContextMenu, {
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { BetaPill } from "../views/beta/BetaCard";
|
||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import Modal from "../../Modal";
|
||||
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||
import { JoinRule } from "../views/settings/tabs/room/SecurityRoomSettingsTab";
|
||||
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";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
@@ -79,7 +92,7 @@ interface IProps {
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
createdRooms?: boolean; // internal state for the creation wizard
|
||||
firstRoomId?: string; // internal state for the creation wizard
|
||||
showRightPanel: boolean;
|
||||
myMembership: string;
|
||||
}
|
||||
@@ -94,26 +107,6 @@ enum Phase {
|
||||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
// XXX: Temporary for the Spaces Beta only
|
||||
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
||||
if (!SdkConfig.get().bug_report_endpoint_url) return null;
|
||||
|
||||
return <div className="mx_SpaceFeedbackPrompt">
|
||||
<hr />
|
||||
<div>
|
||||
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
if (onClick) onClick();
|
||||
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
|
||||
featureId: "feature_spaces",
|
||||
});
|
||||
}}>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const RoomMemberCount = ({ room, children }) => {
|
||||
const members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
@@ -147,7 +140,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={() => {
|
||||
@@ -160,25 +153,56 @@ const SpaceInfo = ({ space }) => {
|
||||
>
|
||||
{ _t("%(count)s members", { count }) }
|
||||
</AccessibleButton>
|
||||
) : null}
|
||||
) : null }
|
||||
</RoomMemberCount> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const onBetaClick = () => {
|
||||
const onPreferencesClick = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
initialTabId: UserTab.Preferences,
|
||||
});
|
||||
};
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
// 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);
|
||||
useDispatcher(defaultDispatcher, payload => {
|
||||
if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) {
|
||||
setBusy(false); // stop the spinner, join failed
|
||||
}
|
||||
});
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const spacesEnabled = SettingsStore.getValue("feature_spaces");
|
||||
const spacesEnabled = SpaceStore.spacesEnabled;
|
||||
|
||||
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
|
||||
&& space.getJoinRule() !== JoinRule.Public;
|
||||
@@ -206,11 +230,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
||||
|
||||
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">
|
||||
@@ -264,15 +288,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
||||
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 view this Space, hide communities in your <a>preferences</a>", {}, {
|
||||
a: sub => <AccessibleButton onClick={onPreferencesClick} 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>,
|
||||
: _t("To join this Space, hide communities in your <a>preferences</a>", {}, {
|
||||
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
@@ -284,8 +304,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
||||
</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">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ migratedCommunitySection }
|
||||
{ inviterSection }
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
@@ -293,7 +323,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>
|
||||
@@ -307,8 +337,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const SpaceLandingAddButton = ({ space }) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
@@ -331,25 +360,33 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (await showCreateNewRoom(cli, space)) {
|
||||
onNewRoomAdded();
|
||||
if (await showCreateNewRoom(space)) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add existing room")}
|
||||
iconClassName="mx_RoomList_iconHash"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const [added] = await showAddExistingRooms(cli, space);
|
||||
if (added) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
showAddExistingRooms(space);
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add space")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
showCreateNewSubspace(space);
|
||||
}}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
@@ -390,19 +427,17 @@ const SpaceLanding = ({ space }) => {
|
||||
|
||||
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
const [refreshToken, forceUpdate] = useStateToggle(false);
|
||||
|
||||
let addRoomButton;
|
||||
if (canAddRooms) {
|
||||
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
|
||||
addRoomButton = <SpaceLandingAddButton space={space} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
if (shouldShowSpaceSettings(cli, space)) {
|
||||
if (shouldShowSpaceSettings(space)) {
|
||||
settingsButton = <AccessibleTooltipButton
|
||||
className="mx_SpaceRoomView_landing_settingsButton"
|
||||
onClick={() => {
|
||||
showSpaceSettings(cli, space);
|
||||
showSpaceSettings(space);
|
||||
}}
|
||||
title={_t("Settings")}
|
||||
/>;
|
||||
@@ -417,15 +452,16 @@ 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">
|
||||
@@ -435,21 +471,14 @@ const SpaceLanding = ({ space }) => {
|
||||
{ settingsButton }
|
||||
</div>
|
||||
<RoomTopic room={space}>
|
||||
{(topic, ref) => (
|
||||
{ (topic, ref) => (
|
||||
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
|
||||
{ topic }
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
</RoomTopic>
|
||||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={showRoom}
|
||||
refreshToken={refreshToken}
|
||||
additionalButtons={addRoomButton}
|
||||
/>
|
||||
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -459,7 +488,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
const numFields = 3;
|
||||
const placeholders = [_t("General"), _t("Random"), _t("Support")];
|
||||
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}
|
||||
@@ -471,6 +500,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
onChange={ev => setRoomName(i, ev.target.value)}
|
||||
autoFocus={i === 2}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
/>;
|
||||
});
|
||||
|
||||
@@ -480,11 +510,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const isPublic = space.getJoinRule() === JoinRule.Public;
|
||||
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
|
||||
await Promise.all(filteredRoomNames.map(name => {
|
||||
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,
|
||||
@@ -492,9 +523,11 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
andView: false,
|
||||
inlineErrors: true,
|
||||
parentSpace: space,
|
||||
joinRule: !isPublic ? JoinRule.Restricted : undefined,
|
||||
suggested: true,
|
||||
});
|
||||
}));
|
||||
onFinished(filteredRoomNames.length > 0);
|
||||
onFinished(roomIds[0]);
|
||||
} catch (e) {
|
||||
console.error("Failed to create initial space rooms", e);
|
||||
setError(_t("Failed to create initial space rooms"));
|
||||
@@ -504,7 +537,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
|
||||
let onClick = (ev) => {
|
||||
ev.preventDefault();
|
||||
onFinished(false);
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("Skip for now");
|
||||
if (roomNames.some(name => name.trim())) {
|
||||
@@ -532,7 +565,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -551,17 +583,20 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
||||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
filterPlaceholder={_t("Search for rooms or spaces")}
|
||||
onFinished={onFinished}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => {
|
||||
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: justCreatedOpts?.createOpts?.name || space.name,
|
||||
@@ -574,10 +609,9 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
|
||||
{ firstRoomId ? _t("Go to my first room") : _t("Go to my space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -606,9 +640,8 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
|
||||
</AccessibleButton>
|
||||
<div className="mx_SpaceRoomView_betaWarning">
|
||||
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
|
||||
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
|
||||
<p>{ _t("We're working on this, but just want to let you know.") }</p>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -626,7 +659,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}
|
||||
@@ -665,7 +698,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
||||
|
||||
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error");
|
||||
if (failedUsers.length > 0) {
|
||||
console.log("Failed to invite users to space: ", result);
|
||||
logger.log("Failed to invite users to space: ", result);
|
||||
setError(_t("Failed to invite the following users to your space: %(csvUsers)s", {
|
||||
csvUsers: failedUsers.join(", "),
|
||||
}));
|
||||
@@ -696,7 +729,7 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
||||
</div>
|
||||
|
||||
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
<BetaPill />
|
||||
{ _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>,
|
||||
@@ -731,7 +764,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -785,6 +817,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) {
|
||||
@@ -815,35 +852,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));
|
||||
}
|
||||
|
||||
if (suggestedRooms.length) {
|
||||
const room = suggestedRooms[0];
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: room.room_id,
|
||||
room_alias: room.canonical_alias || room.aliases?.[0],
|
||||
via_servers: room.viaServers,
|
||||
oobData: {
|
||||
avatarUrl: room.avatar_url,
|
||||
name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
|
||||
},
|
||||
room_id: this.state.firstRoomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -854,7 +866,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
||||
private renderBody() {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
|
||||
if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) {
|
||||
return <SpaceLanding space={this.props.space} />;
|
||||
} else {
|
||||
return <SpacePreview
|
||||
@@ -873,14 +885,14 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
||||
_t("Let's create a room for each of them.") + "\n" +
|
||||
_t("You can add more later too, including already existing ones.")
|
||||
}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })}
|
||||
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })}
|
||||
/>;
|
||||
case Phase.PublicShare:
|
||||
return <SpaceSetupPublicShare
|
||||
justCreatedOpts={this.props.justCreatedOpts}
|
||||
space={this.props.space}
|
||||
onFinished={this.goToFirstRoom}
|
||||
createdRooms={this.state.createdRooms}
|
||||
firstRoomId={this.state.firstRoomId}
|
||||
/>;
|
||||
|
||||
case Phase.PrivateScope:
|
||||
@@ -902,7 +914,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
||||
title={_t("What projects are you working on?")}
|
||||
description={_t("We'll create rooms for each of them. " +
|
||||
"You can add more later too, including already existing ones.")}
|
||||
onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })}
|
||||
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.Landing, firstRoomId })}
|
||||
/>;
|
||||
case Phase.PrivateExistingRooms:
|
||||
return <SpaceAddExistingRooms
|
||||
|
||||
@@ -18,9 +18,10 @@ limitations under the License.
|
||||
|
||||
import * as React from "react";
|
||||
import { _t } from '../../languageHandler';
|
||||
import * as sdk from "../../index";
|
||||
import AutoHideScrollbar from './AutoHideScrollbar';
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
93
src/components/structures/ThreadPanel.tsx
Normal file
93
src/components/structures/ThreadPanel.tsx
Normal 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, ThreadEvent } 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(ThreadEvent.Update, this.onThreadEventReceived);
|
||||
this.room.on(ThreadEvent.Ready, this.onThreadEventReceived);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.room.removeListener(ThreadEvent.Update, this.onThreadEventReceived);
|
||||
this.room.removeListener(ThreadEvent.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>
|
||||
);
|
||||
}
|
||||
}
|
||||
199
src/components/structures/ThreadView.tsx
Normal file
199
src/components/structures/ThreadView.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
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, ThreadEvent } 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';
|
||||
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
||||
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
mxEvent: MatrixEvent;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
e2eStatus?: E2EStatus;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
replyToEvent?: MatrixEvent;
|
||||
thread?: Thread;
|
||||
editState?: EditorStateTransfer;
|
||||
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.ThreadView")
|
||||
export default class ThreadView extends React.Component<IProps, IState> {
|
||||
static contextType = RoomContext;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
switch (payload.action) {
|
||||
case Action.EditEvent: {
|
||||
// Quit early if it's not a thread context
|
||||
if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return;
|
||||
// Quit early if that's not a thread event
|
||||
if (payload.event && !payload.event.getThread()) return;
|
||||
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
|
||||
this.setState({ editState }, () => {
|
||||
if (payload.event) {
|
||||
this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId());
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
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(ThreadEvent.Update, this.updateThread);
|
||||
thread.once(ThreadEvent.Ready, this.updateThread);
|
||||
this.updateThread(thread);
|
||||
};
|
||||
|
||||
private teardownThread = () => {
|
||||
if (this.state.thread) {
|
||||
this.state.thread.removeListener(ThreadEvent.Update, this.updateThread);
|
||||
this.state.thread.removeListener(ThreadEvent.Ready, this.updateThread);
|
||||
}
|
||||
};
|
||||
|
||||
private updateThread = (thread?: Thread) => {
|
||||
if (thread) {
|
||||
this.setState({
|
||||
thread,
|
||||
replyToEvent: thread.replyToEvent,
|
||||
});
|
||||
}
|
||||
|
||||
this.timelinePanelRef.current?.refreshTimeline();
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<RoomContext.Provider value={{
|
||||
...this.context,
|
||||
timelineRenderingType: TimelineRenderingType.Thread,
|
||||
liveTimeline: this.state?.thread?.timelineSet?.getLiveTimeline(),
|
||||
}}>
|
||||
|
||||
<BaseCard
|
||||
className="mx_ThreadView"
|
||||
onClose={this.props.onClose}
|
||||
previousPhase={RightPanelPhases.RoomSummary}
|
||||
withoutScrollContainer={true}
|
||||
>
|
||||
{ this.state.thread && (
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanelRef}
|
||||
showReadReceipts={false} // No RR support in thread's MVP
|
||||
manageReadReceipts={false} // No RR support in thread's MVP
|
||||
manageReadMarkers={false} // No RM support in thread's MVP
|
||||
sendReadReceiptOnLoad={false} // No RR support in thread's MVP
|
||||
timelineSet={this.state?.thread?.timelineSet}
|
||||
showUrlPreview={true}
|
||||
tileShape={TileShape.Thread}
|
||||
empty={<div>empty</div>}
|
||||
alwaysShowTimestamps={true}
|
||||
layout={Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel mx_GroupLayout"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
/>
|
||||
) }
|
||||
|
||||
{ this.state?.thread?.timelineSet && (<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>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,13 @@ limitations under the License.
|
||||
|
||||
import React, { createRef, ReactNode, SyntheticEvent } from 'react';
|
||||
import ReactDOM from "react-dom";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { TimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
|
||||
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
|
||||
import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
|
||||
import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
|
||||
import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
|
||||
import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event';
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { Layout } from "../../settings/Layout";
|
||||
@@ -30,7 +32,6 @@ import RoomContext from "../../contexts/RoomContext";
|
||||
import UserActivity from "../../UserActivity";
|
||||
import Modal from "../../Modal";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import * as sdk from "../../index";
|
||||
import { Key } from '../../Keyboard';
|
||||
import Timer from '../../utils/Timer';
|
||||
import shouldHideEvent from '../../shouldHideEvent';
|
||||
@@ -39,25 +40,29 @@ import { UIFeature } from "../../settings/UIFeature";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { arrayFastClone } from "../../utils/arrays";
|
||||
import MessagePanel from "./MessagePanel";
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
import { IScrollState } from "./ScrollPanel";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import EditorStateTransfer from '../../utils/EditorStateTransfer';
|
||||
import ErrorDialog from '../views/dialogs/ErrorDialog';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
const PAGINATE_SIZE = 20;
|
||||
const INITIAL_SIZE = 20;
|
||||
const READ_RECEIPT_INTERVAL_MS = 500;
|
||||
|
||||
const READ_MARKER_DEBOUNCE_MS = 100;
|
||||
|
||||
const DEBUG = false;
|
||||
|
||||
let debuglog = function(...s: any[]) {};
|
||||
if (DEBUG) {
|
||||
// using bind means that we get to keep useful line numbers in the console
|
||||
debuglog = console.log.bind(console);
|
||||
debuglog = logger.log.bind(console);
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
@@ -65,7 +70,7 @@ interface IProps {
|
||||
// representing. This may or may not have a room, depending on what it's
|
||||
// a timeline representing. If it has a room, we maintain RRs etc for
|
||||
// that room.
|
||||
timelineSet: TimelineSet;
|
||||
timelineSet: EventTimelineSet;
|
||||
showReadReceipts?: boolean;
|
||||
// Enable managing RRs and RMs. These require the timelineSet to have a room.
|
||||
manageReadReceipts?: boolean;
|
||||
@@ -126,6 +131,8 @@ interface IProps {
|
||||
|
||||
// callback which is called when we wish to paginate the timeline window.
|
||||
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
|
||||
|
||||
hideThreadedMessages?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -214,6 +221,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
timelineCap: Number.MAX_VALUE,
|
||||
className: 'mx_RoomView_messagePanel',
|
||||
sendReadReceiptOnLoad: true,
|
||||
hideThreadedMessages: true,
|
||||
};
|
||||
|
||||
private lastRRSentEventId: string = undefined;
|
||||
@@ -277,7 +285,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
// TODO: [REACT-WARNING] Move into constructor
|
||||
// eslint-disable-next-line camelcase
|
||||
// eslint-disable-next-line
|
||||
UNSAFE_componentWillMount() {
|
||||
if (this.props.manageReadReceipts) {
|
||||
this.updateReadReceiptOnUserActivity();
|
||||
@@ -290,7 +298,7 @@ class TimelinePanel 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.timelineSet !== this.props.timelineSet) {
|
||||
// throw new Error("changing timelineSet on a TimelinePanel is not supported");
|
||||
@@ -310,7 +318,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
const differentEventId = newProps.eventId != this.props.eventId;
|
||||
const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId;
|
||||
if (differentEventId || differentHighlightedEventId) {
|
||||
console.log("TimelinePanel switching to eventId " + newProps.eventId +
|
||||
logger.log("TimelinePanel switching to eventId " + newProps.eventId +
|
||||
" (was " + this.props.eventId + ")");
|
||||
return this.initTimeline(newProps);
|
||||
}
|
||||
@@ -388,7 +396,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
|
||||
private onPaginationRequest = (
|
||||
timelineWindow: TimelineWindow,
|
||||
direction: string,
|
||||
direction: Direction,
|
||||
size: number,
|
||||
): Promise<boolean> => {
|
||||
if (this.props.onPaginationRequest) {
|
||||
@@ -472,22 +480,35 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
if (this.props.manageReadMarkers) {
|
||||
const rmPosition = this.getReadMarkerPosition();
|
||||
// we hide the read marker when it first comes onto the screen, but if
|
||||
// it goes back off the top of the screen (presumably because the user
|
||||
// clicks on the 'jump to bottom' button), we need to re-enable it.
|
||||
if (rmPosition < 0) {
|
||||
this.setState({ readMarkerVisible: true });
|
||||
}
|
||||
|
||||
// if read marker position goes between 0 and -1/1,
|
||||
// (and user is active), switch timeout
|
||||
const timeout = this.readMarkerTimeout(rmPosition);
|
||||
// NO-OP when timeout already has set to the given value
|
||||
this.readMarkerActivityTimer.changeTimeout(timeout);
|
||||
this.doManageReadMarkers();
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Debounced function to manage read markers because we don't need to
|
||||
* do this on every tiny scroll update. It also sets state which causes
|
||||
* a component update, which can in turn reset the scroll position, so
|
||||
* it's important we allow the browser to scroll a bit before running this
|
||||
* (hence trailing edge only and debounce rather than throttle because
|
||||
* we really only need to update this once the user has finished scrolling,
|
||||
* not periodically while they scroll).
|
||||
*/
|
||||
private doManageReadMarkers = debounce(() => {
|
||||
const rmPosition = this.getReadMarkerPosition();
|
||||
// we hide the read marker when it first comes onto the screen, but if
|
||||
// it goes back off the top of the screen (presumably because the user
|
||||
// clicks on the 'jump to bottom' button), we need to re-enable it.
|
||||
if (rmPosition < 0) {
|
||||
this.setState({ readMarkerVisible: true });
|
||||
}
|
||||
|
||||
// if read marker position goes between 0 and -1/1,
|
||||
// (and user is active), switch timeout
|
||||
const timeout = this.readMarkerTimeout(rmPosition);
|
||||
// NO-OP when timeout already has set to the given value
|
||||
this.readMarkerActivityTimer.changeTimeout(timeout);
|
||||
}, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true });
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
switch (payload.action) {
|
||||
case "ignore_state_changed":
|
||||
@@ -555,9 +576,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
// more than the timeout on userActiveRecently.
|
||||
//
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
const sender = ev.sender ? ev.sender.userId : null;
|
||||
callRMUpdated = false;
|
||||
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
updatedState.readMarkerVisible = true;
|
||||
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
|
||||
// we know we're stuckAtBottom, so we can advance the RM
|
||||
@@ -579,7 +599,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
});
|
||||
};
|
||||
|
||||
private onRoomTimelineReset = (room: Room, timelineSet: TimelineSet): void => {
|
||||
private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => {
|
||||
if (timelineSet !== this.props.timelineSet) return;
|
||||
|
||||
if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) {
|
||||
@@ -666,8 +686,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
|
||||
private readMarkerTimeout(readMarkerPosition: number): number {
|
||||
return readMarkerPosition === 0 ?
|
||||
this.state.readMarkerInViewThresholdMs :
|
||||
this.state.readMarkerOutOfViewThresholdMs;
|
||||
this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs :
|
||||
this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs;
|
||||
}
|
||||
|
||||
private async updateReadMarkerOnUserActivity(): Promise<void> {
|
||||
@@ -758,16 +778,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
this.lastRMSentEventId = this.state.readMarkerEventId;
|
||||
|
||||
const roomId = this.props.timelineSet.room.roomId;
|
||||
const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId);
|
||||
|
||||
debuglog('TimelinePanel: Sending Read Markers for ',
|
||||
this.props.timelineSet.room.roomId,
|
||||
'rm', this.state.readMarkerEventId,
|
||||
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
|
||||
' hidden:' + hiddenRR,
|
||||
);
|
||||
MatrixClientPeg.get().setRoomReadMarkers(
|
||||
this.props.timelineSet.room.roomId,
|
||||
roomId,
|
||||
this.state.readMarkerEventId,
|
||||
lastReadEvent, // Could be null, in which case no RR is sent
|
||||
{},
|
||||
{ hidden: hiddenRR },
|
||||
).catch((e) => {
|
||||
// /read_markers API is not implemented on this HS, fallback to just RR
|
||||
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
|
||||
@@ -792,8 +816,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
// that sending an RR for the latest message will set our notif counter
|
||||
// to zero: it may not do this if we send an RR for somewhere before the end.
|
||||
if (this.isAtEndOfLiveTimeline()) {
|
||||
this.props.timelineSet.room.setUnreadNotificationCount('total', 0);
|
||||
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
|
||||
this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0);
|
||||
this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
|
||||
dis.dispatch({
|
||||
action: 'on_room_read',
|
||||
roomId: this.props.timelineSet.room.roomId,
|
||||
@@ -863,7 +887,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
for (i++; i < events.length; i++) {
|
||||
const ev = events[i];
|
||||
if (!ev.sender || ev.sender.userId != myUserId) {
|
||||
if (ev.getSender() !== myUserId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1051,6 +1075,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
{ windowLimit: this.props.timelineCap });
|
||||
|
||||
const onLoaded = () => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// clear the timeline min-height when
|
||||
// (re)loading the timeline
|
||||
if (this.messagePanel.current) {
|
||||
@@ -1074,7 +1100,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
// we're in a setState callback, and we know
|
||||
// timelineLoading is now false, so render() should have
|
||||
// mounted the message panel.
|
||||
console.log("can't initialise scroll state because " +
|
||||
logger.log("can't initialise scroll state because " +
|
||||
"messagePanel didn't load");
|
||||
return;
|
||||
}
|
||||
@@ -1092,11 +1118,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
this.setState({ timelineLoading: false });
|
||||
console.error(
|
||||
`Error loading timeline panel at ${eventId}: ${error}`,
|
||||
);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
let onFinished;
|
||||
|
||||
@@ -1170,6 +1197,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
this.setState(this.getEvents());
|
||||
}
|
||||
|
||||
// Force refresh the timeline before threads support pending events
|
||||
public refreshTimeline(): void {
|
||||
this.loadTimeline();
|
||||
this.reloadEvents();
|
||||
}
|
||||
|
||||
// get the list of events from the timeline window and the pending event list
|
||||
private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
|
||||
const events: MatrixEvent[] = this.timelineWindow.getEvents();
|
||||
@@ -1334,8 +1367,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
const shouldIgnore = !!ev.status || // local echo
|
||||
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
|
||||
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context);
|
||||
(ignoreOwn && ev.getSender() === myUserId); // own message
|
||||
const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) ||
|
||||
shouldHideEvent(ev, this.context);
|
||||
|
||||
if (isWithoutTile || !node) {
|
||||
// don't start counting if the event should be ignored,
|
||||
@@ -1416,7 +1450,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
private getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args);
|
||||
private getRelationsForEvent = (
|
||||
eventId: string,
|
||||
relationType: RelationType,
|
||||
eventType: EventType | string,
|
||||
) => this.props.timelineSet.getRelationsForEvent(eventId, relationType, eventType);
|
||||
|
||||
render() {
|
||||
// just show a spinner while the timeline loads.
|
||||
@@ -1441,7 +1479,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
|
||||
return (
|
||||
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
|
||||
<div className="mx_RoomView_empty">{this.props.empty}</div>
|
||||
<div className="mx_RoomView_empty">{ this.props.empty }</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1486,8 +1524,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
onUserScroll={this.props.onUserScroll}
|
||||
onFillRequest={this.onMessageListFillRequest}
|
||||
onUnfillRequest={this.onMessageListUnfillRequest}
|
||||
isTwelveHour={this.state.isTwelveHour}
|
||||
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.alwaysShowTimestamps}
|
||||
isTwelveHour={this.context?.showTwelveHourTimestamps ?? this.state.isTwelveHour}
|
||||
alwaysShowTimestamps={
|
||||
this.props.alwaysShowTimestamps ??
|
||||
this.context?.alwaysShowTimestamps ??
|
||||
this.state.alwaysShowTimestamps
|
||||
}
|
||||
className={this.props.className}
|
||||
tileShape={this.props.tileShape}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
@@ -1496,6 +1538,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
showReactions={this.props.showReactions}
|
||||
layout={this.props.layout}
|
||||
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
|
||||
hideThreadedMessages={this.props.hideThreadedMessages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
@@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
||||
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,
|
||||
@@ -88,7 +99,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
||||
return toast
|
||||
? (
|
||||
<div className={containerClasses} role="alert">
|
||||
{toast}
|
||||
{ toast }
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -104,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>
|
||||
|
||||
@@ -59,6 +59,7 @@ import RoomName from "../views/elements/RoomName";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import InlineSpinner from "../views/elements/InlineSpinner";
|
||||
import TooltipButton from "../views/elements/TooltipButton";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
@@ -90,7 +91,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
if (SettingsStore.getValue("feature_spaces")) {
|
||||
if (SpaceStore.spacesEnabled) {
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
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);
|
||||
@@ -239,7 +240,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
|
||||
// TODO: Archived room view: https://github.com/vector-im/element-web/issues/14038
|
||||
// Note: You'll need to uncomment the button too.
|
||||
console.log("TODO: Show archived rooms");
|
||||
logger.log("TODO: Show archived rooms");
|
||||
};
|
||||
|
||||
private onProvideFeedback = (ev: ButtonEvent) => {
|
||||
@@ -342,20 +343,20 @@ 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) {
|
||||
@@ -394,17 +395,17 @@ 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")}
|
||||
@@ -420,11 +421,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
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>
|
||||
@@ -443,7 +444,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>
|
||||
);
|
||||
@@ -470,13 +471,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 = (
|
||||
@@ -485,10 +486,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>
|
||||
@@ -540,7 +541,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}
|
||||
@@ -553,9 +554,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
/>
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{topSection}
|
||||
{primaryOptionList}
|
||||
{secondarySection}
|
||||
{ topSection }
|
||||
{ primaryOptionList }
|
||||
{ secondarySection }
|
||||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
@@ -570,27 +571,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");
|
||||
@@ -598,8 +599,8 @@ 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;
|
||||
@@ -647,20 +648,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
||||
className="mx_UserMenu_userAvatar"
|
||||
/>
|
||||
</span>
|
||||
{name}
|
||||
{this.state.pendingRoomJoin.size > 0 && (
|
||||
{ name }
|
||||
{ this.state.pendingRoomJoin.size > 0 && (
|
||||
<InlineSpinner>
|
||||
<TooltipButton helpText={_t(
|
||||
"Currently joining %(count)s rooms",
|
||||
{ count: this.state.pendingRoomJoin.size },
|
||||
)} />
|
||||
</InlineSpinner>
|
||||
)}
|
||||
{dnd}
|
||||
{buttons}
|
||||
) }
|
||||
{ dnd }
|
||||
{ buttons }
|
||||
</div>
|
||||
</ContextMenuButton>
|
||||
{this.renderContextMenu()}
|
||||
{ this.renderContextMenu() }
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,52 +16,60 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
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 ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import MainSplit from "./MainSplit";
|
||||
import RightPanel from "./RightPanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
|
||||
interface IProps {
|
||||
userId?: string;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loading: boolean;
|
||||
member?: RoomMember;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.UserView")
|
||||
export default class UserView extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
userId: PropTypes.string,
|
||||
export default class UserView extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
if (this.props.userId) {
|
||||
this._loadProfileInfo();
|
||||
this.loadProfileInfo();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
// XXX: We shouldn't need to null check the userId here, but we declare
|
||||
// it as optional and MatrixChat sometimes fires in a way which results
|
||||
// in an NPE when we try to update the profile info.
|
||||
if (prevProps.userId !== this.props.userId && this.props.userId) {
|
||||
this._loadProfileInfo();
|
||||
this.loadProfileInfo();
|
||||
}
|
||||
}
|
||||
|
||||
async _loadProfileInfo() {
|
||||
private async loadProfileInfo(): Promise<void> {
|
||||
const cli = MatrixClientPeg.get();
|
||||
this.setState({ loading: true });
|
||||
let profileInfo;
|
||||
try {
|
||||
profileInfo = await cli.getProfileInfo(this.props.userId);
|
||||
} catch (err) {
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, {
|
||||
title: _t('Could not load user profile'),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
@@ -75,14 +83,11 @@ export default class UserView extends React.Component {
|
||||
this.setState({ member, loading: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
if (this.state.loading) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
} else if (this.state.member) {
|
||||
const RightPanel = sdk.getComponent('structures.RightPanel');
|
||||
const MainSplit = sdk.getComponent('structures.MainSplit');
|
||||
const panel = <RightPanel user={this.state.member} />;
|
||||
const panel = <RightPanel member={this.state.member} resizeNotifier={this.props.resizeNotifier} />;
|
||||
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
|
||||
<HomePage />
|
||||
</MainSplit>);
|
||||
@@ -17,24 +17,28 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import SyntaxHighlight from "../views/elements/SyntaxHighlight";
|
||||
import { _t } from "../../languageHandler";
|
||||
import * as sdk from "../../index";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog";
|
||||
import { canEditContent } from "../../utils/EventUtils";
|
||||
import { MatrixClientPeg } from '../../MatrixClientPeg';
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { IDialogProps } from "../views/dialogs/IDialogProps";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu
|
||||
}
|
||||
|
||||
interface IState {
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.ViewSource")
|
||||
export default class ViewSource extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
export default class ViewSource extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
@@ -42,19 +46,20 @@ export default class ViewSource extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
onBack() {
|
||||
private onBack(): void {
|
||||
// TODO: refresh the "Event ID:" modal header
|
||||
this.setState({ isEditing: false });
|
||||
}
|
||||
|
||||
onEdit() {
|
||||
private onEdit(): void {
|
||||
this.setState({ isEditing: true });
|
||||
}
|
||||
|
||||
// returns the dialog body for viewing the event source
|
||||
viewSourceContent() {
|
||||
private viewSourceContent(): JSX.Element {
|
||||
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();
|
||||
// @ts-ignore
|
||||
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
|
||||
const originalEventSource = mxEvent.event;
|
||||
|
||||
@@ -63,30 +68,30 @@ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// returns the id of the initial message, not the id of the previous edit
|
||||
getBaseEventId() {
|
||||
private getBaseEventId(): string {
|
||||
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 baseMxEvent = this.props.mxEvent;
|
||||
@@ -100,7 +105,7 @@ export default class ViewSource extends React.Component {
|
||||
}
|
||||
|
||||
// returns the SendCustomEvent component prefilled with the correct details
|
||||
editSourceContent() {
|
||||
private editSourceContent(): JSX.Element {
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
|
||||
const isStateEvent = mxEvent.isState();
|
||||
@@ -110,7 +115,7 @@ export default class ViewSource extends React.Component {
|
||||
if (isStateEvent) {
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
{ (cli) => (
|
||||
<SendCustomEvent
|
||||
room={cli.getRoom(roomId)}
|
||||
forceStateEvent={true}
|
||||
@@ -121,7 +126,7 @@ export default class ViewSource extends React.Component {
|
||||
stateKey: mxEvent.getStateKey(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) }
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
} else {
|
||||
@@ -142,7 +147,7 @@ export default class ViewSource extends React.Component {
|
||||
};
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
{ (cli) => (
|
||||
<SendCustomEvent
|
||||
room={cli.getRoom(roomId)}
|
||||
forceStateEvent={false}
|
||||
@@ -153,20 +158,19 @@ export default class ViewSource extends React.Component {
|
||||
evContent: JSON.stringify(newContent, null, "\t"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) }
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
canSendStateEvent(mxEvent) {
|
||||
private canSendStateEvent(mxEvent: MatrixEvent): boolean {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(mxEvent.getRoomId());
|
||||
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
|
||||
public render(): JSX.Element {
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
|
||||
const isEditing = this.state.isEditing;
|
||||
@@ -176,16 +180,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>
|
||||
);
|
||||
}
|
||||
@@ -15,50 +15,65 @@ 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 } from '../../../stores/SetupEncryptionStore';
|
||||
import SetupEncryptionBody from "./SetupEncryptionBody";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AccessibleButton from '../../views/elements/AccessibleButton';
|
||||
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
lostKeys: boolean;
|
||||
}
|
||||
|
||||
@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, lostKeys: store.lostKeys() };
|
||||
}
|
||||
|
||||
_onStoreUpdate = () => {
|
||||
private onStoreUpdate = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
this.setState({ phase: store.phase });
|
||||
this.setState({ phase: store.phase, lostKeys: store.lostKeys() });
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
private onSkipClick = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.off("update", this._onStoreUpdate);
|
||||
store.skip();
|
||||
};
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
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, lostKeys } = this.state;
|
||||
let icon;
|
||||
let title;
|
||||
|
||||
if (phase === Phase.Loading) {
|
||||
return null;
|
||||
} else if (phase === Phase.Intro) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Verify this login");
|
||||
if (lostKeys) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Unable to verify this login");
|
||||
} else {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Verify this login");
|
||||
}
|
||||
} else if (phase === Phase.Done) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
|
||||
title = _t("Session verified");
|
||||
@@ -68,16 +83,29 @@ export default class CompleteSecurity extends React.Component {
|
||||
} else if (phase === Phase.Busy) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Verify this login");
|
||||
} else if (phase === Phase.ConfirmReset) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("Really reset verification keys?");
|
||||
} else if (phase === Phase.Finished) {
|
||||
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
|
||||
} else {
|
||||
throw new Error(`Unknown phase ${phase}`);
|
||||
}
|
||||
|
||||
let skipButton;
|
||||
if (phase === Phase.Intro || phase === Phase.ConfirmReset) {
|
||||
skipButton = (
|
||||
<AccessibleButton onClick={this.onSkipClick} className="mx_CompleteSecurity_skip" aria-label={_t("Skip verification for now")} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<CompleteSecurityBody>
|
||||
<h2 className="mx_CompleteSecurity_header">
|
||||
{icon}
|
||||
{title}
|
||||
{ icon }
|
||||
{ title }
|
||||
{ skipButton }
|
||||
</h2>
|
||||
<div className="mx_CompleteSecurity_body">
|
||||
<SetupEncryptionBody onFinished={this.props.onFinished} />
|
||||
@@ -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";
|
||||
|
||||
@replaceableComponent("structures.auth.E2eSetup")
|
||||
export default class E2eSetup extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
accountPassword: PropTypes.string,
|
||||
tokenLogin: PropTypes.bool,
|
||||
};
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
accountPassword?: string;
|
||||
tokenLogin?: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.E2eSetup")
|
||||
export default class E2eSetup extends React.Component<IProps> {
|
||||
render() {
|
||||
return (
|
||||
<AuthPage>
|
||||
@@ -17,7 +17,6 @@ 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";
|
||||
@@ -31,27 +30,52 @@ import PassphraseField from '../../views/auth/PassphraseField';
|
||||
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>
|
||||
);
|
||||
@@ -18,7 +18,6 @@ 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 SdkConfig from '../../../SdkConfig';
|
||||
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
|
||||
@@ -36,6 +35,10 @@ import Spinner from "../../views/elements/Spinner";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from "../../views/elements/ServerPicker";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AuthBody from "../../views/auth/AuthBody";
|
||||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
// 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.
|
||||
@@ -143,7 +146,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);
|
||||
}
|
||||
@@ -153,7 +156,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;
|
||||
@@ -238,8 +241,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) {
|
||||
@@ -250,10 +253,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 },
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -437,7 +440,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||
// technically the flow can have multiple steps, but no one does this
|
||||
// for login and loginLogic doesn't support it so we can ignore it.
|
||||
if (!this.stepRendererMap[flow.type]) {
|
||||
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
|
||||
logger.log("Skipping flow", flow, "due to unsupported login type", flow.type);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -462,7 +465,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 }
|
||||
@@ -541,8 +546,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;
|
||||
|
||||
@@ -566,7 +569,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||
});
|
||||
serverDeadSection = (
|
||||
<div className={classes}>
|
||||
{this.state.serverDeadError}
|
||||
{ this.state.serverDeadError }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -579,15 +582,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>
|
||||
);
|
||||
}
|
||||
@@ -597,8 +600,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 }
|
||||
|
||||
@@ -18,19 +18,26 @@ 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 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 dis from "../../../dispatcher/dispatcher";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from '../../views/elements/ServerPicker';
|
||||
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";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
@@ -47,13 +54,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;
|
||||
@@ -142,7 +143,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;
|
||||
@@ -216,7 +217,7 @@ export default class Registration extends React.Component<IProps, IState> {
|
||||
if (!this.state.doingUIAuth) {
|
||||
await this.makeRegisterRequest(null);
|
||||
// This should never succeed since we specified no auth object.
|
||||
console.log("Expecting 401 from register request but got success!");
|
||||
logger.log("Expecting 401 from register request but got success!");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.httpStatus === 401) {
|
||||
@@ -240,13 +241,13 @@ export default class Registration extends React.Component<IProps, IState> {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("Unable to query for supported registration methods.", e);
|
||||
logger.log("Unable to query for supported registration methods.", e);
|
||||
showGenericError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onFormSubmit = formVals => {
|
||||
private onFormSubmit = async (formVals): Promise<void> => {
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
@@ -291,8 +292,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;
|
||||
@@ -331,7 +332,7 @@ export default class Registration extends React.Component<IProps, IState> {
|
||||
// the user had a separate guest session they didn't actually mean to replace.
|
||||
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
|
||||
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
|
||||
);
|
||||
newState.differentLoggedInUserId = sessionOwner;
|
||||
@@ -367,7 +368,7 @@ export default class Registration extends React.Component<IProps, IState> {
|
||||
const emailPusher = pushers[i];
|
||||
emailPusher.data = { brand: this.props.brand };
|
||||
matrixClient.setPusher(emailPusher).then(() => {
|
||||
console.log("Set email branding to " + this.props.brand);
|
||||
logger.log("Set email branding to " + this.props.brand);
|
||||
}, (error) => {
|
||||
console.error("Couldn't set email branding: " + error);
|
||||
});
|
||||
@@ -442,10 +443,6 @@ export default class Registration extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
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}
|
||||
@@ -487,13 +484,13 @@ export default class Registration extends React.Component<IProps, IState> {
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
/>
|
||||
<h3 className="mx_AuthBody_centered">
|
||||
{_t(
|
||||
{ _t(
|
||||
"%(ssoButtons)s Or %(usernamePassword)s",
|
||||
{
|
||||
ssoButtons: "",
|
||||
usernamePassword: "",
|
||||
},
|
||||
).trim()}
|
||||
).trim() }
|
||||
</h3>
|
||||
</React.Fragment>;
|
||||
}
|
||||
@@ -516,10 +513,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) {
|
||||
@@ -535,15 +528,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
|
||||
@@ -559,43 +552,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={async event => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_welcome_page" });
|
||||
}
|
||||
}}>
|
||||
{_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 {
|
||||
|
||||
@@ -21,13 +21,15 @@ import Modal from '../../../Modal';
|
||||
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
|
||||
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { ISecretStorageKeyInfo } from 'matrix-js-sdk';
|
||||
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";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
|
||||
return Boolean(
|
||||
keyInfo.passphrase &&
|
||||
@@ -44,6 +46,7 @@ interface IState {
|
||||
phase: Phase;
|
||||
verificationRequest: VerificationRequest;
|
||||
backupInfo: IKeyBackupInfo;
|
||||
lostKeys: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("structures.auth.SetupEncryptionBody")
|
||||
@@ -60,6 +63,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
// Because of the latter, it lives in the state.
|
||||
verificationRequest: store.verificationRequest,
|
||||
backupInfo: store.backupInfo,
|
||||
lostKeys: store.lostKeys(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +77,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
phase: store.phase,
|
||||
verificationRequest: store.verificationRequest,
|
||||
backupInfo: store.backupInfo,
|
||||
lostKeys: store.lostKeys(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -103,11 +108,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
});
|
||||
};
|
||||
|
||||
private onSkipClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.skip();
|
||||
};
|
||||
|
||||
private onSkipConfirmClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.skipConfirm();
|
||||
@@ -118,6 +118,22 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
store.returnAfterSkip();
|
||||
};
|
||||
|
||||
private onResetClick = (ev: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
ev.preventDefault();
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.reset();
|
||||
};
|
||||
|
||||
private onResetConfirmClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.resetConfirm();
|
||||
};
|
||||
|
||||
private onResetBackClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.returnAfterReset();
|
||||
};
|
||||
|
||||
private onDoneClick = () => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.done();
|
||||
@@ -130,6 +146,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
public render() {
|
||||
const {
|
||||
phase,
|
||||
lostKeys,
|
||||
} = this.state;
|
||||
|
||||
if (this.state.verificationRequest) {
|
||||
@@ -141,65 +158,89 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
isRoomEncrypted={false}
|
||||
/>;
|
||||
} else if (phase === Phase.Intro) {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
let recoveryKeyPrompt;
|
||||
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
|
||||
recoveryKeyPrompt = _t("Use Security Key or Phrase");
|
||||
} else if (store.keyInfo) {
|
||||
recoveryKeyPrompt = _t("Use Security Key");
|
||||
}
|
||||
if (lostKeys) {
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"It looks like you don't have a Security Key or any other devices you can " +
|
||||
"verify against. This device will not be able to access old encrypted messages. " +
|
||||
"In order to verify your identity on this device, you'll need to reset " +
|
||||
"your verification keys.",
|
||||
) }</p>
|
||||
|
||||
let useRecoveryKeyButton;
|
||||
if (recoveryKeyPrompt) {
|
||||
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}>
|
||||
{recoveryKeyPrompt}
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let verifyButton;
|
||||
if (store.hasDevicesToVerifyAgainst) {
|
||||
verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
|
||||
{ _t("Use another login") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
"Verify your identity to access encrypted messages and prove your identity to others.",
|
||||
)}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
{verifyButton}
|
||||
{useRecoveryKeyButton}
|
||||
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
|
||||
{_t("Skip")}
|
||||
</AccessibleButton>
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
|
||||
{ _t("Proceed with reset") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
} else {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
let recoveryKeyPrompt;
|
||||
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
|
||||
recoveryKeyPrompt = _t("Verify with Security Key or Phrase");
|
||||
} else if (store.keyInfo) {
|
||||
recoveryKeyPrompt = _t("Verify with Security Key");
|
||||
}
|
||||
|
||||
let useRecoveryKeyButton;
|
||||
if (recoveryKeyPrompt) {
|
||||
useRecoveryKeyButton = <AccessibleButton kind="primary" onClick={this.onUsePassphraseClick}>
|
||||
{ recoveryKeyPrompt }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
let verifyButton;
|
||||
if (store.hasDevicesToVerifyAgainst) {
|
||||
verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
|
||||
{ _t("Verify with another login") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"Verify your identity to access encrypted messages and prove your identity to others.",
|
||||
) }</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
{ verifyButton }
|
||||
{ useRecoveryKeyButton }
|
||||
</div>
|
||||
<div className="mx_SetupEncryptionBody_reset">
|
||||
{ _t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
|
||||
a: (sub) => <a
|
||||
href=""
|
||||
onClick={this.onResetClick}
|
||||
className="mx_SetupEncryptionBody_reset_link">{ sub }</a>,
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} 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>
|
||||
@@ -207,23 +248,46 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
} else if (phase === Phase.ConfirmSkip) {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t(
|
||||
<p>{ _t(
|
||||
"Without verifying, you won’t 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"
|
||||
kind="danger_outline"
|
||||
onClick={this.onSkipConfirmClick}
|
||||
>
|
||||
{_t("Skip")}
|
||||
{ _t("I'll verify later") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="danger"
|
||||
kind="primary"
|
||||
onClick={this.onSkipBackClick}
|
||||
>
|
||||
{_t("Go Back")}
|
||||
{ _t("Go Back") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === Phase.ConfirmReset) {
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t(
|
||||
"Resetting your verification keys cannot be undone. After resetting, " +
|
||||
"you won't have access to old encrypted messages, and any friends who " +
|
||||
"have previously verified you will see security warnings until you " +
|
||||
"re-verify with them.",
|
||||
) }</p>
|
||||
<p>{ _t(
|
||||
"Please only proceed if you're sure you've lost all of your other " +
|
||||
"devices and your security key.",
|
||||
) }</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
|
||||
{ _t("Proceed with reset") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
|
||||
{ _t("Go Back") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,7 +295,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
|
||||
} else if (phase === Phase.Busy || phase === Phase.Loading) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
console.log(`SetupEncryptionBody: Unknown phase ${phase}`);
|
||||
logger.log(`SetupEncryptionBody: Unknown phase ${phase}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import * as Lifecycle from '../../../Lifecycle';
|
||||
import Modal from '../../../Modal';
|
||||
@@ -26,6 +25,14 @@ import AuthPage from "../../views/auth/AuthPage";
|
||||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
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";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
const LOGIN_VIEW = {
|
||||
LOADING: 1,
|
||||
@@ -94,12 +101,11 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
onClearAll = () => {
|
||||
const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog');
|
||||
Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, {
|
||||
onFinished: (wipeData) => {
|
||||
if (!wipeData) return;
|
||||
|
||||
console.log("Clearing data from soft-logged-out session");
|
||||
logger.log("Clearing data from soft-logged-out session");
|
||||
Lifecycle.logout();
|
||||
},
|
||||
});
|
||||
@@ -202,7 +208,6 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
|
||||
private renderSignInSection() {
|
||||
if (this.state.loginView === LOGIN_VIEW.LOADING) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
@@ -214,12 +219,9 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -228,8 +230,8 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
|
||||
return (
|
||||
<form onSubmit={this.onPasswordLogin}>
|
||||
<p>{introText}</p>
|
||||
{error}
|
||||
<p>{ introText }</p>
|
||||
{ error }
|
||||
<Field
|
||||
type="password"
|
||||
label={_t("Password")}
|
||||
@@ -243,10 +245,10 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
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>
|
||||
);
|
||||
@@ -262,7 +264,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{introText}</p>
|
||||
<p>{ introText }</p>
|
||||
<SSOButtons
|
||||
matrixClient={MatrixClientPeg.get()}
|
||||
flow={flow}
|
||||
@@ -277,43 +279,39 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
// 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>
|
||||
|
||||
@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { createRef, ReactNode, RefObject } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { formatBytes } from "../../../utils/FormattingUtils";
|
||||
@@ -25,44 +23,13 @@ import { Key } from "../../../Keyboard";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SeekBar from "./SeekBar";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
|
||||
mediaName: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
|
||||
@replaceableComponent("views.audio_messages.AudioPlayer")
|
||||
export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||
export default class AudioPlayer extends AudioPlayerBase {
|
||||
private playPauseRef: RefObject<PlayPauseButton> = createRef();
|
||||
private seekRef: RefObject<SeekBar> = createRef();
|
||||
|
||||
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.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.prepare();
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
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
|
||||
@@ -88,37 +55,39 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
|
||||
return `(${formatBytes(bytes)})`;
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
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} />
|
||||
{/* easiest way to introduce a gap between the components */}
|
||||
{ this.renderFileSize() }
|
||||
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} />
|
||||
{ /* 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>
|
||||
<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>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal file
70
src/components/views/audio_messages/AudioPlayerBase.tsx
Normal 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> }
|
||||
</>;
|
||||
}
|
||||
}
|
||||
@@ -15,34 +15,30 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { formatSeconds } from "../../../DateUtils";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
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.audio_messages.Clock")
|
||||
export default class Clock extends React.Component<IProps, IState> {
|
||||
export default class Clock extends React.Component<IProps> {
|
||||
public constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>, nextContext: any): boolean {
|
||||
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.floor(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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback } from "../../../voice/Playback";
|
||||
import { Playback } from "../../../audio/Playback";
|
||||
|
||||
interface IProps {
|
||||
playback: Playback;
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
@@ -15,10 +15,9 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording";
|
||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { arrayFastResample } from "../../../utils/arrays";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
||||
import Waveform from "./Waveform";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
@@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
waveform: [],
|
||||
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||
// The incoming data is between zero and one, but typically even screaming into a
|
||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
||||
// user.
|
||||
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import React, { ReactNode } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import classNames from "classnames";
|
||||
|
||||
// omitted props are handled by render function
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import React from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Clock from "./Clock";
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
|
||||
interface IProps {
|
||||
|
||||
@@ -18,7 +18,7 @@ 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 "../../../voice/Playback";
|
||||
import { Playback, PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/Playback";
|
||||
import { percentageOf } from "../../../utils/numbers";
|
||||
|
||||
interface IProps {
|
||||
|
||||
@@ -14,51 +14,30 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import React, { ReactNode } from "react";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import PlaybackWaveform from "./PlaybackWaveform";
|
||||
import PlayPauseButton from "./PlayPauseButton";
|
||||
import PlaybackClock from "./PlaybackClock";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps {
|
||||
// Playback instance to render. Cannot change during component lifecycle: create
|
||||
// an all-new component instead.
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
playbackPhase: PlaybackState;
|
||||
}
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import PlaybackWaveform from "./PlaybackWaveform";
|
||||
import AudioPlayerBase from "./AudioPlayerBase";
|
||||
|
||||
@replaceableComponent("views.audio_messages.RecordingPlayback")
|
||||
export default class RecordingPlayback 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.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.props.playback.prepare();
|
||||
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;
|
||||
}
|
||||
|
||||
private onPlaybackUpdate = (ev: PlaybackState) => {
|
||||
this.setState({ playbackPhase: ev });
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
return <div className='mx_MediaBody mx_VoiceMessagePrimaryContainer'>
|
||||
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
|
||||
<PlaybackClock playback={this.props.playback} />
|
||||
<PlaybackWaveform playback={this.props.playback} />
|
||||
</div>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Playback, PlaybackState } from "../../../voice/Playback";
|
||||
import { Playback, PlaybackState } from "../../../audio/Playback";
|
||||
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||
|
||||
@@ -47,17 +47,21 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
|
||||
|
||||
public render() {
|
||||
return <div className='mx_Waveform'>
|
||||
{this.props.relHeights.map((h, i) => {
|
||||
{ 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} />;
|
||||
})}
|
||||
return <span
|
||||
key={i}
|
||||
style={{
|
||||
"--barHeight": h,
|
||||
} as WaveformCSSProperties}
|
||||
className={classes}
|
||||
/>;
|
||||
}) }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ 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>;
|
||||
@@ -22,7 +22,7 @@ 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>
|
||||
@@ -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 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 />
|
||||
@@ -19,7 +19,7 @@ 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>;
|
||||
@@ -17,18 +17,16 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
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>
|
||||
@@ -15,66 +15,76 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
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();};
|
||||
logger.log("Loading recaptcha script...");
|
||||
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 +94,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() {
|
||||
console.log("Loaded recaptcha script.");
|
||||
private onCaptchaLoaded() {
|
||||
logger.log("Loaded recaptcha script.");
|
||||
try {
|
||||
this._renderRecaptcha(DIV_ID);
|
||||
this.renderRecaptcha(DIV_ID);
|
||||
// clear error if re-rendered
|
||||
this.setState({
|
||||
errorText: null,
|
||||
@@ -128,10 +138,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>
|
||||
@@ -19,7 +19,7 @@ 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>;
|
||||
@@ -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 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,
|
||||
};
|
||||
@@ -17,8 +17,8 @@ limitations under the License.
|
||||
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";
|
||||
@@ -26,6 +26,10 @@ import Spinner from "../elements/Spinner";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { LocalisedPolicy, Policies } from '../../../Terms';
|
||||
import Field from '../elements/Field';
|
||||
import CaptchaForm from "./CaptchaForm";
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
/* This file contains a collection of components which are used by the
|
||||
* InteractiveAuth to prompt the user to enter the information needed
|
||||
@@ -40,7 +44,7 @@ import { LocalisedPolicy, Policies } from '../../../Terms';
|
||||
* 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
|
||||
@@ -53,8 +57,8 @@ import { LocalisedPolicy, Policies } from '../../../Terms';
|
||||
* 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.
|
||||
@@ -73,33 +77,6 @@ import { LocalisedPolicy, Policies } from '../../../Terms';
|
||||
* focus: set the input focus appropriately in the form.
|
||||
*/
|
||||
|
||||
enum AuthType {
|
||||
Password = "m.login.password",
|
||||
Recaptcha = "m.login.recaptcha",
|
||||
Terms = "m.login.terms",
|
||||
Email = "m.login.email.identity",
|
||||
Msisdn = "m.login.msisdn",
|
||||
Sso = "m.login.sso",
|
||||
SsoUnstable = "org.matrix.login.sso",
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IAuthDict {
|
||||
type?: AuthType;
|
||||
// TODO: Remove `user` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
user?: string;
|
||||
identifier?: any;
|
||||
password?: string;
|
||||
response?: string;
|
||||
// TODO: Remove `threepid_creds` once servers support proper UIA
|
||||
// See https://github.com/vector-im/element-web/issues/10312
|
||||
// See https://github.com/matrix-org/matrix-doc/issues/2220
|
||||
threepid_creds?: any;
|
||||
threepidCreds?: any;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
export const DEFAULT_PHASE = 0;
|
||||
|
||||
interface IAuthEntryProps {
|
||||
@@ -164,8 +141,7 @@ export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswor
|
||||
|
||||
let submitButtonOrSpinner;
|
||||
if (this.props.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
submitButtonOrSpinner = <Loader />;
|
||||
submitButtonOrSpinner = <Spinner />;
|
||||
} else {
|
||||
submitButtonOrSpinner = (
|
||||
<input type="submit"
|
||||
@@ -185,8 +161,6 @@ export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswor
|
||||
);
|
||||
}
|
||||
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{ _t("Confirm your identity by entering your account password below.") }</p>
|
||||
@@ -236,13 +210,11 @@ export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps
|
||||
|
||||
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(
|
||||
@@ -390,8 +362,7 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
|
||||
|
||||
render() {
|
||||
if (this.props.busy) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const checkboxes = [];
|
||||
@@ -421,13 +392,15 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
|
||||
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 }
|
||||
@@ -584,14 +557,13 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
|
||||
}
|
||||
} catch (e) {
|
||||
this.props.fail(e);
|
||||
console.log("Failed to submit msisdn token");
|
||||
logger.log("Failed to submit msisdn token");
|
||||
}
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -619,15 +591,17 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
|
||||
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
|
||||
value={this.state.token}
|
||||
onChange={this.onTokenChange}
|
||||
aria-label={ _t("Code")}
|
||||
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>
|
||||
);
|
||||
@@ -723,21 +697,21 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -759,8 +733,8 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
|
||||
return <React.Fragment>
|
||||
{ errorSection }
|
||||
<div className="mx_InteractiveAuthEntryComponents_sso_buttons">
|
||||
{cancelButton}
|
||||
{continueButton}
|
||||
{ cancelButton }
|
||||
{ continueButton }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
@@ -831,13 +805,32 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
|
||||
<a href="" ref={this.fallbackButton} onClick={this.onShowFallbackClick}>{
|
||||
_t("Start authentication")
|
||||
}</a>
|
||||
{errorSection}
|
||||
{ errorSection }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
|
||||
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;
|
||||
|
||||
@@ -18,21 +18,23 @@ import SdkConfig from "../../../SdkConfig";
|
||||
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 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}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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';
|
||||
@@ -31,6 +30,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import Field from '../elements/Field';
|
||||
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
|
||||
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 }
|
||||
|
||||
@@ -17,7 +17,7 @@ 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";
|
||||
@@ -25,21 +25,26 @@ import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
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;
|
||||
@@ -187,7 +187,8 @@ const BaseAvatar = (props: IProps) => {
|
||||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt={_t("Avatar")}
|
||||
title={title}
|
||||
alt={_t("Avatar")}
|
||||
inputRef={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
@@ -201,7 +202,8 @@ const BaseAvatar = (props: IProps) => {
|
||||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt=""
|
||||
title={title}
|
||||
alt=""
|
||||
ref={inputRef}
|
||||
{...otherProps} />
|
||||
);
|
||||
|
||||
@@ -205,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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,10 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
|
||||
resizeMethod?: ResizeMethod;
|
||||
// The onClick to give the avatar
|
||||
onClick?: React.MouseEventHandler;
|
||||
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
|
||||
// Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser`
|
||||
viewUserOnClick?: boolean;
|
||||
title?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,43 +15,48 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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 { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
interface IProps {
|
||||
member: RoomMember;
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeMethod?: ResizeMethod;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hasStatus: boolean;
|
||||
menuDisplayed: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.avatars.MemberStatusMessageAvatar")
|
||||
export default class MemberStatusMessageAvatar extends React.Component {
|
||||
static propTypes = {
|
||||
member: PropTypes.object.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
private button = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hasStatus: this.hasStatus,
|
||||
menuDisplayed: false,
|
||||
};
|
||||
|
||||
this._button = createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
|
||||
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
|
||||
}
|
||||
@@ -62,44 +67,44 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
|
||||
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.removeListener(
|
||||
"User._unstable_statusMessage",
|
||||
this._onStatusMessageCommitted,
|
||||
this.onStatusMessageCommitted,
|
||||
);
|
||||
}
|
||||
|
||||
get hasStatus() {
|
||||
private get hasStatus(): boolean {
|
||||
const { user } = this.props.member;
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return !!user._unstable_statusMessage;
|
||||
return !!user.unstable_statusMessage;
|
||||
}
|
||||
|
||||
_onStatusMessageCommitted = () => {
|
||||
private onStatusMessageCommitted = (): void => {
|
||||
// The `User` object has observed a status message change.
|
||||
this.setState({
|
||||
hasStatus: this.hasStatus,
|
||||
});
|
||||
};
|
||||
|
||||
openMenu = () => {
|
||||
private openMenu = (): void => {
|
||||
this.setState({ menuDisplayed: true });
|
||||
};
|
||||
|
||||
closeMenu = () => {
|
||||
private closeMenu = (): void => {
|
||||
this.setState({ menuDisplayed: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
const avatar = <MemberAvatar
|
||||
member={this.props.member}
|
||||
width={this.props.width}
|
||||
@@ -118,7 +123,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.menuDisplayed) {
|
||||
const elementRect = this._button.current.getBoundingClientRect();
|
||||
const elementRect = this.button.current.getBoundingClientRect();
|
||||
|
||||
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
|
||||
const chevronMargin = 1; // Add some spacing away from target
|
||||
@@ -126,13 +131,13 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronOffset={(elementRect.width - chevronWidth) / 2}
|
||||
chevronFace="bottom"
|
||||
chevronFace={ChevronFace.Bottom}
|
||||
left={elementRect.left + window.pageXOffset}
|
||||
top={elementRect.top + window.pageYOffset - chevronMargin}
|
||||
menuWidth={226}
|
||||
onFinished={this.closeMenu}
|
||||
>
|
||||
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} />
|
||||
<StatusMessageContextMenu user={this.props.member.user} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -140,12 +145,12 @@ export default class MemberStatusMessageAvatar extends React.Component {
|
||||
return <React.Fragment>
|
||||
<ContextMenuButton
|
||||
className={classes}
|
||||
inputRef={this._button}
|
||||
inputRef={this.button}
|
||||
onClick={this.openMenu}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
label={_t("User Status")}
|
||||
>
|
||||
{avatar}
|
||||
{ avatar }
|
||||
</ContextMenuButton>
|
||||
|
||||
{ contextMenu }
|
||||
@@ -13,15 +13,18 @@ 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 { 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 Modal from '../../../Modal';
|
||||
import * as Avatar from '../../../Avatar';
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { IOOBData } from '../../../stores/ThreepidInviteStore';
|
||||
@@ -31,11 +34,14 @@ interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idNam
|
||||
// oobData.avatarUrl should be set (else there
|
||||
// would be nowhere to get the avatar from)
|
||||
room?: Room;
|
||||
oobData?: IOOBData;
|
||||
oobData?: IOOBData & {
|
||||
roomId?: string;
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizeMethod?: ResizeMethod;
|
||||
viewAvatarOnClick?: boolean;
|
||||
className?: string;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
@@ -128,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}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,8 @@ 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;
|
||||
@@ -105,7 +107,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
|
||||
</div>
|
||||
<img src={image} alt="" />
|
||||
</div>
|
||||
{ extraSettings && <div className="mx_BetaCard_relatedSettings">
|
||||
{ extraSettings && value && <div className="mx_BetaCard_relatedSettings">
|
||||
{ extraSettings.map(key => (
|
||||
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
|
||||
)) }
|
||||
|
||||
@@ -53,7 +53,7 @@ export default class CallContextMenu extends React.Component<IProps> {
|
||||
onTransferClick = () => {
|
||||
Modal.createTrackedDialog(
|
||||
'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call },
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
|
||||
/*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true,
|
||||
);
|
||||
this.props.onFinished();
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ 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 Field from "../elements/Field";
|
||||
import Dialpad from '../voip/DialPad';
|
||||
import DialPad from '../voip/DialPad';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
interface IProps extends IContextMenuProps {
|
||||
@@ -32,6 +33,8 @@ 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);
|
||||
|
||||
@@ -40,9 +43,27 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
||||
};
|
||||
}
|
||||
|
||||
onDigitPress = (digit) => {
|
||||
onDigitPress = (digit: string, ev: ButtonEvent) => {
|
||||
this.props.call.sendDtmfDigit(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) => {
|
||||
@@ -51,18 +72,23 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
||||
|
||||
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>
|
||||
<Field className="mx_DialPadContextMenu_dialled"
|
||||
value={this.state.value} autoFocus={true}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_DialPadContextMenu_horizSep" />
|
||||
<div className="mx_DialPadContextMenu_dialPad">
|
||||
<Dialpad onDigitPress={this.onDigitPress} hasDialAndDelete={false} />
|
||||
</div>
|
||||
</ContextMenu>;
|
||||
}
|
||||
|
||||
@@ -15,45 +15,41 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
/*
|
||||
interface IProps {
|
||||
element: React.ReactNode;
|
||||
// Function to be called when the parent window is resized
|
||||
// This can be used to reposition or close the menu on resize and
|
||||
// ensure that it is not displayed in a stale position.
|
||||
onResize?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
element: PropTypes.element.isRequired,
|
||||
// Function to be called when the parent window is resized
|
||||
// This can be used to reposition or close the menu on resize and
|
||||
// ensure that it is not displayed in a stale position.
|
||||
onResize: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
export default class GenericElementContextMenu extends React.Component<IProps> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.resize = this.resize.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.resize = this.resize.bind(this);
|
||||
public componentDidMount(): void {
|
||||
window.addEventListener("resize", this.resize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
window.removeEventListener("resize", this.resize);
|
||||
}
|
||||
|
||||
resize() {
|
||||
private resize = (): void => {
|
||||
if (this.props.onResize) {
|
||||
this.props.onResize();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
return <div>{ this.props.element }</div>;
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,15 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
@replaceableComponent("views.context_menus.GenericTextContextMenu")
|
||||
export default class GenericTextContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
render() {
|
||||
@replaceableComponent("views.context_menus.GenericTextContextMenu")
|
||||
export default class GenericTextContextMenu extends React.Component<IProps> {
|
||||
public render(): JSX.Element {
|
||||
return <div>{ this.props.message }</div>;
|
||||
}
|
||||
}
|
||||
@@ -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,15 +85,19 @@ 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>;
|
||||
};
|
||||
|
||||
@@ -104,7 +108,7 @@ export const IconizedContextMenuOptionList: React.FC<IOptionListProps> = ({ firs
|
||||
});
|
||||
|
||||
return <div className={classes}>
|
||||
{children}
|
||||
{ children }
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016, 2018, 2019, 2021 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,12 +16,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { EventStatus } from 'matrix-js-sdk/src/models/event';
|
||||
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 * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Modal from '../../../Modal';
|
||||
import Resend from '../../../Resend';
|
||||
@@ -29,53 +28,70 @@ import SettingsStore from '../../../settings/SettingsStore';
|
||||
import { isUrlPermitted } from '../../../HtmlUtils';
|
||||
import { isContentActionable } from '../../../utils/EventUtils';
|
||||
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu';
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
|
||||
import ForwardDialog from "../dialogs/ForwardDialog";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import ReportEventDialog from '../dialogs/ReportEventDialog';
|
||||
import ViewSource from '../../structures/ViewSource';
|
||||
import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
|
||||
import ShareDialog from '../dialogs/ShareDialog';
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { IPosition, ChevronFace } from '../../structures/ContextMenu';
|
||||
|
||||
export function canCancel(eventStatus) {
|
||||
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 extends IPosition {
|
||||
chevronFace: ChevronFace;
|
||||
/* 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 {
|
||||
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,
|
||||
};
|
||||
|
||||
export default class MessageContextMenu extends React.Component<IProps, IState> {
|
||||
state = {
|
||||
canRedact: false,
|
||||
canPin: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
|
||||
this._checkPermissions();
|
||||
MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions);
|
||||
this.checkPermissions();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
|
||||
cli.removeListener('RoomMember.powerLevel', this.checkPermissions);
|
||||
}
|
||||
}
|
||||
|
||||
_checkPermissions = () => {
|
||||
private checkPermissions = (): void => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
||||
@@ -93,7 +109,7 @@ export default class MessageContextMenu extends React.Component {
|
||||
this.setState({ canRedact, canPin });
|
||||
};
|
||||
|
||||
_isPinned() {
|
||||
private isPinned(): boolean {
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
|
||||
if (!pinnedEvent) return false;
|
||||
@@ -101,64 +117,37 @@ export default class MessageContextMenu extends React.Component {
|
||||
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
onResendReactionsClick = () => {
|
||||
for (const reaction of this._getUnsentReactions()) {
|
||||
private onResendReactionsClick = (): void => {
|
||||
for (const reaction of this.getUnsentReactions()) {
|
||||
Resend.resend(reaction);
|
||||
}
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onReportEventClick = () => {
|
||||
const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog");
|
||||
private onReportEventClick = (): void => {
|
||||
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
|
||||
mxEvent: this.props.mxEvent,
|
||||
}, 'mx_Dialog_reportEvent');
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onViewSourceClick = () => {
|
||||
const ViewSource = sdk.getComponent('structures.ViewSource');
|
||||
private onViewSourceClick = (): void => {
|
||||
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');
|
||||
private onRedactClick = (): void => {
|
||||
const { mxEvent, onCloseDialog } = this.props;
|
||||
createRedactEventDialog({
|
||||
mxEvent,
|
||||
onCloseDialog,
|
||||
});
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onForwardClick = () => {
|
||||
private onForwardClick = (): void => {
|
||||
Modal.createTrackedDialog('Forward Message', '', ForwardDialog, {
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
event: this.props.mxEvent,
|
||||
@@ -167,12 +156,12 @@ export default class MessageContextMenu extends React.Component {
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onPinClick = () => {
|
||||
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, "")?.pinned || [];
|
||||
const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];
|
||||
if (pinnedIds.includes(eventId)) {
|
||||
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
|
||||
} else {
|
||||
@@ -188,18 +177,16 @@ export default class MessageContextMenu extends React.Component {
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
closeMenu = () => {
|
||||
if (this.props.onFinished) this.props.onFinished();
|
||||
private closeMenu = (): void => {
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
onUnhidePreviewClick = () => {
|
||||
if (this.props.eventTileOps) {
|
||||
this.props.eventTileOps.unhideWidget();
|
||||
}
|
||||
private onUnhidePreviewClick = (): void => {
|
||||
this.props.eventTileOps?.unhideWidget();
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onQuoteClick = () => {
|
||||
private onQuoteClick = (): void => {
|
||||
dis.dispatch({
|
||||
action: Action.ComposerInsert,
|
||||
event: this.props.mxEvent,
|
||||
@@ -207,9 +194,8 @@ export default class MessageContextMenu extends React.Component {
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onPermalinkClick = (e) => {
|
||||
private onPermalinkClick = (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
|
||||
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
|
||||
target: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
@@ -217,30 +203,27 @@ export default class MessageContextMenu extends React.Component {
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
onCollapseReplyThreadClick = () => {
|
||||
private onCollapseReplyThreadClick = (): void => {
|
||||
this.props.collapseReplyThread();
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
_getReactions(filter) {
|
||||
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 &&
|
||||
relation.rel_type === "m.annotation" &&
|
||||
relation.event_id === eventId &&
|
||||
filter(e);
|
||||
return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e);
|
||||
});
|
||||
}
|
||||
|
||||
_getPendingReactions() {
|
||||
return this._getReactions(e => canCancel(e.status));
|
||||
private getPendingReactions(): MatrixEvent[] {
|
||||
return this.getReactions(e => canCancel(e.status));
|
||||
}
|
||||
|
||||
_getUnsentReactions() {
|
||||
return this._getReactions(e => e.status === EventStatus.NOT_SENT);
|
||||
private getUnsentReactions(): MatrixEvent[] {
|
||||
return this.getReactions(e => e.status === EventStatus.NOT_SENT);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -248,16 +231,17 @@ export default class MessageContextMenu extends React.Component {
|
||||
const me = cli.getUserId();
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const eventStatus = mxEvent.status;
|
||||
const unsentReactionsCount = this._getUnsentReactions().length;
|
||||
let resendReactionsButton;
|
||||
let redactButton;
|
||||
let forwardButton;
|
||||
let pinButton;
|
||||
let unhidePreviewButton;
|
||||
let externalURLButton;
|
||||
let quoteButton;
|
||||
let collapseReplyThread;
|
||||
let redactItemList;
|
||||
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;
|
||||
@@ -266,7 +250,7 @@ export default class MessageContextMenu extends React.Component {
|
||||
resendReactionsButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconResend"
|
||||
label={ _t('Resend %(unsentCount)s reaction(s)', { unsentCount: unsentReactionsCount }) }
|
||||
label={_t('Resend %(unsentCount)s reaction(s)', { unsentCount: unsentReactionsCount })}
|
||||
onClick={this.onResendReactionsClick}
|
||||
/>
|
||||
);
|
||||
@@ -296,7 +280,7 @@ export default class MessageContextMenu extends React.Component {
|
||||
pinButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconPin"
|
||||
label={ this._isPinned() ? _t('Unpin') : _t('Pin') }
|
||||
label={this.isPinned() ? _t('Unpin') : _t('Pin')}
|
||||
onClick={this.onPinClick}
|
||||
/>
|
||||
);
|
||||
@@ -327,16 +311,20 @@ export default class MessageContextMenu extends React.Component {
|
||||
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 = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconPermalink"
|
||||
onClick={this.onPermalinkClick}
|
||||
label= {_t('Share')}
|
||||
label={_t('Share')}
|
||||
element="a"
|
||||
href={permalink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
{
|
||||
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
|
||||
...{
|
||||
href: permalink,
|
||||
target: "_blank",
|
||||
rel: "noreferrer noopener",
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -351,18 +339,23 @@ export default class MessageContextMenu extends React.Component {
|
||||
}
|
||||
|
||||
// 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)
|
||||
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') }
|
||||
label={_t('Source URL')}
|
||||
element="a"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={mxEvent.event.content.external_url}
|
||||
{
|
||||
// XXX: Typescript signature for AccessibleButton doesn't work properly for non-inputs like `a`
|
||||
...{
|
||||
target: "_blank",
|
||||
rel: "noreferrer noopener",
|
||||
href: mxEvent.getContent().external_url,
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -377,7 +370,7 @@ export default class MessageContextMenu extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
let reportEventButton;
|
||||
let reportEventButton: JSX.Element;
|
||||
if (mxEvent.getSender() !== me) {
|
||||
reportEventButton = (
|
||||
<IconizedContextMenuOption
|
||||
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal 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;
|
||||
|
||||
@@ -14,53 +14,59 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import * as sdk from '../../../index';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { User } from "matrix-js-sdk/src/models/user";
|
||||
import Spinner from "../elements/Spinner";
|
||||
|
||||
interface IProps {
|
||||
// js-sdk User object. Not required because it might not exist.
|
||||
user?: User;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
message: string;
|
||||
waiting: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.context_menus.StatusMessageContextMenu")
|
||||
export default class StatusMessageContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
// js-sdk User object. Not required because it might not exist.
|
||||
user: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
message: this.comittedStatusMessage,
|
||||
waiting: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
|
||||
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount(): void {
|
||||
const { user } = this.props;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
user.removeListener(
|
||||
"User._unstable_statusMessage",
|
||||
this._onStatusMessageCommitted,
|
||||
this.onStatusMessageCommitted,
|
||||
);
|
||||
}
|
||||
|
||||
get comittedStatusMessage() {
|
||||
return this.props.user ? this.props.user._unstable_statusMessage : "";
|
||||
get comittedStatusMessage(): string {
|
||||
return this.props.user ? this.props.user.unstable_statusMessage : "";
|
||||
}
|
||||
|
||||
_onStatusMessageCommitted = () => {
|
||||
private onStatusMessageCommitted = (): void => {
|
||||
// The `User` object has observed a status message change.
|
||||
this.setState({
|
||||
message: this.comittedStatusMessage,
|
||||
@@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_onClearClick = (e) => {
|
||||
private onClearClick = (): void=> {
|
||||
MatrixClientPeg.get()._unstable_setStatusMessage("");
|
||||
this.setState({
|
||||
waiting: true,
|
||||
});
|
||||
};
|
||||
|
||||
_onSubmit = (e) => {
|
||||
private onSubmit = (e: ButtonEvent): void => {
|
||||
e.preventDefault();
|
||||
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
|
||||
this.setState({
|
||||
@@ -83,55 +89,62 @@ export default class StatusMessageContextMenu extends React.Component {
|
||||
});
|
||||
};
|
||||
|
||||
_onStatusChange = (e) => {
|
||||
private onStatusChange = (e: ChangeEvent): void => {
|
||||
// The input field's value was changed.
|
||||
this.setState({
|
||||
message: e.target.value,
|
||||
message: (e.target as HTMLInputElement).value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const Spinner = sdk.getComponent('views.elements.Spinner');
|
||||
|
||||
public render(): JSX.Element {
|
||||
let actionButton;
|
||||
if (this.comittedStatusMessage) {
|
||||
if (this.state.message === this.comittedStatusMessage) {
|
||||
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
|
||||
onClick={this._onClearClick}
|
||||
onClick={this.onClearClick}
|
||||
>
|
||||
<span>{_t("Clear status")}</span>
|
||||
<span>{ _t("Clear status") }</span>
|
||||
</AccessibleButton>;
|
||||
} else {
|
||||
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
|
||||
onClick={this._onSubmit}
|
||||
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>;
|
||||
}
|
||||
|
||||
let spinner = null;
|
||||
if (this.state.waiting) {
|
||||
spinner = <Spinner w="24" h="24" />;
|
||||
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}
|
||||
onChange={this._onStatusChange}
|
||||
<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>;
|
||||
|
||||
@@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
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 {
|
||||
@@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
|
||||
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();
|
||||
@@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
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') }
|
||||
@@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component {
|
||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
|
||||
{ _t("Unpin") }
|
||||
</MenuItem>
|
||||
{ createSpaceOption }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,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")}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
||||
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
67
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal 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;
|
||||
|
||||
@@ -17,10 +17,10 @@ limitations under the License.
|
||||
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 { 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 BaseDialog from "./BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
@@ -29,27 +29,26 @@ import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { getDisplayAliasForRoom } from "../../../Rooms";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import { sleep } from "../../../utils/promise";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { calculateRoomVia } from "../../../utils/permalinks/Permalinks";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
import ProgressBar from "../elements/ProgressBar";
|
||||
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import QueryMatcher from "../../../autocomplete/QueryMatcher";
|
||||
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 }) => {
|
||||
export const Entry = ({ room, checked, onChange }) => {
|
||||
return <label className="mx_AddExistingToSpace_entry">
|
||||
{ room?.isSpaceRoom()
|
||||
? <RoomAvatar room={room} height={32} width={32} />
|
||||
@@ -67,14 +66,36 @@ const Entry = ({ room, checked, onChange }) => {
|
||||
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);
|
||||
@@ -198,7 +219,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
</>;
|
||||
}
|
||||
|
||||
const onChange = !busy && !error ? (checked, room) => {
|
||||
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
@@ -208,83 +229,51 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
} : null;
|
||||
|
||||
const [truncateAt, setTruncateAt] = useState(20);
|
||||
function overflowTile(overflowCount, totalCount) {
|
||||
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)} />
|
||||
<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_AddExistingToSpace_content">
|
||||
{ rooms.length > 0 ? (
|
||||
<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) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
{ rooms.length > 0 && roomsRenderer ? (
|
||||
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<div className="mx_AddExistingToSpace_section_experimental">
|
||||
<div>{ _t("Feeling experimental?") }</div>
|
||||
<div>{ _t("You can add existing spaces to a space.") }</div>
|
||||
</div>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ spaces.length > 0 && spacesRenderer ? (
|
||||
spacesRenderer(spaces, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ dms.length > 0 && dmsRenderer ? (
|
||||
dmsRenderer(dms, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
@@ -295,69 +284,166 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
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>
|
||||
);
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspaces.length > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</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>
|
||||
);
|
||||
|
||||
spaceOptionSection = (
|
||||
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) => {
|
||||
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
|
||||
onChange(options.find(space => space.roomId === key) || space);
|
||||
}}
|
||||
value={selectedSpace.roomId}
|
||||
value={value.roomId}
|
||||
label={_t("Space selection")}
|
||||
>
|
||||
{ options }
|
||||
{ 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 {
|
||||
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
body = (
|
||||
<div className="mx_SubspaceSelector_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={selectedSpace} height={40} width={40} />
|
||||
return <div className="mx_SubspaceSelector">
|
||||
<RoomAvatar room={value} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Add existing rooms") }</h1>
|
||||
{ spaceOptionSection }
|
||||
<h1>{ title }</h1>
|
||||
{ body }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
|
||||
return <BaseDialog
|
||||
title={title}
|
||||
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={cli}>
|
||||
<MatrixClientContext.Provider value={space.client}>
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new room instead?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
|
||||
<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>
|
||||
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,22 +18,24 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { addressTypes, getAddressType } from '../../../UserAddress';
|
||||
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 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) {
|
||||
@@ -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,7 +589,7 @@ 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 }) => {
|
||||
@@ -584,15 +603,15 @@ export default class AddressPickerDialog extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
_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 }
|
||||
@@ -15,11 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
|
||||
interface IProps {
|
||||
unknownProfileUsers: Array<{
|
||||
@@ -50,10 +50,8 @@ export default class AskInviteAnywayDialog extends React.Component<IProps> {
|
||||
};
|
||||
|
||||
public render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
||||
const errorList = this.props.unknownProfileUsers
|
||||
.map(address => <li key={address.userId}>{address.userId}: {address.errorText}</li>);
|
||||
.map(address => <li key={address.userId}>{ address.userId }: { address.errorText }</li>);
|
||||
|
||||
return (
|
||||
<BaseDialog className='mx_RetryInvitesDialog'
|
||||
@@ -62,8 +60,8 @@ export default class AskInviteAnywayDialog extends React.Component<IProps> {
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div id='mx_Dialog_content'>
|
||||
{/* eslint-disable-next-line */}
|
||||
<p>{_t("Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?")}</p>
|
||||
<p>{ _t("Unable to find profiles for the Matrix IDs listed below - " +
|
||||
"would you like to invite them anyway?") }</p>
|
||||
<ul>
|
||||
{ errorList }
|
||||
</ul>
|
||||
|
||||
@@ -18,15 +18,54 @@ limitations under the License.
|
||||
|
||||
import React from 'react';
|
||||
import FocusLock from 'react-focus-lock';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Key } from '../../../Keyboard';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
// Whether the dialog should have a 'close' button that will
|
||||
// cause the dialog to be cancelled. This should only be set
|
||||
// to false if there is nothing the app can sensibly do if the
|
||||
// dialog is cancelled, eg. "We can't restore your session and
|
||||
// the app cannot work". Default: true.
|
||||
hasCancel?: boolean;
|
||||
|
||||
// called when a key is pressed
|
||||
onKeyDown?: (e: KeyboardEvent | React.KeyboardEvent) => void;
|
||||
|
||||
// CSS class to apply to dialog div
|
||||
className?: string;
|
||||
|
||||
// if true, dialog container is 60% of the viewport width. Otherwise,
|
||||
// the container will have no fixed size, allowing its contents to
|
||||
// determine its size. Default: true.
|
||||
fixedWidth?: boolean;
|
||||
|
||||
// Title for the dialog.
|
||||
title?: JSX.Element | string;
|
||||
|
||||
// Path to an icon to put in the header
|
||||
headerImage?: string;
|
||||
|
||||
// children should be the content of the dialog
|
||||
children?: React.ReactNode;
|
||||
|
||||
// Id of content element
|
||||
// If provided, this is used to add a aria-describedby attribute
|
||||
contentId?: string;
|
||||
|
||||
// optional additional class for the title element (basically anything that can be passed to classnames)
|
||||
titleClass?: string | string[];
|
||||
|
||||
headerButton?: JSX.Element;
|
||||
}
|
||||
|
||||
/*
|
||||
* Basic container for modal dialogs.
|
||||
@@ -35,54 +74,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
* dialog on escape.
|
||||
*/
|
||||
@replaceableComponent("views.dialogs.BaseDialog")
|
||||
export default class BaseDialog extends React.Component {
|
||||
static propTypes = {
|
||||
// onFinished callback to call when Escape is pressed
|
||||
// Take a boolean which is true if the dialog was dismissed
|
||||
// with a positive / confirm action or false if it was
|
||||
// cancelled (BaseDialog itself only calls this with false).
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
export default class BaseDialog extends React.Component<IProps> {
|
||||
private matrixClient: MatrixClient;
|
||||
|
||||
// Whether the dialog should have a 'close' button that will
|
||||
// cause the dialog to be cancelled. This should only be set
|
||||
// to false if there is nothing the app can sensibly do if the
|
||||
// dialog is cancelled, eg. "We can't restore your session and
|
||||
// the app cannot work". Default: true.
|
||||
hasCancel: PropTypes.bool,
|
||||
|
||||
// called when a key is pressed
|
||||
onKeyDown: PropTypes.func,
|
||||
|
||||
// CSS class to apply to dialog div
|
||||
className: PropTypes.string,
|
||||
|
||||
// if true, dialog container is 60% of the viewport width. Otherwise,
|
||||
// the container will have no fixed size, allowing its contents to
|
||||
// determine its size. Default: true.
|
||||
fixedWidth: PropTypes.bool,
|
||||
|
||||
// Title for the dialog.
|
||||
title: PropTypes.node.isRequired,
|
||||
|
||||
// Path to an icon to put in the header
|
||||
headerImage: PropTypes.string,
|
||||
|
||||
// children should be the content of the dialog
|
||||
children: PropTypes.node,
|
||||
|
||||
// Id of content element
|
||||
// If provided, this is used to add a aria-describedby attribute
|
||||
contentId: PropTypes.string,
|
||||
|
||||
// optional additional class for the title element (basically anything that can be passed to classnames)
|
||||
titleClass: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.object,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
]),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
public static defaultProps = {
|
||||
hasCancel: true,
|
||||
fixedWidth: true,
|
||||
};
|
||||
@@ -90,10 +85,10 @@ export default class BaseDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._matrixClient = MatrixClientPeg.get();
|
||||
this.matrixClient = MatrixClientPeg.get();
|
||||
}
|
||||
|
||||
_onKeyDown = (e) => {
|
||||
private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => {
|
||||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
@@ -104,31 +99,29 @@ export default class BaseDialog extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
_onCancelClick = (e) => {
|
||||
private onCancelClick = (e: ButtonEvent): void => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): JSX.Element {
|
||||
let cancelButton;
|
||||
if (this.props.hasCancel) {
|
||||
cancelButton = (
|
||||
<AccessibleButton onClick={this._onCancelClick} className="mx_Dialog_cancelButton" aria-label={_t("Close dialog")} />
|
||||
<AccessibleButton onClick={this.onCancelClick} className="mx_Dialog_cancelButton" aria-label={_t("Close dialog")} />
|
||||
);
|
||||
}
|
||||
|
||||
let headerImage;
|
||||
if (this.props.headerImage) {
|
||||
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage}
|
||||
alt=""
|
||||
/>;
|
||||
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} alt="" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<MatrixClientContext.Provider value={this.matrixClient}>
|
||||
<FocusLock
|
||||
returnFocus={true}
|
||||
lockProps={{
|
||||
onKeyDown: this._onKeyDown,
|
||||
onKeyDown: this.onKeyDown,
|
||||
role: "dialog",
|
||||
["aria-labelledby"]: "mx_BaseDialog_title",
|
||||
// This should point to a node describing the dialog.
|
||||
@@ -149,7 +142,7 @@ export default class BaseDialog extends React.Component {
|
||||
'mx_Dialog_headerWithCancel': !!cancelButton,
|
||||
})}>
|
||||
<div className={classNames('mx_Dialog_title', this.props.titleClass)} id='mx_BaseDialog_title'>
|
||||
{headerImage}
|
||||
{ headerImage }
|
||||
{ this.props.title }
|
||||
</div>
|
||||
{ this.props.headerButton }
|
||||
@@ -14,22 +14,18 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import QuestionDialog from './QuestionDialog';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { submitFeedback } from "../../../rageshake/submit-rageshake";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { UserTab } from "./UserSettingsDialog";
|
||||
import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog";
|
||||
|
||||
// XXX: Keep this around for re-use in future Betas
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
featureId: string;
|
||||
@@ -38,74 +34,28 @@ interface IProps extends IDialogProps {
|
||||
const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
|
||||
const info = SettingsStore.getBetaInfo(featureId);
|
||||
|
||||
const [comment, setComment] = useState("");
|
||||
const [canContact, setCanContact] = useState(false);
|
||||
|
||||
const sendFeedback = async (ok: boolean) => {
|
||||
if (!ok) return onFinished(false);
|
||||
|
||||
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
|
||||
o[k] = SettingsStore.getValue(k);
|
||||
return o;
|
||||
}, {});
|
||||
|
||||
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
|
||||
onFinished(true);
|
||||
|
||||
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
|
||||
title: _t("Beta feedback"),
|
||||
description: _t("Thank you for your feedback, we really appreciate it."),
|
||||
button: _t("Done"),
|
||||
hasCloseButton: false,
|
||||
fixedWidth: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (<QuestionDialog
|
||||
className="mx_BetaFeedbackDialog"
|
||||
hasCancelButton={true}
|
||||
return <GenericFeatureFeedbackDialog
|
||||
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
|
||||
description={<React.Fragment>
|
||||
<div className="mx_BetaFeedbackDialog_subheading">
|
||||
{ _t(info.feedbackSubheading) }
|
||||
|
||||
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.")}
|
||||
|
||||
<AccessibleButton kind="link" onClick={() => {
|
||||
onFinished(false);
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
}}>
|
||||
{ _t("To leave the beta, visit your settings.") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="feedbackComment"
|
||||
label={_t("Feedback")}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={comment}
|
||||
element="textarea"
|
||||
onChange={(ev) => {
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
autoFocus={true}
|
||||
/>
|
||||
|
||||
<StyledCheckbox
|
||||
checked={canContact}
|
||||
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
|
||||
>
|
||||
{ _t("You may contact me if you have any follow up questions") }
|
||||
</StyledCheckbox>
|
||||
</React.Fragment>}
|
||||
button={_t("Send feedback")}
|
||||
buttonDisabled={!comment}
|
||||
onFinished={sendFeedback}
|
||||
/>);
|
||||
subheading={_t(info.feedbackSubheading)}
|
||||
onFinished={onFinished}
|
||||
rageshakeLabel={info.feedbackLabel}
|
||||
rageshakeData={Object.fromEntries((SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map(k => {
|
||||
return SettingsStore.getValue(k);
|
||||
}))}
|
||||
>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onFinished(false);
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{ _t("To leave the beta, visit your settings.") }
|
||||
</AccessibleButton>
|
||||
</GenericFeatureFeedbackDialog>;
|
||||
};
|
||||
|
||||
export default BetaFeedbackDialog;
|
||||
|
||||
@@ -18,18 +18,24 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import sendBugReport, { downloadBugReport } from '../../../rageshake/submit-rageshake';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import Field from '../elements/Field';
|
||||
import Spinner from "../elements/Spinner";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import { sendSentryReport } from "../../../sentry";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (success: boolean) => void;
|
||||
initialText?: string;
|
||||
label?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -93,7 +99,6 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
}).then(() => {
|
||||
if (!this.unmounted) {
|
||||
this.props.onFinished(false);
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
// N.B. first param is passed to piwik and so doesn't want i18n
|
||||
Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, {
|
||||
title: _t('Logs sent'),
|
||||
@@ -110,6 +115,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sendSentryReport(this.state.text, this.state.issueUrl, this.props.error);
|
||||
};
|
||||
|
||||
private onDownload = async (): Promise<void> => {
|
||||
@@ -160,15 +167,10 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
public render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
let error = null;
|
||||
if (this.state.err) {
|
||||
error = <div className="error">
|
||||
{this.state.err}
|
||||
{ this.state.err }
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -176,8 +178,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
if (this.state.busy) {
|
||||
progress = (
|
||||
<div className="progress">
|
||||
<Loader />
|
||||
{this.state.progress} ...
|
||||
<Spinner />
|
||||
{ this.state.progress } ...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,7 +192,9 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_BugReportDialog" onFinished={this.onCancel}
|
||||
<BaseDialog
|
||||
className="mx_BugReportDialog"
|
||||
onFinished={this.onCancel}
|
||||
title={_t('Submit debug logs')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
@@ -200,8 +204,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
{ _t(
|
||||
"Debug logs contain application usage data including your " +
|
||||
"username, the IDs or aliases of the rooms or groups you " +
|
||||
"have visited and the usernames of other users. They do " +
|
||||
"not contain messages.",
|
||||
"have visited, which UI elements you last interacted with, " +
|
||||
"and the usernames of other users. They do not contain messages.",
|
||||
) }
|
||||
</p>
|
||||
<p><b>
|
||||
@@ -211,7 +215,7 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
{
|
||||
a: (sub) => <a
|
||||
target="_blank"
|
||||
href="https://github.com/vector-im/element-web/issues/new"
|
||||
href="https://github.com/vector-im/element-web/issues/new/choose"
|
||||
>
|
||||
{ sub }
|
||||
</a>,
|
||||
@@ -223,7 +227,7 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
<AccessibleButton onClick={this.onDownload} kind="link" disabled={this.state.downloadBusy}>
|
||||
{ _t("Download logs") }
|
||||
</AccessibleButton>
|
||||
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
|
||||
{ this.state.downloadProgress && <span>{ this.state.downloadProgress } ...</span> }
|
||||
</div>
|
||||
|
||||
<Field
|
||||
@@ -248,8 +252,8 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
|
||||
"please include those things here.",
|
||||
)}
|
||||
/>
|
||||
{progress}
|
||||
{error}
|
||||
{ progress }
|
||||
{ error }
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t("Send logs")}
|
||||
onPrimaryButtonClick={this.onSubmit}
|
||||
|
||||
@@ -16,9 +16,10 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as sdk from '../../../index';
|
||||
import request from 'browser-request';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
import Spinner from "../elements/Spinner";
|
||||
|
||||
interface IProps {
|
||||
newVersion: string;
|
||||
@@ -58,16 +59,13 @@ export default class ChangelogDialog extends React.Component<IProps> {
|
||||
return (
|
||||
<li key={commit.sha} className="mx_ChangelogDialog_li">
|
||||
<a href={commit.html_url} target="_blank" rel="noreferrer noopener">
|
||||
{commit.commit.message.split('\n')[0]}
|
||||
{ commit.commit.message.split('\n')[0] }
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const Spinner = sdk.getComponent('views.elements.Spinner');
|
||||
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
|
||||
|
||||
const logs = REPOS.map(repo => {
|
||||
let content;
|
||||
if (this.state[repo] == null) {
|
||||
@@ -81,15 +79,15 @@ export default class ChangelogDialog extends React.Component<IProps> {
|
||||
}
|
||||
return (
|
||||
<div key={repo}>
|
||||
<h2>{repo}</h2>
|
||||
<ul>{content}</ul>
|
||||
<h2>{ repo }</h2>
|
||||
<ul>{ content }</ul>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const content = (
|
||||
<div className="mx_ChangelogDialog_content">
|
||||
{this.props.version == null || this.props.newVersion == null ? <h2>{_t("Unavailable")}</h2> : logs}
|
||||
{ this.props.version == null || this.props.newVersion == null ? <h2>{ _t("Unavailable") }</h2> : logs }
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -156,8 +156,8 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
|
||||
height={avatarSize}
|
||||
/>
|
||||
<div className="mx_CommunityPrototypeInviteDialog_personIdentifiers">
|
||||
<span className="mx_CommunityPrototypeInviteDialog_personName">{person.user.name}</span>
|
||||
<span className="mx_CommunityPrototypeInviteDialog_personId">{person.userId}</span>
|
||||
<span className="mx_CommunityPrototypeInviteDialog_personName">{ person.user.name }</span>
|
||||
<span className="mx_CommunityPrototypeInviteDialog_personId">{ person.userId }</span>
|
||||
</div>
|
||||
<StyledCheckbox onChange={(e) => this.setPersonToggle(person, e.target.checked)} />
|
||||
</div>
|
||||
@@ -187,7 +187,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
|
||||
emailAddresses.push((
|
||||
<Field
|
||||
key={emailAddresses.length}
|
||||
value={""}
|
||||
value=""
|
||||
onChange={(e) => this.onAddressChange(e, emailAddresses.length)}
|
||||
label={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")}
|
||||
placeholder={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")}
|
||||
@@ -205,18 +205,21 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
|
||||
people.push((
|
||||
<AccessibleButton
|
||||
onClick={this.onShowMorePeople}
|
||||
kind="link" key="more"
|
||||
kind="link"
|
||||
key="more"
|
||||
className="mx_CommunityPrototypeInviteDialog_morePeople"
|
||||
>{_t("Show more")}</AccessibleButton>
|
||||
>
|
||||
{ _t("Show more") }
|
||||
</AccessibleButton>
|
||||
));
|
||||
}
|
||||
}
|
||||
if (this.state.people.length > 0) {
|
||||
peopleIntro = (
|
||||
<div className="mx_CommunityPrototypeInviteDialog_people">
|
||||
<span>{_t("People you know on %(brand)s", { brand: SdkConfig.get().brand })}</span>
|
||||
<span>{ _t("People you know on %(brand)s", { brand: SdkConfig.get().brand }) }</span>
|
||||
<AccessibleButton onClick={this.onShowPeopleClick}>
|
||||
{this.state.showPeople ? _t("Hide") : _t("Show")}
|
||||
{ this.state.showPeople ? _t("Hide") : _t("Show") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
@@ -236,14 +239,17 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
|
||||
>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<div className="mx_Dialog_content">
|
||||
{emailAddresses}
|
||||
{peopleIntro}
|
||||
{people}
|
||||
{ emailAddresses }
|
||||
{ peopleIntro }
|
||||
{ people }
|
||||
<AccessibleButton
|
||||
kind="primary" onClick={this.onSubmit}
|
||||
kind="primary"
|
||||
onClick={this.onSubmit}
|
||||
disabled={this.state.busy}
|
||||
className="mx_CommunityPrototypeInviteDialog_primaryButton"
|
||||
>{buttonText}</AccessibleButton>
|
||||
>
|
||||
{ buttonText }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user