Merge remote-tracking branch 'origin/develop' into last-admin-leave-room-warning

This commit is contained in:
David Baker 2024-03-21 11:39:47 +00:00
commit 0fdb300858
2886 changed files with 393845 additions and 234022 deletions

View file

@ -16,30 +16,31 @@ limitations under the License.
*/
import classNames from "classnames";
import React, { HTMLAttributes, ReactHTML, WheelEvent } from "react";
import React, { HTMLAttributes, ReactHTML, ReactNode, WheelEvent } from "react";
type DynamicHtmlElementProps<T extends keyof JSX.IntrinsicElements> =
JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps<T> : DynamicElementProps<"div">;
type DynamicElementProps<T extends keyof JSX.IntrinsicElements> = Partial<Omit<JSX.IntrinsicElements[T], 'ref'>>;
type DynamicElementProps<T extends keyof JSX.IntrinsicElements> = Partial<Omit<JSX.IntrinsicElements[T], "ref">>;
export type IProps<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T> & {
element?: T;
export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<DynamicHtmlElementProps<T>, "onScroll"> & {
element: T;
className?: string;
onScroll?: (event: Event) => void;
onWheel?: (event: WheelEvent) => void;
style?: React.CSSProperties;
tabIndex?: number;
wrappedRef?: (ref: HTMLDivElement) => void;
wrappedRef?: (ref: HTMLDivElement | null) => void;
children: ReactNode;
};
export default class AutoHideScrollbar<T extends keyof JSX.IntrinsicElements> extends React.Component<IProps<T>> {
static defaultProps = {
element: 'div' as keyof ReactHTML,
public static defaultProps = {
element: "div" as keyof ReactHTML,
};
public readonly containerRef: React.RefObject<HTMLDivElement> = React.createRef();
public componentDidMount() {
public componentDidMount(): void {
if (this.containerRef.current && this.props.onScroll) {
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
@ -49,23 +50,29 @@ export default class AutoHideScrollbar<T extends keyof JSX.IntrinsicElements> ex
this.props.wrappedRef?.(this.containerRef.current);
}
public componentWillUnmount() {
public componentWillUnmount(): void {
if (this.containerRef.current && this.props.onScroll) {
this.containerRef.current.removeEventListener("scroll", this.props.onScroll);
}
this.props.wrappedRef?.(null);
}
public render() {
public render(): React.ReactNode {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { element, className, onScroll, tabIndex, wrappedRef, children, ...otherProps } = this.props;
return React.createElement(element, {
...otherProps,
ref: this.containerRef,
className: classNames("mx_AutoHideScrollbar", className),
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex: tabIndex ?? -1,
}, children);
return React.createElement(
element,
{
...otherProps,
ref: this.containerRef,
className: classNames("mx_AutoHideScrollbar", className),
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex: tabIndex ?? -1,
},
children,
);
}
}

View file

@ -0,0 +1,238 @@
/*
Copyright 2022 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, ReactNode, ChangeEvent, KeyboardEvent, useRef, ReactElement } from "react";
import classNames from "classnames";
import Autocompleter from "../../autocomplete/AutocompleteProvider";
import { Key } from "../../Keyboard";
import { ICompletion } from "../../autocomplete/Autocompleter";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { Icon as PillRemoveIcon } from "../../../res/img/icon-pill-remove.svg";
import { Icon as SearchIcon } from "../../../res/img/element-icons/roomlist/search.svg";
import useFocus from "../../hooks/useFocus";
interface AutocompleteInputProps {
provider: Autocompleter;
placeholder: string;
selection: ICompletion[];
onSelectionChange: (selection: ICompletion[]) => void;
maxSuggestions?: number;
renderSuggestion?: (s: ICompletion) => ReactElement;
renderSelection?: (m: ICompletion) => ReactElement;
additionalFilter?: (suggestion: ICompletion) => boolean;
}
export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
provider,
renderSuggestion,
renderSelection,
maxSuggestions = 5,
placeholder,
onSelectionChange,
selection,
additionalFilter,
}) => {
const [query, setQuery] = useState<string>("");
const [suggestions, setSuggestions] = useState<ICompletion[]>([]);
const [isFocused, onFocusChangeHandlerFunctions] = useFocus();
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<HTMLInputElement>(null);
const focusEditor = (): void => {
editorRef?.current?.focus();
};
const onQueryChange = async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
const value = e.target.value.trim();
setQuery(value);
let matches = await provider.getCompletions(
query,
{ start: query.length, end: query.length },
true,
maxSuggestions,
);
if (additionalFilter) {
matches = matches.filter(additionalFilter);
}
setSuggestions(matches);
};
const onClickInputArea = (): void => {
focusEditor();
};
const onKeyDown = (e: KeyboardEvent): void => {
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
// when the field is empty and the user hits backspace remove the right-most target
if (!query && selection.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
removeSelection(selection[selection.length - 1]);
}
};
const toggleSelection = (completion: ICompletion): void => {
const newSelection = [...selection];
const index = selection.findIndex((selection) => selection.completionId === completion.completionId);
if (index >= 0) {
newSelection.splice(index, 1);
} else {
newSelection.push(completion);
}
onSelectionChange(newSelection);
focusEditor();
};
const removeSelection = (completion: ICompletion): void => {
const newSelection = [...selection];
const index = selection.findIndex((selection) => selection.completionId === completion.completionId);
if (index >= 0) {
newSelection.splice(index, 1);
onSelectionChange(newSelection);
}
};
const hasPlaceholder = (): boolean => selection.length === 0 && query.length === 0;
return (
<div className="mx_AutocompleteInput">
<div
ref={editorContainerRef}
className={classNames({
"mx_AutocompleteInput_editor": true,
"mx_AutocompleteInput_editor--focused": isFocused,
"mx_AutocompleteInput_editor--has-suggestions": suggestions.length > 0,
})}
onClick={onClickInputArea}
data-testid="autocomplete-editor"
>
<SearchIcon className="mx_AutocompleteInput_search_icon" width={16} height={16} />
{selection.map((item) => (
<SelectionItem
key={item.completionId}
item={item}
onClick={removeSelection}
render={renderSelection}
/>
))}
<input
ref={editorRef}
type="text"
onKeyDown={onKeyDown}
onChange={onQueryChange}
value={query}
autoComplete="off"
placeholder={hasPlaceholder() ? placeholder : undefined}
data-testid="autocomplete-input"
{...onFocusChangeHandlerFunctions}
/>
</div>
{isFocused && suggestions.length ? (
<div
className="mx_AutocompleteInput_matches"
style={{ top: editorContainerRef.current?.clientHeight }}
data-testid="autocomplete-matches"
>
{suggestions.map((item) => (
<SuggestionItem
key={item.completionId}
item={item}
selection={selection}
onClick={toggleSelection}
render={renderSuggestion}
/>
))}
</div>
) : null}
</div>
);
};
type SelectionItemProps = {
item: ICompletion;
onClick: (completion: ICompletion) => void;
render?: (completion: ICompletion) => ReactElement;
};
const SelectionItem: React.FC<SelectionItemProps> = ({ item, onClick, render }) => {
const withContainer = (children: ReactNode): ReactElement => (
<span
className="mx_AutocompleteInput_editor_selection"
data-testid={`autocomplete-selection-item-${item.completionId}`}
>
<span className="mx_AutocompleteInput_editor_selection_pill">{children}</span>
<AccessibleButton
className="mx_AutocompleteInput_editor_selection_remove_button"
onClick={() => onClick(item)}
data-testid={`autocomplete-selection-remove-button-${item.completionId}`}
>
<PillRemoveIcon width={8} height={8} />
</AccessibleButton>
</span>
);
if (render) {
return withContainer(render(item));
}
return withContainer(<span className="mx_AutocompleteInput_editor_selection_text">{item.completion}</span>);
};
type SuggestionItemProps = {
item: ICompletion;
selection: ICompletion[];
onClick: (completion: ICompletion) => void;
render?: (completion: ICompletion) => ReactElement;
};
const SuggestionItem: React.FC<SuggestionItemProps> = ({ item, selection, onClick, render }) => {
const isSelected = selection.some((selection) => selection.completionId === item.completionId);
const classes = classNames({
"mx_AutocompleteInput_suggestion": true,
"mx_AutocompleteInput_suggestion--selected": isSelected,
});
const withContainer = (children: ReactNode): ReactElement => (
<div
className={classes}
// `onClick` cannot be used here as it would lead to focus loss and closing the suggestion list.
onMouseDown={(event) => {
event.preventDefault();
onClick(item);
}}
data-testid={`autocomplete-suggestion-item-${item.completionId}`}
>
{children}
</div>
);
if (render) {
return withContainer(render(item));
}
return withContainer(
<>
<span className="mx_AutocompleteInput_suggestion_title">{item.completion}</span>
<span className="mx_AutocompleteInput_suggestion_description">{item.completionId}</span>
</>,
);
};

View file

@ -27,20 +27,17 @@ export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplie
const styles: CSSProperties = {};
if (blurMultiplier) {
const rootStyle = getComputedStyle(document.documentElement);
const blurValue = rootStyle.getPropertyValue('--lp-background-blur');
const pixelsValue = blurValue.replace('px', '');
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
role="presentation"
alt=""
style={styles}
className="mx_BackdropPanel--image"
src={backgroundImage} />
</div>;
return (
<div className="mx_BackdropPanel">
<img role="presentation" alt="" style={styles} className="mx_BackdropPanel--image" src={backgroundImage} />
</div>
);
};
export default BackdropPanel;

View file

@ -20,12 +20,14 @@ import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } fro
import ReactDOM from "react-dom";
import classNames from "classnames";
import FocusLock from "react-focus-lock";
import { TooltipProvider } from "@vector-im/compound-web";
import { Writeable } from "../../@types/common";
import UIStore from "../../stores/UIStore";
import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import Modal, { ModalManagerEvent } from "../../Modal";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -63,7 +65,7 @@ export enum ChevronFace {
None = "none",
}
export interface IProps extends IPosition {
export interface MenuProps extends IPosition {
menuWidth?: number;
menuHeight?: number;
@ -76,7 +78,9 @@ export interface IProps extends IPosition {
menuPaddingRight?: number;
zIndex?: number;
}
export interface IProps extends MenuProps {
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
hasBackground?: boolean;
// whether this context menu should be focus managed. If false it must handle itself
@ -96,48 +100,56 @@ export interface IProps extends IPosition {
closeOnInteraction?: boolean;
// Function to be called on menu close
onFinished();
onFinished(): void;
// on resize callback
windowResize?();
windowResize?(): void;
}
interface IState {
contextMenuElem: HTMLDivElement;
contextMenuElem?: HTMLDivElement;
}
// Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
export default class ContextMenu extends React.PureComponent<IProps, IState> {
export default class ContextMenu extends React.PureComponent<React.PropsWithChildren<IProps>, IState> {
private readonly initialFocus: HTMLElement;
static defaultProps = {
public static defaultProps = {
hasBackground: true,
managed: true,
};
constructor(props, context) {
super(props, context);
public constructor(props: IProps) {
super(props);
this.state = {
contextMenuElem: null,
};
this.state = {};
// persist what had focus when we got initialized so we can return it after
this.initialFocus = document.activeElement as HTMLElement;
}
componentWillUnmount() {
public componentDidMount(): void {
Modal.on(ModalManagerEvent.Opened, this.onModalOpen);
}
public componentWillUnmount(): void {
Modal.off(ModalManagerEvent.Opened, this.onModalOpen);
// return focus to the thing which had it before us
this.initialFocus.focus();
}
private collectContextMenuRect = (element: HTMLDivElement) => {
private onModalOpen = (): void => {
this.props.onFinished?.();
};
private collectContextMenuRect = (element: HTMLDivElement): void => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
const first = element.querySelector<HTMLElement>('[role^="menuitem"]')
|| element.querySelector<HTMLElement>('[tab-index]');
const first =
element.querySelector<HTMLElement>('[role^="menuitem"]') ||
element.querySelector<HTMLElement>("[tabindex]");
if (first) {
first.focus();
@ -148,7 +160,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
});
};
private onContextMenu = (e) => {
private onContextMenu = (e: React.MouseEvent): void => {
if (this.props.onFinished) {
this.props.onFinished();
@ -168,25 +180,25 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
button: 0, // Left
relatedTarget: null,
});
document.elementFromPoint(x, y).dispatchEvent(clickEvent);
document.elementFromPoint(x, y)?.dispatchEvent(clickEvent);
});
}
};
private onContextMenuPreventBubbling = (e) => {
private onContextMenuPreventBubbling = (e: React.MouseEvent): void => {
// stop propagation so that any context menu handlers don't leak out of this context menu
// but do not inhibit the default browser menu
e.stopPropagation();
};
// Prevent clicks on the background from going through to the component which opened the menu.
private onFinished = (ev: React.MouseEvent) => {
private onFinished = (ev: React.MouseEvent): void => {
ev.stopPropagation();
ev.preventDefault();
if (this.props.onFinished) this.props.onFinished();
this.props.onFinished?.();
};
private onClick = (ev: React.MouseEvent) => {
private onClick = (ev: React.MouseEvent): void => {
// Don't allow clicks to escape the context menu wrapper
ev.stopPropagation();
@ -197,7 +209,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
// We now only handle closing the ContextMenu in this keyDown handler.
// All of the item/option navigation is delegated to RovingTabIndex.
private onKeyDown = (ev: React.KeyboardEvent) => {
private onKeyDown = (ev: React.KeyboardEvent): void => {
ev.stopPropagation(); // prevent keyboard propagating out of the context menu, we're focus-locked
const action = getKeyBindingsManager().getAccessibilityAction(ev);
@ -216,21 +228,23 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
return;
}
if ([
KeyBindingAction.Escape,
// You can only navigate the ContextMenu by arrow keys and Home/End (see RovingTabIndex).
// Tabbing to the next section of the page, will close the ContextMenu.
KeyBindingAction.Tab,
// When someone moves left or right along a <Toolbar /> (like the
// MessageActionBar), we should close any ContextMenu that is open.
KeyBindingAction.ArrowLeft,
KeyBindingAction.ArrowRight,
].includes(action)) {
if (
[
KeyBindingAction.Escape,
// You can only navigate the ContextMenu by arrow keys and Home/End (see RovingTabIndex).
// Tabbing to the next section of the page, will close the ContextMenu.
KeyBindingAction.Tab,
// When someone moves left or right along a <Toolbar /> (like the
// MessageActionBar), we should close any ContextMenu that is open.
KeyBindingAction.ArrowLeft,
KeyBindingAction.ArrowRight,
].includes(action!)
) {
this.props.onFinished();
}
};
protected renderMenu(hasBackground = this.props.hasBackground) {
protected renderMenu(hasBackground = this.props.hasBackground): JSX.Element {
const position: Partial<Writeable<DOMRect>> = {};
const {
top,
@ -253,6 +267,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
wrapperClassName,
chevronFace: propsChevronFace,
chevronOffset: propsChevronOffset,
mountAsChild,
...props
} = this.props;
@ -297,15 +312,12 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
position.top = Math.min(position.top, maxTop);
// Adjust the chevron if necessary
if (chevronOffset.top !== undefined) {
chevronOffset.top = propsChevronOffset + top - position.top;
chevronOffset.top = propsChevronOffset! + top! - position.top;
}
} else if (position.bottom !== undefined) {
position.bottom = Math.min(
position.bottom,
windowHeight - contextMenuRect.height - WINDOW_PADDING,
);
position.bottom = Math.min(position.bottom, windowHeight - contextMenuRect.height - WINDOW_PADDING);
if (chevronOffset.top !== undefined) {
chevronOffset.top = propsChevronOffset + position.bottom - bottom;
chevronOffset.top = propsChevronOffset! + position.bottom - bottom!;
}
}
if (position.left !== undefined) {
@ -315,15 +327,12 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
}
position.left = Math.min(position.left, maxLeft);
if (chevronOffset.left !== undefined) {
chevronOffset.left = propsChevronOffset + left - position.left;
chevronOffset.left = propsChevronOffset! + left! - position.left;
}
} else if (position.right !== undefined) {
position.right = Math.min(
position.right,
windowWidth - contextMenuRect.width - WINDOW_PADDING,
);
position.right = Math.min(position.right, windowWidth - contextMenuRect.width - WINDOW_PADDING);
if (chevronOffset.left !== undefined) {
chevronOffset.left = propsChevronOffset + position.right - right;
chevronOffset.left = propsChevronOffset! + position.right - right!;
}
}
}
@ -333,25 +342,28 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} />;
}
const menuClasses = classNames({
'mx_ContextualMenu': true,
/**
* 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,
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
'mx_ContextualMenu_rightAligned': rightAligned === true,
'mx_ContextualMenu_bottomAligned': bottomAligned === true,
}, menuClassName);
const menuClasses = classNames(
{
mx_ContextualMenu: true,
/**
* 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,
mx_ContextualMenu_withChevron_bottom: chevronFace === ChevronFace.Bottom,
mx_ContextualMenu_rightAligned: rightAligned === true,
mx_ContextualMenu_bottomAligned: bottomAligned === true,
},
menuClassName,
);
const menuStyle: CSSProperties = {};
if (menuWidth) {
@ -375,13 +387,13 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
menuStyle["paddingRight"] = menuPaddingRight;
}
const wrapperStyle = {};
const wrapperStyle: CSSProperties = {};
if (!isNaN(Number(zIndex))) {
menuStyle["zIndex"] = zIndex + 1;
menuStyle["zIndex"] = zIndex! + 1;
wrapperStyle["zIndex"] = zIndex;
}
let background;
let background: JSX.Element;
if (hasBackground) {
background = (
<div
@ -393,15 +405,15 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
);
}
let body = <>
{ chevron }
{ children }
</>;
let body = (
<>
{chevron}
{children}
</>
);
if (focusLock) {
body = <FocusLock>
{ body }
</FocusLock>;
body = <FocusLock>{body}</FocusLock>;
}
// filter props that are invalid for DOM elements
@ -413,7 +425,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
return (
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.onKeyDown}>
{ ({ onKeyDownHandler }) => (
{({ onKeyDownHandler }) => (
<div
className={classNames("mx_ContextualMenu_wrapper", wrapperClassName)}
style={{ ...position, ...wrapperStyle }}
@ -421,7 +433,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
onKeyDown={onKeyDownHandler}
onContextMenu={this.onContextMenuPreventBubbling}
>
{ background }
{background}
<div
className={menuClasses}
style={menuStyle}
@ -429,15 +441,15 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
role={managed ? "menu" : undefined}
{...divProps}
>
{ body }
{body}
</div>
</div>
) }
)}
</RovingTabIndexProvider>
);
}
render(): React.ReactChild {
public render(): React.ReactChild {
if (this.props.mountAsChild) {
// Render as a child of the current parent
return this.renderMenu();
@ -457,13 +469,38 @@ export type ToRightOf = {
// 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): ToRightOf => {
const left = elementRect.right + window.scrollX + 3;
let top = elementRect.top + (elementRect.height / 2) + window.scrollY;
let top = elementRect.top + elementRect.height / 2 + window.scrollY;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return { left, top, chevronOffset };
};
export type AboveLeftOf = IPosition & {
chevronFace: ChevronFace;
export type ToLeftOf = {
chevronOffset: number;
right: number;
top: number;
};
// Placement method for <ContextMenu /> to position context menu to left of elementRect with chevronOffset
export const toLeftOf = (elementRect: DOMRect, chevronOffset = 12): ToLeftOf => {
const right = UIStore.instance.windowWidth - elementRect.left + window.scrollX - 3;
let top = elementRect.top + elementRect.height / 2 + window.scrollY;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return { right, top, chevronOffset };
};
/**
* Placement method for <ContextMenu /> to position context menu of or right of elementRect
* depending on which side has more space.
*/
export const toLeftOrRightOf = (elementRect: DOMRect, chevronOffset = 12): ToRightOf | ToLeftOf => {
const spaceToTheLeft = elementRect.left;
const spaceToTheRight = UIStore.instance.windowWidth - elementRect.right;
if (spaceToTheLeft > spaceToTheRight) {
return toLeftOf(elementRect, chevronOffset);
}
return toRightOf(elementRect, chevronOffset);
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
@ -472,7 +509,7 @@ export const aboveLeftOf = (
elementRect: Pick<DOMRect, "right" | "top" | "bottom">,
chevronFace = ChevronFace.None,
vPadding = 0,
): AboveLeftOf => {
): MenuProps => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.scrollX;
@ -484,7 +521,7 @@ export const aboveLeftOf = (
if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
}
return menuOptions;
@ -496,7 +533,7 @@ export const aboveRightOf = (
elementRect: Pick<DOMRect, "left" | "top" | "bottom">,
chevronFace = ChevronFace.None,
vPadding = 0,
): AboveLeftOf => {
): MenuProps => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonLeft = elementRect.left + window.scrollX;
@ -508,7 +545,7 @@ export const aboveRightOf = (
if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
}
return menuOptions;
@ -516,24 +553,19 @@ export const aboveRightOf = (
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
// and always above elementRect
export const alwaysAboveLeftOf = (
export const alwaysMenuProps = (
elementRect: Pick<DOMRect, "right" | "bottom" | "top">,
chevronFace = ChevronFace.None,
vPadding = 0,
) => {
): IPosition & { chevronFace: ChevronFace } => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.scrollX;
const buttonBottom = elementRect.bottom + window.scrollY;
const buttonTop = elementRect.top + window.scrollY;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding;
} else {
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
}
// Align the menu vertically above the menu
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
return menuOptions;
};
@ -544,7 +576,7 @@ export const alwaysAboveRightOf = (
elementRect: Pick<DOMRect, "left" | "top">,
chevronFace = ChevronFace.None,
vPadding = 0,
) => {
): IPosition & { chevronFace: ChevronFace } => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonLeft = elementRect.left + window.scrollX;
@ -552,7 +584,7 @@ export const alwaysAboveRightOf = (
// Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft;
// Align the menu vertically above the menu
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
return menuOptions;
};
@ -573,12 +605,12 @@ export const useContextMenu = <T extends any = HTMLElement>(inputRef?: RefObject
}
const [isOpen, setIsOpen] = useState(false);
const open = (ev?: SyntheticEvent) => {
const open = (ev?: SyntheticEvent): void => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(true);
};
const close = (ev?: SyntheticEvent) => {
const close = (ev?: SyntheticEvent): void => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(false);
@ -588,21 +620,28 @@ export const useContextMenu = <T extends any = HTMLElement>(inputRef?: RefObject
};
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
export function createMenu(ElementClass, props) {
const onFinished = function(...args) {
export function createMenu(
ElementClass: typeof React.Component,
props: Record<string, any>,
): { close: (...args: any[]) => void } {
const onFinished = function (...args: any[]): void {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
props?.onFinished?.apply(null, args);
};
const menu = <ContextMenu
{...props}
mountAsChild={true}
hasBackground={false}
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
>
<ElementClass {...props} onFinished={onFinished} />
</ContextMenu>;
const menu = (
<TooltipProvider>
<ContextMenu
{...props}
mountAsChild={true}
hasBackground={false}
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
>
<ElementClass {...props} onFinished={onFinished} />
</ContextMenu>
</TooltipProvider>
);
ReactDOM.render(menu, getOrCreateContainer());

View file

@ -16,14 +16,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import classnames from 'classnames';
import React from "react";
import sanitizeHtml from "sanitize-html";
import classnames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../languageHandler';
import dis from '../../dispatcher/dispatcher';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { _t, TranslationKey } from "../../languageHandler";
import dis from "../../dispatcher/dispatcher";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { ActionPayload } from "../../dispatcher/payloads";
@ -46,29 +46,29 @@ interface IState {
export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
public static contextType = MatrixClientContext;
private unmounted = false;
private dispatcherRef: string = null;
private dispatcherRef: string | null = null;
constructor(props: IProps, context: typeof MatrixClientContext) {
public constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context);
this.state = {
page: '',
page: "",
};
}
private translate(s: string): string {
private translate(s: TranslationKey): string {
return sanitizeHtml(_t(s));
}
private async fetchEmbed() {
private async fetchEmbed(): Promise<void> {
let res: Response;
try {
res = await fetch(this.props.url, { method: "GET" });
res = await fetch(this.props.url!, { method: "GET" });
} catch (err) {
if (this.unmounted) return;
logger.warn(`Error loading page: ${err}`);
this.setState({ page: _t("Couldn't load page") });
this.setState({ page: _t("cant_load_page") });
return;
}
@ -76,15 +76,15 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
if (!res.ok) {
logger.warn(`Error loading page: ${res.status}`);
this.setState({ page: _t("Couldn't load page") });
this.setState({ page: _t("cant_load_page") });
return;
}
let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1) => this.translate(g1));
let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/gm, (match, g1) => this.translate(g1));
if (this.props.replaceMap) {
Object.keys(this.props.replaceMap).forEach(key => {
body = body.split(key).join(this.props.replaceMap[key]);
Object.keys(this.props.replaceMap).forEach((key) => {
body = body.split(key).join(this.props.replaceMap![key]);
});
}
@ -113,34 +113,27 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
private onAction = (payload: ActionPayload): void => {
// HACK: Workaround for the context's MatrixClient not being set up at render time.
if (payload.action === 'client_started') {
if (payload.action === "client_started") {
this.forceUpdate();
}
};
public render(): JSX.Element {
public render(): React.ReactNode {
// HACK: Workaround for the context's MatrixClient not updating.
const client = this.context || MatrixClientPeg.get();
const isGuest = client ? client.isGuest() : true;
const className = this.props.className;
const classes = classnames({
[className]: true,
const classes = classnames(className, {
[`${className}_guest`]: isGuest,
[`${className}_loggedIn`]: !!client,
});
const content = <div className={`${className}_body`}
dangerouslySetInnerHTML={{ __html: this.state.page }}
/>;
const content = <div className={`${className}_body`} dangerouslySetInnerHTML={{ __html: this.state.page }} />;
if (this.props.scrollbar) {
return <AutoHideScrollbar className={classes}>
{ content }
</AutoHideScrollbar>;
return <AutoHideScrollbar className={classes}>{content}</AutoHideScrollbar>;
} else {
return <div className={classes}>
{ content }
</div>;
return <div className={classes}>{content}</div>;
}
}
}

View file

@ -0,0 +1,38 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import { Icon as WarningBadgeIcon } from "../../../res/img/compound/error-16px.svg";
interface ErrorMessageProps {
message: string | ReactNode | null;
}
/**
* Error message component.
* Reserves two lines to display errors to prevent layout shifts when the error pops up.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
const icon = message ? <WarningBadgeIcon className="mx_Icon mx_Icon_16" /> : null;
return (
<div className="mx_ErrorMessage">
{icon}
{message}
</div>
);
};

View file

@ -37,37 +37,40 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
useEffect(() => {
if (!parent || parent.ondrop) return;
const onDragEnter = (ev: DragEvent) => {
const onDragEnter = (ev: DragEvent): void => {
ev.stopPropagation();
ev.preventDefault();
if (!ev.dataTransfer) return;
setState(state => ({
setState((state) => ({
// We always increment the counter no matter the types, because dragging is
// still happening. If we didn't, the drag counter would get out of sync.
counter: state.counter + 1,
// See:
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
dragging: (
ev.dataTransfer.types.includes("Files") ||
ev.dataTransfer.types.includes("application/x-moz-file")
) ? true : state.dragging,
dragging:
ev.dataTransfer!.types.includes("Files") ||
ev.dataTransfer!.types.includes("application/x-moz-file")
? true
: state.dragging,
}));
};
const onDragLeave = (ev: DragEvent) => {
const onDragLeave = (ev: DragEvent): void => {
ev.stopPropagation();
ev.preventDefault();
setState(state => ({
setState((state) => ({
counter: state.counter - 1,
dragging: state.counter <= 1 ? false : state.dragging,
}));
};
const onDragOver = (ev: DragEvent) => {
const onDragOver = (ev: DragEvent): void => {
ev.stopPropagation();
ev.preventDefault();
if (!ev.dataTransfer) return;
ev.dataTransfer.dropEffect = "none";
@ -79,12 +82,13 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
}
};
const onDrop = (ev: DragEvent) => {
const onDrop = (ev: DragEvent): void => {
ev.stopPropagation();
ev.preventDefault();
if (!ev.dataTransfer) return;
onFileDrop(ev.dataTransfer);
setState(state => ({
setState((state) => ({
dragging: false,
counter: state.counter - 1,
}));
@ -108,10 +112,16 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
}, [parent, onFileDrop]);
if (state.dragging) {
return <div className="mx_FileDropTarget">
<img src={require("../../../res/img/upload-big.svg").default} className="mx_FileDropTarget_image" alt="" />
{ _t("Drop file here to upload") }
</div>;
return (
<div className="mx_FileDropTarget">
<img
src={require("../../../res/img/upload-big.svg").default}
className="mx_FileDropTarget_image"
alt=""
/>
{_t("room|drop_file_prompt")}
</div>
);
}
return null;

View file

@ -15,26 +15,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import { Filter } from 'matrix-js-sdk/src/filter';
import { EventTimelineSet, IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set";
import { Direction } from "matrix-js-sdk/src/models/event-timeline";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room';
import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
import React, { createRef } from "react";
import {
Filter,
EventTimelineSet,
IRoomTimelineData,
Direction,
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
TimelineWindow,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import EventIndexPeg from "../../indexing/EventIndexPeg";
import { _t } from '../../languageHandler';
import { _t } from "../../languageHandler";
import SearchWarning, { WarningKind } from "../views/elements/SearchWarning";
import BaseCard from "../views/right_panel/BaseCard";
import ResizeNotifier from '../../utils/ResizeNotifier';
import ResizeNotifier from "../../utils/ResizeNotifier";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import Measured from '../views/elements/Measured';
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import Measured from "../views/elements/Measured";
interface IProps {
roomId: string;
@ -43,7 +48,7 @@ interface IProps {
}
interface IState {
timelineSet: EventTimelineSet;
timelineSet: EventTimelineSet | null;
narrow: boolean;
}
@ -51,34 +56,34 @@ interface IState {
* Component which shows the filtered file using a TimelinePanel
*/
class FilePanel extends React.Component<IProps, IState> {
static contextType = RoomContext;
public static contextType = RoomContext;
// This is used to track if a decrypted event was a live event and should be
// added to the timeline.
private decryptingEvents = new Set<string>();
public noRoom: boolean;
public noRoom = false;
private card = createRef<HTMLDivElement>();
state = {
public state: IState = {
timelineSet: null,
narrow: false,
};
private onRoomTimeline = (
ev: MatrixEvent,
room: Room | null,
toStartOfTimeline: boolean,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
removed: boolean,
data: IRoomTimelineData,
): void => {
if (room?.roomId !== this.props?.roomId) return;
if (room?.roomId !== this.props.roomId) return;
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
const client = MatrixClientPeg.get();
const client = MatrixClientPeg.safeGet();
client.decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted()) {
this.decryptingEvents.add(ev.getId());
this.decryptingEvents.add(ev.getId()!);
} else {
this.addEncryptedLiveEvent(ev);
}
@ -86,7 +91,7 @@ class FilePanel extends React.Component<IProps, IState> {
private onEventDecrypted = (ev: MatrixEvent, err?: any): void => {
if (ev.getRoomId() !== this.props.roomId) return;
const eventId = ev.getId();
const eventId = ev.getId()!;
if (!this.decryptingEvents.delete(eventId)) return;
if (err) return;
@ -99,21 +104,21 @@ class FilePanel extends React.Component<IProps, IState> {
const timeline = this.state.timelineSet.getLiveTimeline();
if (ev.getType() !== "m.room.message") return;
if (["m.file", "m.image", "m.video", "m.audio"].indexOf(ev.getContent().msgtype) == -1) {
if (!["m.file", "m.image", "m.video", "m.audio"].includes(ev.getContent().msgtype!)) {
return;
}
if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) {
if (!this.state.timelineSet.eventIdToTimeline(ev.getId()!)) {
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
}
}
public async componentDidMount(): Promise<void> {
const client = MatrixClientPeg.get();
const client = MatrixClientPeg.safeGet();
await this.updateTimelineSet(this.props.roomId);
if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
if (!client.isRoomEncrypted(this.props.roomId)) return;
// The timelineSets filter makes sure that encrypted events that contain
// URLs never get added to the timeline, even if they are live events.
@ -133,7 +138,7 @@ class FilePanel extends React.Component<IProps, IState> {
const client = MatrixClientPeg.get();
if (client === null) return;
if (!MatrixClientPeg.get().isRoomEncrypted(this.props.roomId)) return;
if (!client.isRoomEncrypted(this.props.roomId)) return;
if (EventIndexPeg.get() !== null) {
client.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
@ -142,27 +147,20 @@ class FilePanel extends React.Component<IProps, IState> {
}
public async fetchFileEventsServer(room: Room): Promise<EventTimelineSet> {
const client = MatrixClientPeg.get();
const client = MatrixClientPeg.safeGet();
const filter = new Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true,
"types": [
"m.room.message",
],
},
const filter = new Filter(client.getSafeUserId());
filter.setDefinition({
room: {
timeline: {
contains_url: true,
types: ["m.room.message"],
},
},
);
});
const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter);
filter.filterId = filterId;
const timelineSet = room.getOrCreateFilteredTimelineSet(filter);
return timelineSet;
filter.filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter);
return room.getOrCreateFilteredTimelineSet(filter);
}
private onPaginationRequest = (
@ -170,7 +168,7 @@ class FilePanel extends React.Component<IProps, IState> {
direction: Direction,
limit: number,
): Promise<boolean> => {
const client = MatrixClientPeg.get();
const client = MatrixClientPeg.safeGet();
const eventIndex = EventIndexPeg.get();
const roomId = this.props.roomId;
@ -180,7 +178,7 @@ class FilePanel extends React.Component<IProps, IState> {
// the event index to fulfill the pagination request. Asking the server
// to paginate won't ever work since the server can't correctly filter
// out events containing URLs
if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
if (room && client.isRoomEncrypted(roomId) && eventIndex !== null) {
return eventIndex.paginateTimelineWindow(room, timelineWindow, direction, limit);
} else {
return timelineWindow.paginate(direction, limit);
@ -192,7 +190,7 @@ class FilePanel extends React.Component<IProps, IState> {
};
public async updateTimelineSet(roomId: string): Promise<void> {
const client = MatrixClientPeg.get();
const client = MatrixClientPeg.safeGet();
const room = client.getRoom(roomId);
const eventIndex = EventIndexPeg.get();
@ -227,54 +225,62 @@ class FilePanel extends React.Component<IProps, IState> {
}
}
public render() {
if (MatrixClientPeg.get().isGuest()) {
return <BaseCard
className="mx_FilePanel mx_RoomView_messageListWrapper"
onClose={this.props.onClose}
>
<div className="mx_RoomView_empty">
{ _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
</div>
</BaseCard>;
public render(): React.ReactNode {
if (MatrixClientPeg.safeGet().isGuest()) {
return (
<BaseCard className="mx_FilePanel mx_RoomView_messageListWrapper" onClose={this.props.onClose}>
<div className="mx_RoomView_empty">
{_t(
"file_panel|guest_note",
{},
{
a: (sub) => (
<a href="#/register" key="sub">
{sub}
</a>
),
},
)}
</div>
</BaseCard>
);
} else if (this.noRoom) {
return <BaseCard
className="mx_FilePanel mx_RoomView_messageListWrapper"
onClose={this.props.onClose}
>
<div className="mx_RoomView_empty">{ _t("You must join the room to see its files") }</div>
</BaseCard>;
return (
<BaseCard className="mx_FilePanel mx_RoomView_messageListWrapper" onClose={this.props.onClose}>
<div className="mx_RoomView_empty">{_t("file_panel|peek_note")}</div>
</BaseCard>
);
}
// wrap a TimelinePanel with the jump-to-event bits turned off.
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>
</div>);
const emptyState = (
<div className="mx_RightPanel_empty mx_FilePanel_empty">
<h2>{_t("file_panel|empty_heading")}</h2>
<p>{_t("file_panel|empty_description")}</p>
</div>
);
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.safeGet().isRoomEncrypted(this.props.roomId);
if (this.state.timelineSet) {
return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
narrow: this.state.narrow,
}}>
<RoomContext.Provider
value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
narrow: this.state.narrow,
}}
>
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
withoutScrollContainer
ref={this.card}
>
<Measured
sensor={this.card.current}
onMeasurement={this.onMeasurement}
/>
{this.card.current && (
<Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />
)}
<SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
@ -291,14 +297,13 @@ class FilePanel extends React.Component<IProps, IState> {
);
} else {
return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
>
<RoomContext.Provider
value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}
>
<BaseCard className="mx_FilePanel" onClose={this.props.onClose}>
<Spinner />
</BaseCard>
</RoomContext.Provider>

View file

@ -44,17 +44,19 @@ export function GenericDropdownMenuOption<T extends Key>({
onClick: (ev: ButtonEvent) => void;
isSelected: boolean;
}): JSX.Element {
return <MenuItemRadio
active={isSelected}
className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item"
onClick={onClick}
>
<div className="mx_GenericDropdownMenu_Option--label">
<span>{ label }</span>
<span>{ description }</span>
</div>
{ adornment }
</MenuItemRadio>;
return (
<MenuItemRadio
active={isSelected}
className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item"
onClick={onClick}
>
<div className="mx_GenericDropdownMenu_Option--label">
<span>{label}</span>
<span>{description}</span>
</div>
{adornment}
</MenuItemRadio>
);
}
export function GenericDropdownMenuGroup<T extends Key>({
@ -63,33 +65,41 @@ export function GenericDropdownMenuGroup<T extends Key>({
adornment,
children,
}: PropsWithChildren<GenericDropdownMenuOption<T>>): JSX.Element {
return <>
<div className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--header">
<div className="mx_GenericDropdownMenu_Option--label">
<span>{ label }</span>
<span>{ description }</span>
return (
<>
<div className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--header">
<div className="mx_GenericDropdownMenu_Option--label">
<span>{label}</span>
<span>{description}</span>
</div>
{adornment}
</div>
{ adornment }
</div>
{ children }
</>;
{children}
</>
);
}
function isGenericDropdownMenuGroup<T>(
item: GenericDropdownMenuItem<T>,
): item is GenericDropdownMenuGroup<T> {
function isGenericDropdownMenuGroupArray<T>(
items: readonly GenericDropdownMenuItem<T>[],
): items is GenericDropdownMenuGroup<T>[] {
return isGenericDropdownMenuGroup(items[0]);
}
function isGenericDropdownMenuGroup<T>(item: GenericDropdownMenuItem<T>): item is GenericDropdownMenuGroup<T> {
return "options" in item;
}
type WithKeyFunction<T> = T extends Key ? {
toKey?: (key: T) => Key;
} : {
toKey: (key: T) => Key;
};
type WithKeyFunction<T> = T extends Key
? {
toKey?: (key: T) => Key;
}
: {
toKey: (key: T) => Key;
};
type IProps<T> = WithKeyFunction<T> & {
value: T;
options: (readonly GenericDropdownMenuOption<T>[] | readonly GenericDropdownMenuGroup<T>[]);
options: readonly GenericDropdownMenuOption<T>[] | readonly GenericDropdownMenuGroup<T>[];
onChange: (option: T) => void;
selectedLabel: (option: GenericDropdownMenuItem<T> | null | undefined) => ReactNode;
onOpen?: (ev: ButtonEvent) => void;
@ -102,82 +112,99 @@ type IProps<T> = WithKeyFunction<T> & {
}>;
};
export function GenericDropdownMenu<T>(
{ value, onChange, options, selectedLabel, onOpen, onClose, toKey, className, AdditionalOptions }: IProps<T>,
): JSX.Element {
export function GenericDropdownMenu<T>({
value,
onChange,
options,
selectedLabel,
onOpen,
onClose,
toKey,
className,
AdditionalOptions,
}: IProps<T>): JSX.Element {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
const selected: GenericDropdownMenuItem<T> | null = options
.flatMap(it => isGenericDropdownMenuGroup(it) ? [it, ...it.options] : [it])
.find(option => toKey ? toKey(option.key) === toKey(value) : option.key === value);
const selected: GenericDropdownMenuItem<T> | undefined = options
.flatMap((it) => (isGenericDropdownMenuGroup(it) ? [it, ...it.options] : [it]))
.find((option) => (toKey ? toKey(option.key) === toKey(value) : option.key === value));
let contextMenuOptions: JSX.Element;
if (options && isGenericDropdownMenuGroup(options[0])) {
contextMenuOptions = <>
{ options.map(group => (
<GenericDropdownMenuGroup
key={toKey?.(group.key) ?? group.key}
label={group.label}
description={group.description}
adornment={group.adornment}
>
{ group.options.map(option => (
<GenericDropdownMenuOption
key={toKey?.(option.key) ?? option.key}
label={option.label}
description={option.description}
onClick={(ev: ButtonEvent) => {
onChange(option.key);
closeMenu();
onClose?.(ev);
}}
adornment={option.adornment}
isSelected={option === selected}
/>
)) }
</GenericDropdownMenuGroup>
)) }
</>;
if (options && isGenericDropdownMenuGroupArray(options)) {
contextMenuOptions = (
<>
{options.map((group) => (
<GenericDropdownMenuGroup
key={toKey?.(group.key) ?? (group.key as Key)}
label={group.label}
description={group.description}
adornment={group.adornment}
>
{group.options.map((option) => (
<GenericDropdownMenuOption
key={toKey?.(option.key) ?? (option.key as Key)}
label={option.label}
description={option.description}
onClick={(ev: ButtonEvent) => {
onChange(option.key);
closeMenu();
onClose?.(ev);
}}
adornment={option.adornment}
isSelected={option === selected}
/>
))}
</GenericDropdownMenuGroup>
))}
</>
);
} else {
contextMenuOptions = <>
{ options.map(option => (
<GenericDropdownMenuOption
key={toKey?.(option.key) ?? option.key}
label={option.label}
description={option.description}
onClick={(ev: ButtonEvent) => {
onChange(option.key);
closeMenu();
onClose?.(ev);
}}
adornment={option.adornment}
isSelected={option === selected}
/>
)) }
</>;
contextMenuOptions = (
<>
{options.map((option) => (
<GenericDropdownMenuOption
key={toKey?.(option.key) ?? (option.key as Key)}
label={option.label}
description={option.description}
onClick={(ev: ButtonEvent) => {
onChange(option.key);
closeMenu();
onClose?.(ev);
}}
adornment={option.adornment}
isSelected={option === selected}
/>
))}
</>
);
}
const contextMenu = menuDisplayed ? <ContextMenu
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
wrapperClassName={classNames("mx_GenericDropdownMenu_wrapper", className)}
{...aboveLeftOf(button.current.getBoundingClientRect())}
>
{ contextMenuOptions }
{ AdditionalOptions && (
<AdditionalOptions menuDisplayed={menuDisplayed} openMenu={openMenu} closeMenu={closeMenu} />
) }
</ContextMenu> : null;
return <>
<ContextMenuButton
className="mx_GenericDropdownMenu_button"
inputRef={button}
isExpanded={menuDisplayed}
onClick={(ev: ButtonEvent) => {
openMenu();
onOpen?.(ev);
}}
>
{ selectedLabel(selected) }
</ContextMenuButton>
{ contextMenu }
</>;
const contextMenu =
menuDisplayed && button.current ? (
<ContextMenu
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
wrapperClassName={classNames("mx_GenericDropdownMenu_wrapper", className)}
{...aboveLeftOf(button.current.getBoundingClientRect())}
>
{contextMenuOptions}
{AdditionalOptions && (
<AdditionalOptions menuDisplayed={menuDisplayed} openMenu={openMenu} closeMenu={closeMenu} />
)}
</ContextMenu>
) : null;
return (
<>
<ContextMenuButton
className="mx_GenericDropdownMenu_button"
ref={button}
isExpanded={menuDisplayed}
onClick={(ev: ButtonEvent) => {
openMenu();
onOpen?.(ev);
}}
>
{selectedLabel(selected)}
</ContextMenuButton>
{contextMenu}
</>
);
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
interface IProps {
title: React.ReactNode;
@ -22,12 +22,14 @@ interface IProps {
}
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>
public render(): React.ReactNode {
return (
<div className="mx_GenericErrorPage">
<div className="mx_GenericErrorPage_box">
<h1>{this.props.title}</h1>
<p>{this.props.message}</p>
</div>
</div>
</div>;
);
}
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import * as React from "react";
import { useContext, useState } from "react";
import AutoHideScrollbar from './AutoHideScrollbar';
import AutoHideScrollbar from "./AutoHideScrollbar";
import { getHomePageUrl } from "../../utils/pages";
import { _tDom } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
@ -28,105 +28,115 @@ import { OwnProfileStore } from "../../stores/OwnProfileStore";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { useEventEmitter } from "../../hooks/useEventEmitter";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext";
import MiniAvatarUploader, { AVATAR_SIZE } from "../views/elements/MiniAvatarUploader";
import PosthogTrackers from "../../PosthogTrackers";
import EmbeddedPage from "./EmbeddedPage";
const onClickSendDm = (ev: ButtonEvent) => {
const onClickSendDm = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebHomeCreateChatButton", ev);
dis.dispatch({ action: 'view_create_chat' });
dis.dispatch({ action: "view_create_chat" });
};
const onClickExplore = (ev: ButtonEvent) => {
const onClickExplore = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebHomeExploreRoomsButton", ev);
dis.fire(Action.ViewRoomDirectory);
};
const onClickNewRoom = (ev: ButtonEvent) => {
const onClickNewRoom = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebHomeCreateRoomButton", ev);
dis.dispatch({ action: 'view_create_room' });
dis.dispatch({ action: "view_create_room" });
};
interface IProps {
justRegistered?: boolean;
}
const getOwnProfile = (userId: string) => ({
const getOwnProfile = (
userId: string,
): {
displayName: string;
avatarUrl?: string;
} => ({
displayName: OwnProfileStore.instance.displayName || userId,
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE),
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(parseInt(AVATAR_SIZE, 10)) ?? undefined,
});
const UserWelcomeTop = () => {
const UserWelcomeTop: React.FC = () => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
const userId = cli.getUserId()!;
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
setOwnProfile(getOwnProfile(userId));
});
return <div>
<MiniAvatarUploader
hasAvatar={!!ownProfile.avatarUrl}
hasAvatarLabel={_tDom("Great, that'll help people know it's you")}
noAvatarLabel={_tDom("Add a photo so people know it's you.")}
setAvatarUrl={url => cli.setAvatarUrl(url)}
isUserAvatar
onClick={ev => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)}
>
<BaseAvatar
idName={userId}
name={ownProfile.displayName}
url={ownProfile.avatarUrl}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
resizeMethod="crop"
/>
</MiniAvatarUploader>
return (
<div>
<MiniAvatarUploader
hasAvatar={!!ownProfile.avatarUrl}
hasAvatarLabel={_tDom("onboarding|has_avatar_label")}
noAvatarLabel={_tDom("onboarding|no_avatar_label")}
setAvatarUrl={(url) => cli.setAvatarUrl(url)}
isUserAvatar
onClick={(ev) => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)}
>
<BaseAvatar
idName={userId}
name={ownProfile.displayName}
url={ownProfile.avatarUrl}
size={AVATAR_SIZE}
/>
</MiniAvatarUploader>
<h1>{ _tDom("Welcome %(name)s", { name: ownProfile.displayName }) }</h1>
<h2>{ _tDom("Now, let's help you get started") }</h2>
</div>;
<h1>{_tDom("onboarding|welcome_user", { name: ownProfile.displayName })}</h1>
<h2>{_tDom("onboarding|welcome_detail")}</h2>
</div>
);
};
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
const cli = useMatrixClientContext();
const config = SdkConfig.get();
const pageUrl = getHomePageUrl(config);
const pageUrl = getHomePageUrl(config, cli);
if (pageUrl) {
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
}
let introSection: JSX.Element;
if (justRegistered || !OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE)) {
if (justRegistered || !OwnProfileStore.instance.getHttpAvatarUrl(parseInt(AVATAR_SIZE, 10))) {
introSection = <UserWelcomeTop />;
} else {
const brandingConfig = SdkConfig.getObject("branding");
const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg";
introSection = <React.Fragment>
<img src={logoUrl} alt={config.brand} />
<h1>{ _tDom("Welcome to %(appName)s", { appName: config.brand }) }</h1>
<h2>{ _tDom("Own your conversations.") }</h2>
</React.Fragment>;
introSection = (
<React.Fragment>
<img src={logoUrl} alt={config.brand} />
<h1>{_tDom("onboarding|intro_welcome", { appName: config.brand })}</h1>
<h2>{_tDom("onboarding|intro_byline")}</h2>
</React.Fragment>
);
}
return <AutoHideScrollbar className="mx_HomePage mx_HomePage_default" element="main">
<div className="mx_HomePage_default_wrapper">
{ introSection }
<div className="mx_HomePage_default_buttons">
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
{ _tDom("Send a Direct Message") }
</AccessibleButton>
<AccessibleButton onClick={onClickExplore} className="mx_HomePage_button_explore">
{ _tDom("Explore Public Rooms") }
</AccessibleButton>
<AccessibleButton onClick={onClickNewRoom} className="mx_HomePage_button_createGroup">
{ _tDom("Create a Group Chat") }
</AccessibleButton>
return (
<AutoHideScrollbar className="mx_HomePage mx_HomePage_default" element="main">
<div className="mx_HomePage_default_wrapper">
{introSection}
<div className="mx_HomePage_default_buttons">
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
{_tDom("onboarding|send_dm")}
</AccessibleButton>
<AccessibleButton onClick={onClickExplore} className="mx_HomePage_button_explore">
{_tDom("onboarding|explore_rooms")}
</AccessibleButton>
<AccessibleButton onClick={onClickNewRoom} className="mx_HomePage_button_createGroup">
{_tDom("onboarding|create_room")}
</AccessibleButton>
</div>
</div>
</div>
</AutoHideScrollbar>;
</AutoHideScrollbar>
);
};
export default HomePage;

View file

@ -1,60 +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 from "react";
import {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { _t } from "../../languageHandler";
import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig";
interface IProps {
onClick?(): void;
}
interface IState {}
export default class HostSignupAction extends React.PureComponent<IProps, IState> {
private openDialog = async () => {
this.props.onClick?.();
await HostSignupStore.instance.setHostSignupActive(true);
};
public render(): React.ReactNode {
const hostSignupConfig = SdkConfig.getObject("host_signup");
if (!hostSignupConfig?.get("brand")) {
return null;
}
return (
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconHosting"
label={_t(
"Upgrade to %(hostSignupBrand)s",
{
hostSignupBrand: hostSignupConfig.get("brand"),
},
)}
onClick={this.openDialog}
/>
</IconizedContextMenuOptionList>
);
}
}

View file

@ -19,7 +19,8 @@ import React, { createRef } from "react";
import AutoHideScrollbar, { IProps as AutoHideScrollbarProps } from "./AutoHideScrollbar";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<AutoHideScrollbarProps<T>, "onWheel"> & {
export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<AutoHideScrollbarProps<T>, "onWheel" | "element"> & {
element?: T;
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element.
@ -38,20 +39,21 @@ interface IState {
rightIndicatorOffset: string;
}
export default class IndicatorScrollbar<
T extends keyof JSX.IntrinsicElements,
> extends React.Component<IProps<T>, IState> {
export default class IndicatorScrollbar<T extends keyof JSX.IntrinsicElements> extends React.Component<
IProps<T>,
IState
> {
private autoHideScrollbar = createRef<AutoHideScrollbar<any>>();
private scrollElement: HTMLDivElement;
private likelyTrackpadUser: boolean = null;
private scrollElement?: HTMLDivElement;
private likelyTrackpadUser: boolean | null = null;
private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
constructor(props: IProps<T>) {
public constructor(props: IProps<T>) {
super(props);
this.state = {
leftIndicatorOffset: '0',
rightIndicatorOffset: '0',
leftIndicatorOffset: "0",
rightIndicatorOffset: "0",
};
}
@ -83,12 +85,13 @@ export default class IndicatorScrollbar<
}
private checkOverflow = (): void => {
if (!this.scrollElement) return;
const hasTopOverflow = this.scrollElement.scrollTop > 0;
const hasBottomOverflow = this.scrollElement.scrollHeight >
(this.scrollElement.scrollTop + this.scrollElement.clientHeight);
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);
const hasRightOverflow =
this.scrollElement.scrollWidth > this.scrollElement.scrollLeft + this.scrollElement.clientWidth;
if (hasTopOverflow) {
this.scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
@ -114,10 +117,10 @@ export default class IndicatorScrollbar<
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",
});
}
};
@ -143,7 +146,7 @@ export default class IndicatorScrollbar<
const now = new Date().getTime();
if (Math.abs(e.deltaX) > 0) {
this.likelyTrackpadUser = true;
this.checkAgainForTrackpad = now + (1 * 60 * 1000);
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
@ -158,7 +161,8 @@ export default class IndicatorScrollbar<
return;
}
if (Math.abs(e.deltaX) <= xyThreshold) { // we are vertically scrolling.
if (Math.abs(e.deltaX) <= xyThreshold) {
// we are vertically scrolling.
// HACK: We increase the amount of scroll to counteract smooth scrolling browsers.
// Smooth scrolling browsers (Firefox) use the relative area to determine the scroll
// amount, which means the likely small area of content results in a small amount of
@ -169,32 +173,36 @@ export default class IndicatorScrollbar<
const additionalScroll = e.deltaY < 0 ? -50 : 50;
// noinspection JSSuspiciousNameCombination
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
const val = Math.abs(e.deltaY) < 25 ? e.deltaY + additionalScroll : e.deltaY;
this.scrollElement.scrollLeft += val * yRetention;
}
}
};
public render(): JSX.Element {
public render(): React.ReactNode {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
const rightIndicatorStyle = { right: this.state.rightIndicatorOffset };
const leftOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
const leftOverflowIndicator = trackHorizontalOverflow ? (
<div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} />
) : null;
const rightOverflowIndicator = trackHorizontalOverflow ? (
<div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} />
) : null;
return (<AutoHideScrollbar
{...otherProps}
ref={this.autoHideScrollbar}
wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel}
>
{ leftOverflowIndicator }
{ children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
return (
<AutoHideScrollbar
{...otherProps}
ref={this.autoHideScrollbar}
wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel}
>
{leftOverflowIndicator}
{children}
{rightOverflowIndicator}
</AutoHideScrollbar>
);
}
}

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import {
AuthType,
IAuthData,
@ -22,27 +23,26 @@ import {
InteractiveAuth,
IStageStatus,
} from "matrix-js-sdk/src/interactive-auth";
import { MatrixClient } from "matrix-js-sdk/src/client";
import React, { createRef } from 'react';
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
import getEntryComponentForLoginType, {
ContinueKind,
IStageComponent,
} from "../views/auth/InteractiveAuthEntryComponents";
import Spinner from "../views/elements/Spinner";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
type InteractiveAuthCallbackSuccess = (
type InteractiveAuthCallbackSuccess<T> = (
success: true,
response: IAuthData,
extra?: { emailSid?: string, clientSecret?: string }
) => void;
type InteractiveAuthCallbackFailure = (
success: false,
response: IAuthData | Error,
) => void;
export type InteractiveAuthCallback = InteractiveAuthCallbackSuccess & InteractiveAuthCallbackFailure;
response: T,
extra?: { emailSid?: string; clientSecret?: string },
) => Promise<void>;
type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => Promise<void>;
export type InteractiveAuthCallback<T> = InteractiveAuthCallbackSuccess<T> & InteractiveAuthCallbackFailure;
interface IProps {
export interface InteractiveAuthProps<T> {
// matrix client to use for UI auth requests
matrixClient: MatrixClient;
// response from initial request. If not supplied, will do a request on mount.
@ -62,9 +62,9 @@ interface IProps {
continueIsManaged?: boolean;
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText?: string;
continueKind?: string;
continueKind?: ContinueKind;
// callback
makeRequest(auth: IAuthData): Promise<IAuthData>;
makeRequest(auth: IAuthDict | null): Promise<T>;
// callback called when the auth process has finished,
// successfully or unsuccessfully.
// @param {boolean} status True if the operation requiring
@ -77,13 +77,13 @@ interface IProps {
// the auth session.
// * clientSecret {string} The client secret used in auth
// sessions with the ID server.
onAuthFinished: InteractiveAuthCallback;
onAuthFinished: InteractiveAuthCallback<T>;
// 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;
onStagePhaseChange?(stage: AuthType | null, phase: number): void;
}
interface IState {
@ -95,25 +95,22 @@ interface IState {
submitButtonEnabled: boolean;
}
export default class InteractiveAuthComponent extends React.Component<IProps, IState> {
private readonly authLogic: InteractiveAuth;
private readonly intervalId: number = null;
export default class InteractiveAuthComponent<T> extends React.Component<InteractiveAuthProps<T>, IState> {
private readonly authLogic: InteractiveAuth<T>;
private readonly intervalId: number | null = null;
private readonly stageComponent = createRef<IStageComponent>();
private unmounted = false;
constructor(props) {
public constructor(props: InteractiveAuthProps<T>) {
super(props);
this.state = {
authStage: null,
busy: false,
errorText: null,
errorCode: null,
submitButtonEnabled: false,
};
this.authLogic = new InteractiveAuth({
this.authLogic = new InteractiveAuth<T>({
authData: this.props.authData,
doRequest: this.requestCallback,
busyChanged: this.onBusyChanged,
@ -124,39 +121,52 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this.requestEmailToken,
supportedStages: [
AuthType.Password,
AuthType.Recaptcha,
AuthType.Email,
AuthType.Msisdn,
AuthType.Terms,
AuthType.RegistrationToken,
AuthType.UnstableRegistrationToken,
AuthType.Sso,
AuthType.SsoUnstable,
],
});
if (this.props.poll) {
this.intervalId = setInterval(() => {
this.intervalId = window.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);
logger.error("Error during user-interactive auth:", error);
if (this.unmounted) {
return;
}
public componentDidMount(): void {
this.authLogic
.attemptAuth()
.then(async (result) => {
const extra = {
emailSid: this.authLogic.getEmailSid(),
clientSecret: this.authLogic.getClientSecret(),
};
await this.props.onAuthFinished(true, result, extra);
})
.catch(async (error) => {
await this.props.onAuthFinished(false, error);
logger.error("Error during user-interactive auth:", error);
if (this.unmounted) {
return;
}
const msg = error.message || error.toString();
this.setState({
errorText: msg,
errorCode: error.errcode,
const msg = error.message || error.toString();
this.setState({
errorText: msg,
errorCode: error.errcode,
});
});
});
}
componentWillUnmount() {
public componentWillUnmount(): void {
this.unmounted = true;
if (this.intervalId !== null) {
@ -169,12 +179,13 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
secret: string,
attempt: number,
session: string,
): Promise<{sid: string}> => {
): Promise<{ sid: string }> => {
this.setState({
busy: true,
});
try {
return await this.props.requestEmailToken(email, secret, attempt, session);
// We know this method only gets called on flows where requestEmailToken is passed but types don't
return await this.props.requestEmailToken!(email, secret, attempt, session);
} finally {
this.setState({
busy: false,
@ -184,22 +195,25 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
private authStateUpdated = (stageType: AuthType, stageState: IStageStatus): void => {
const oldStage = this.state.authStage;
this.setState({
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
errorCode: stageState.errcode,
}, () => {
if (oldStage !== stageType) {
this.setFocus();
} else if (!stageState.error) {
this.stageComponent.current?.attemptFailed?.();
}
});
this.setState(
{
busy: false,
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
errorCode: stageState.errcode,
},
() => {
if (oldStage !== stageType) {
this.setFocus();
} else if (!stageState.error) {
this.stageComponent.current?.attemptFailed?.();
}
},
);
};
private requestCallback = (auth: IAuthData, background: boolean): Promise<IAuthData> => {
private requestCallback = (auth: IAuthDict | null, background: boolean): Promise<T> => {
// 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.
@ -208,19 +222,24 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
private onBusyChanged = (busy: boolean): void => {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
busy: true,
errorText: null,
errorCode: 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
if (busy) {
this.setState({
busy: true,
errorText: undefined,
errorCode: undefined,
});
}
// authStateUpdated is not called during sso flows
if (!busy && (this.state.authStage === AuthType.Sso || this.state.authStage === AuthType.SsoUnstable)) {
this.setState({ busy });
}
};
private setFocus(): void {
@ -232,22 +251,22 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
};
private onPhaseChange = (newPhase: number): void => {
this.props.onStagePhaseChange?.(this.state.authStage, newPhase || 0);
this.props.onStagePhaseChange?.(this.state.authStage ?? null, newPhase || 0);
};
private onStageCancel = (): void => {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
private onStageCancel = async (): Promise<void> => {
await this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
};
private onAuthStageFailed = (e: Error): void => {
this.props.onAuthFinished(false, e);
private onAuthStageFailed = async (e: Error): Promise<void> => {
await this.props.onAuthFinished(false, e);
};
private setEmailSid = (sid: string): void => {
this.authLogic.setEmailSid(sid);
};
render() {
public render(): React.ReactNode {
const stage = this.state.authStage;
if (!stage) {
if (this.state.busy) {

View file

@ -29,9 +29,7 @@ export const LargeLoader: React.FC<LargeLoaderProps> = ({ text }) => {
return (
<div className="mx_LargeLoader">
<Spinner w={45} h={45} />
<div className="mx_LargeLoader_text">
{ text }
</div>
<div className="mx_LargeLoader_text">{text}</div>
</div>
);
};

View file

@ -33,13 +33,11 @@ import { getKeyBindingsManager } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
import { IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
import RoomListHeader from "../views/rooms/RoomListHeader";
import RecentlyViewedButton from "../views/rooms/RecentlyViewedButton";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import IndicatorScrollbar from "./IndicatorScrollbar";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import SettingsStore from "../../settings/SettingsStore";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
@ -57,7 +55,6 @@ interface IProps {
enum BreadcrumbsMode {
Disabled,
Legacy,
Labs,
}
interface IState {
@ -68,10 +65,10 @@ interface IState {
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef = createRef<HTMLDivElement>();
private roomListRef = createRef<RoomList>();
private focusedElement = null;
private focusedElement: Element | null = null;
private isDoingStickyHeaders = false;
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -85,19 +82,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
private static get breadcrumbsMode(): BreadcrumbsMode {
if (!BreadcrumbsStore.instance.visible) return BreadcrumbsMode.Disabled;
return SettingsStore.getValue("feature_breadcrumbs_v2") ? BreadcrumbsMode.Labs : BreadcrumbsMode.Legacy;
return !BreadcrumbsStore.instance.visible ? BreadcrumbsMode.Disabled : BreadcrumbsMode.Legacy;
}
public componentDidMount() {
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
public componentDidMount(): void {
if (this.listContainerRef.current) {
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.listContainerRef.current.addEventListener("scroll", this.onScroll, { passive: true });
}
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true });
}
public componentWillUnmount() {
public componentWillUnmount(): void {
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
@ -112,25 +110,25 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private updateActiveSpace = (activeSpace: SpaceKey) => {
private updateActiveSpace = (activeSpace: SpaceKey): void => {
this.setState({ activeSpace });
};
private onDialPad = () => {
private onDialPad = (): void => {
dis.fire(Action.OpenDialPad);
};
private onExplore = (ev: ButtonEvent) => {
private onExplore = (ev: ButtonEvent): void => {
dis.fire(Action.ViewRoomDirectory);
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
};
private refreshStickyHeaders = () => {
private refreshStickyHeaders = (): void => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
private onBreadcrumbsUpdate = () => {
private onBreadcrumbsUpdate = (): void => {
const newVal = LeftPanel.breadcrumbsMode;
if (newVal !== this.state.showBreadcrumbs) {
this.setState({ showBreadcrumbs: newVal });
@ -141,7 +139,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private handleStickyHeaders(list: HTMLDivElement) {
private handleStickyHeaders(list: HTMLDivElement): void {
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
window.requestAnimationFrame(() => {
@ -150,29 +148,34 @@ export default class LeftPanel extends React.Component<IProps, IState> {
});
}
private doStickyHeaders(list: HTMLDivElement) {
private doStickyHeaders(list: HTMLDivElement): void {
if (!list.parentElement) return;
const topEdge = list.scrollTop;
const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
// We track which styles we want on a target before making the changes to avoid
// excessive layout updates.
const targetStyles = new Map<HTMLDivElement, {
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}>();
const targetStyles = new Map<
HTMLDivElement,
{
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}
>();
let lastTopHeader;
let firstBottomHeader;
let lastTopHeader: HTMLDivElement | undefined;
let firstBottomHeader: HTMLDivElement | undefined;
for (const sublist of sublists) {
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
if (!header) continue; // this should never occur
header.style.removeProperty("display"); // always clear display:none first
// When an element is <=40% off screen, make it take over
const offScreenFactor = 0.4;
const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
const isOffTop = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT <= topEdge;
const isOffBottom = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT >= bottomEdge;
if (isOffTop || sublist === sublists[0]) {
targetStyles.set(header, { stickyTop: true });
@ -193,7 +196,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// cause a no-op update, as adding/removing properties that are/aren't there cause
// layout updates.
for (const header of targetStyles.keys()) {
const style = targetStyles.get(header);
const style = targetStyles.get(header)!;
if (style.makeInvisible) {
// we will have already removed the 'display: none', so add it back.
@ -215,7 +218,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.remove("mx_RoomSublist_headerContainer_stickyTop");
}
if (header.style.top) {
header.style.removeProperty('top');
header.style.removeProperty("top");
}
}
@ -224,8 +227,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
}
const offset = UIStore.instance.windowHeight -
(list.parentElement.offsetTop + list.parentElement.offsetHeight);
const offset =
UIStore.instance.windowHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom;
@ -235,7 +238,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
}
if (header.style.bottom) {
header.style.removeProperty('bottom');
header.style.removeProperty("bottom");
}
}
@ -259,7 +262,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
if (header.style.width) {
header.style.removeProperty('width');
header.style.removeProperty("width");
}
}
}
@ -267,6 +270,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// add appropriate sticky classes to wrapper so it has
// the necessary top/bottom padding to put the sticky header in
const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper
if (!listWrapper) return;
if (lastTopHeader) {
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop");
} else {
@ -279,20 +283,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private onScroll = (ev: Event) => {
private onScroll = (ev: Event): void => {
const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list);
};
private onFocus = (ev: React.FocusEvent) => {
private onFocus = (ev: React.FocusEvent): void => {
this.focusedElement = ev.target;
};
private onBlur = () => {
private onBlur = (): void => {
this.focusedElement = null;
};
private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState) => {
private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState): void => {
if (!this.focusedElement) return;
const action = getKeyBindingsManager().getRoomListAction(ev);
@ -311,6 +315,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) {
return (
<IndicatorScrollbar
role="navigation"
aria-label={_t("a11y|recent_rooms")}
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
>
@ -321,28 +327,29 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
private renderSearchDialExplore(): React.ReactNode {
let dialPadButton = null;
let dialPadButton: JSX.Element | undefined;
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (LegacyCallHandler.instance.getSupportsPstnProtocol()) {
dialPadButton =
dialPadButton = (
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}
onClick={this.onDialPad}
title={_t("Open dial pad")}
/>;
title={_t("left_panel|open_dial_pad")}
/>
);
}
let rightButton: JSX.Element;
if (this.state.showBreadcrumbs === BreadcrumbsMode.Labs) {
rightButton = <RecentlyViewedButton />;
} else if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) {
rightButton = <AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/>;
let rightButton: JSX.Element | undefined;
if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) {
rightButton = (
<AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("action|explore_rooms")}
/>
);
}
return (
@ -351,53 +358,49 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
role="search"
>
<RoomSearch isMinimized={this.props.isMinimized} />
{ dialPadButton }
{ rightButton }
{dialPadButton}
{rightButton}
</div>
);
}
public render(): React.ReactNode {
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
ref={this.roomListRef}
/>;
const roomList = (
<RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
ref={this.roomListRef}
/>
);
const containerClasses = classNames({
"mx_LeftPanel": true,
"mx_LeftPanel_minimized": this.props.isMinimized,
mx_LeftPanel: true,
mx_LeftPanel_minimized: this.props.isMinimized,
});
const roomListClasses = classNames(
"mx_LeftPanel_actualRoomListContainer",
"mx_AutoHideScrollbar",
);
const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar");
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">
{ this.renderSearchDialExplore() }
{ this.renderBreadcrumbs() }
{ !this.props.isMinimized && (
<RoomListHeader
onVisibilityChange={this.refreshStickyHeaders}
/>
) }
{shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()}
{this.renderBreadcrumbs()}
{!this.props.isMinimized && <RoomListHeader onVisibilityChange={this.refreshStickyHeaders} />}
<UserOnboardingButton
selected={this.props.pageType === PageType.HomePage}
minimized={this.props.isMinimized}
/>
<div className="mx_LeftPanel_roomListWrapper">
<nav className="mx_LeftPanel_roomListWrapper" aria-label={_t("common|rooms")}>
<div
className={roomListClasses}
ref={this.listContainerRef}
@ -405,9 +408,9 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{ roomList }
{roomList}
</div>
</div>
</nav>
</div>
</div>
);

View file

@ -14,12 +14,11 @@ 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 { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { EventEmitter } from 'events';
import { EventEmitter } from "events";
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { MatrixClientPeg } from "../../MatrixClientPeg";
export enum LegacyCallEventGrouperEvent {
@ -35,22 +34,20 @@ const CONNECTING_STATES = [
CallState.CreateAnswer,
];
const SUPPORTED_STATES = [
CallState.Connected,
CallState.Ringing,
];
const SUPPORTED_STATES = [CallState.Connected, CallState.Ringing, CallState.Ended];
export enum CustomCallState {
Missed = "missed",
}
const isCallEventType = (eventType: string): boolean =>
eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call.");
export const isCallEvent = (event: MatrixEvent): boolean => isCallEventType(event.getType());
export function buildLegacyCallEventGroupers(
callEventGroupers: Map<string, LegacyCallEventGrouper>,
events?: MatrixEvent[],
): Map<string, LegacyCallEventGrouper> {
const newCallEventGroupers = new Map();
events?.forEach(ev => {
if (!ev.getType().startsWith("m.call.") && !ev.getType().startsWith("org.matrix.call.")) {
events?.forEach((ev) => {
if (!isCallEvent(ev)) {
return;
}
@ -70,48 +67,49 @@ export function buildLegacyCallEventGroupers(
export default class LegacyCallEventGrouper extends EventEmitter {
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
private call: MatrixCall;
public state: CallState | CustomCallState;
private call: MatrixCall | null = null;
public state?: CallState;
constructor() {
public constructor() {
super();
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallsChanged, this.setCall);
LegacyCallHandler.instance.addListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
LegacyCallHandlerEvent.SilencedCallsChanged,
this.onSilencedCallsChanged,
);
}
private get invite(): MatrixEvent {
private get invite(): MatrixEvent | undefined {
return [...this.events].find((event) => event.getType() === EventType.CallInvite);
}
private get hangup(): MatrixEvent {
private get hangup(): MatrixEvent | undefined {
return [...this.events].find((event) => event.getType() === EventType.CallHangup);
}
private get reject(): MatrixEvent {
private get reject(): MatrixEvent | undefined {
return [...this.events].find((event) => event.getType() === EventType.CallReject);
}
private get selectAnswer(): MatrixEvent {
private get selectAnswer(): MatrixEvent | undefined {
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
}
public get isVoice(): boolean {
public get isVoice(): boolean | undefined {
const invite = this.invite;
if (!invite) return;
if (!invite) return undefined;
// FIXME: Find a better way to determine this from the event?
if (invite.getContent()?.offer?.sdp?.indexOf('m=video') !== -1) return false;
if (invite.getContent()?.offer?.sdp?.indexOf("m=video") !== -1) return false;
return true;
}
public get hangupReason(): string | null {
return this.hangup?.getContent()?.reason;
return this.call?.hangupReason ?? this.hangup?.getContent()?.reason ?? null;
}
public get rejectParty(): string {
public get rejectParty(): string | undefined {
return this.reject?.getSender();
}
@ -119,16 +117,19 @@ export default class LegacyCallEventGrouper extends EventEmitter {
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());
public get duration(): number | null {
if (!this.hangup?.getDate() || !this.selectAnswer?.getDate()) return null;
return 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());
public get callWasMissed(): boolean {
return (
this.state === CallState.Ended &&
![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.safeGet().getUserId())
);
}
private get callId(): string | undefined {
@ -139,7 +140,7 @@ export default class LegacyCallEventGrouper extends EventEmitter {
return [...this.events][0]?.getRoomId();
}
private onSilencedCallsChanged = () => {
private onSilencedCallsChanged = (): void => {
const newState = LegacyCallHandler.instance.isCallSilenced(this.callId);
this.emit(LegacyCallEventGrouperEvent.SilencedChanged, newState);
};
@ -149,53 +150,63 @@ export default class LegacyCallEventGrouper extends EventEmitter {
};
public answerCall = (): void => {
LegacyCallHandler.instance.answerCall(this.roomId);
const roomId = this.roomId;
if (!roomId) return;
LegacyCallHandler.instance.answerCall(roomId);
};
public rejectCall = (): void => {
LegacyCallHandler.instance.hangupOrReject(this.roomId, true);
const roomId = this.roomId;
if (!roomId) return;
LegacyCallHandler.instance.hangupOrReject(roomId, true);
};
public callBack = (): void => {
LegacyCallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
const roomId = this.roomId;
if (!roomId) return;
LegacyCallHandler.instance.placeCall(roomId, this.isVoice ? CallType.Voice : CallType.Video);
};
public toggleSilenced = () => {
public toggleSilenced = (): void => {
const silenced = LegacyCallHandler.instance.isCallSilenced(this.callId);
silenced ?
LegacyCallHandler.instance.unSilenceCall(this.callId) :
LegacyCallHandler.instance.silenceCall(this.callId);
silenced
? LegacyCallHandler.instance.unSilenceCall(this.callId)
: LegacyCallHandler.instance.silenceCall(this.callId);
};
private setCallListeners() {
private setCallListeners(): void {
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)) {
private setState = (): void => {
if (this.call && CONNECTING_STATES.includes(this.call.state)) {
this.state = CallState.Connecting;
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
} else if (this.call && 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;
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(LegacyCallEventGrouperEvent.StateChanged, this.state);
};
private setCall = () => {
if (this.call) return;
private setCall = (): void => {
const callId = this.callId;
if (!callId || this.call) return;
this.call = LegacyCallHandler.instance.getCallById(this.callId);
this.call = LegacyCallHandler.instance.getCallById(callId);
this.setCallListeners();
this.setState();
};
public add(event: MatrixEvent) {
public add(event: MatrixEvent): void {
if (this.events.has(event)) return; // nothing to do
this.events.add(event);
this.setCall();

View file

@ -14,26 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ClipboardEvent } from 'react';
import { ClientEvent, MatrixClient } from 'matrix-js-sdk/src/client';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync';
import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials';
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { MatrixError } from 'matrix-js-sdk/src/matrix';
import React, { ClipboardEvent } from "react";
import {
ClientEvent,
MatrixClient,
MatrixEvent,
RoomStateEvent,
MatrixError,
IUsageLimit,
SyncStateData,
SyncState,
} from "matrix-js-sdk/src/matrix";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import classNames from "classnames";
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard';
import PageTypes from '../../PageTypes';
import MediaDeviceHandler from '../../MediaDeviceHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import dis from '../../dispatcher/dispatcher';
import { IMatrixClientCreds } from '../../MatrixClientPeg';
import { isOnlyCtrlOrCmdKeyEvent, Key } from "../../Keyboard";
import PageTypes from "../../PageTypes";
import MediaDeviceHandler from "../../MediaDeviceHandler";
import { fixupColorFonts } from "../../utils/FontManager";
import dis from "../../dispatcher/dispatcher";
import { IMatrixClientCreds } from "../../MatrixClientPeg";
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
import ResizeHandle from '../views/elements/ResizeHandle';
import { CollapseDistributor, Resizer } from '../../resizer';
import ResizeHandle from "../views/elements/ResizeHandle";
import { CollapseDistributor, Resizer } from "../../resizer";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg";
@ -41,36 +45,36 @@ import { DefaultTagID } from "../../stores/room-list/models";
import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions";
import LeftPanel from "./LeftPanel";
import PipContainer from '../views/voip/PipContainer';
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
import RoomListStore from "../../stores/room-list/RoomListStore";
import NonUrgentToastContainer from "./NonUrgentToastContainer";
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import Modal from "../../Modal";
import { ICollapseConfig } from "../../resizer/distributors/collapse";
import HostSignupContainer from '../views/host_signup/HostSignupContainer';
import { getKeyBindingsManager } from '../../KeyBindingsManager';
import { CollapseItem, ICollapseConfig } from "../../resizer/distributors/collapse";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import AudioFeedArrayForLegacyCall from '../views/voip/AudioFeedArrayForLegacyCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import AudioFeedArrayForLegacyCall from "../views/voip/AudioFeedArrayForLegacyCall";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import RoomView from './RoomView';
import type { RoomView as RoomViewType } from './RoomView';
import ToastContainer from './ToastContainer';
import RoomView from "./RoomView";
import type { RoomView as RoomViewType } from "./RoomView";
import ToastContainer from "./ToastContainer";
import UserView from "./UserView";
import BackdropPanel from "./BackdropPanel";
import { mediaFromMxc } from "../../customisations/Media";
import { UserTab } from "../views/dialogs/UserTab";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RightPanelStore from '../../stores/right-panel/RightPanelStore';
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
import { IConfigOptions } from "../../IConfigOptions";
import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning';
import { UserOnboardingPage } from '../views/user-onboarding/UserOnboardingPage';
import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning";
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
import { PipContainer } from "./PipContainer";
import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules";
import { ConfigOptions } from "../../SdkConfig";
// 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.
@ -96,17 +100,17 @@ interface IProps {
autoJoin?: boolean;
threepidInvite?: IThreepidInvite;
roomOobData?: IOOBData;
currentRoomId: string;
currentRoomId: string | null;
collapseLhs: boolean;
config: IConfigOptions;
currentUserId?: string;
config: ConfigOptions;
currentUserId: string | null;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
forceTimeline?: boolean; // see props on MatrixChat
}
interface IState {
syncErrorData?: ISyncStateData;
syncErrorData?: SyncStateData;
usageLimitDismissed: boolean;
usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number;
@ -125,24 +129,24 @@ interface IState {
* Components mounted below us can access the matrix client via the react context.
*/
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
public static displayName = "LoggedInView";
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<RoomViewType>;
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
protected layoutWatcherRef: string;
protected compactLayoutWatcherRef: string;
protected backgroundImageWatcherRef: string;
protected resizer: Resizer;
protected layoutWatcherRef?: string;
protected compactLayoutWatcherRef?: string;
protected backgroundImageWatcherRef?: string;
protected resizer?: Resizer<ICollapseConfig, CollapseItem>;
constructor(props, context) {
super(props, context);
public constructor(props: IProps) {
super(props);
this.state = {
syncErrorData: undefined,
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
useCompactLayout: SettingsStore.getValue("useCompactLayout"),
usageLimitDismissed: false,
activeCalls: LegacyCallHandler.instance.getAllActiveCalls(),
};
@ -159,28 +163,30 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizeHandler = React.createRef();
}
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
public componentDidMount(): void {
document.addEventListener("keydown", this.onNativeKeyDown, false);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this.updateServerNoticeEvents();
this._matrixClient.on(ClientEvent.AccountData, this.onAccountData);
// check push rules on start up as well
monitorSyncedPushRules(this._matrixClient.getAccountData("m.push_rules"), this._matrixClient);
this._matrixClient.on(ClientEvent.Sync, this.onSync);
// Call `onSync` with the current state as well
this.onSync(
this._matrixClient.getSyncState(),
null,
this._matrixClient.getSyncStateData(),
);
this.onSync(this._matrixClient.getSyncState(), null, this._matrixClient.getSyncStateData() ?? undefined);
this._matrixClient.on(RoomStateEvent.Events, this.onRoomStateEvents);
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onCompactLayoutChanged);
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
"useCompactLayout",
null,
this.onCompactLayoutChanged,
);
this.backgroundImageWatcherRef = SettingsStore.watchSetting(
"RoomList.backgroundImage", null, this.refreshBackgroundImage,
"RoomList.backgroundImage",
null,
this.refreshBackgroundImage,
);
this.resizer = this.createResizer();
@ -191,17 +197,17 @@ class LoggedInView extends React.Component<IProps, IState> {
this.refreshBackgroundImage();
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
public componentWillUnmount(): void {
document.removeEventListener("keydown", this.onNativeKeyDown, false);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this._matrixClient.removeListener(ClientEvent.AccountData, this.onAccountData);
this._matrixClient.removeListener(ClientEvent.Sync, this.onSync);
this._matrixClient.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage);
SettingsStore.unwatchSetting(this.layoutWatcherRef);
SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
this.resizer.detach();
if (this.layoutWatcherRef) SettingsStore.unwatchSetting(this.layoutWatcherRef);
if (this.compactLayoutWatcherRef) SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
if (this.backgroundImageWatcherRef) SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
this.resizer?.detach();
}
private onCallState = (): void => {
@ -221,16 +227,16 @@ class LoggedInView extends React.Component<IProps, IState> {
this.setState({ backgroundImage });
};
public canResetTimelineInRoom = (roomId: string) => {
public canResetTimelineInRoom = (roomId: string): boolean => {
if (!this._roomView.current) {
return true;
}
return this._roomView.current.canResetTimeline();
};
private createResizer() {
let panelSize;
let panelCollapsed;
private createResizer(): Resizer<ICollapseConfig, CollapseItem> {
let panelSize: number | null;
let panelCollapsed: boolean;
const collapseConfig: ICollapseConfig = {
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
toggleSize: 206 - 50,
@ -238,7 +244,7 @@ class LoggedInView extends React.Component<IProps, IState> {
panelCollapsed = collapsed;
if (collapsed) {
dis.dispatch({ action: "hide_left_panel" });
window.localStorage.setItem("mx_lhs_size", '0');
window.localStorage.setItem("mx_lhs_size", "0");
} else {
dis.dispatch({ action: "show_left_panel" });
}
@ -251,50 +257,51 @@ class LoggedInView extends React.Component<IProps, IState> {
this.props.resizeNotifier.startResizing();
},
onResizeStop: () => {
if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", '' + panelSize);
if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize);
this.props.resizeNotifier.stopResizing();
},
isItemCollapsed: domNode => {
isItemCollapsed: (domNode) => {
return domNode.classList.contains("mx_LeftPanel_minimized");
},
handler: this.resizeHandler.current,
handler: this.resizeHandler.current ?? undefined,
};
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
vertical: "mx_ResizeHandle--vertical",
reverse: "mx_ResizeHandle_reverse",
});
return resizer;
}
private loadResizerPreferences() {
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size"), 10);
private loadResizerPreferences(): void {
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size")!, 10);
if (isNaN(lhsSize)) {
lhsSize = 350;
}
this.resizer.forHandleWithId('lp-resizer').resize(lhsSize);
this.resizer?.forHandleWithId("lp-resizer")?.resize(lhsSize);
}
private onAccountData = (event: MatrixEvent) => {
private onAccountData = (event: MatrixEvent): void => {
if (event.getType() === "m.ignored_user_list") {
dis.dispatch({ action: "ignore_state_changed" });
}
monitorSyncedPushRules(event, this._matrixClient);
};
private onCompactLayoutChanged = () => {
private onCompactLayoutChanged = (): void => {
this.setState({
useCompactLayout: SettingsStore.getValue("useCompactLayout"),
});
};
private onSync = (syncState: SyncState, oldSyncState?: SyncState, data?: ISyncStateData): void => {
private onSync = (syncState: SyncState | null, oldSyncState: SyncState | null, data?: SyncStateData): void => {
const oldErrCode = (this.state.syncErrorData?.error as MatrixError)?.errcode;
const newErrCode = (data?.error as MatrixError)?.errcode;
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
this.setState({
syncErrorData: syncState === SyncState.Error ? data : null,
syncErrorData: syncState === SyncState.Error ? data : undefined,
});
if (oldSyncState === SyncState.Prepared && syncState === SyncState.Syncing) {
@ -306,18 +313,18 @@ class LoggedInView extends React.Component<IProps, IState> {
private onRoomStateEvents = (ev: MatrixEvent): void => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (serverNoticeList?.some(r => r.roomId === ev.getRoomId())) {
if (serverNoticeList?.some((r) => r.roomId === ev.getRoomId())) {
this.updateServerNoticeEvents();
}
};
private onUsageLimitDismissed = () => {
private onUsageLimitDismissed = (): void => {
this.setState({
usageLimitDismissed: true,
});
};
private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) {
private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit): void {
const error = (syncError?.error as MatrixError)?.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
if (error) {
usageLimitEventContent = (syncError?.error as MatrixError).data as IUsageLimit;
@ -337,11 +344,11 @@ class LoggedInView extends React.Component<IProps, IState> {
}
}
private updateServerNoticeEvents = async () => {
private updateServerNoticeEvents = async (): Promise<void> => {
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
if (!serverNoticeList) return [];
if (!serverNoticeList) return;
const events = [];
const events: MatrixEvent[] = [];
let pinnedEventTs = 0;
for (const room of serverNoticeList) {
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
@ -352,23 +359,24 @@ class LoggedInView extends React.Component<IProps, IState> {
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
const event = timeline?.getEvents().find((ev) => ev.getId() === eventId);
if (event) events.push(event);
}
}
if (pinnedEventTs && this.state.usageLimitEventTs > pinnedEventTs) {
if (pinnedEventTs && this.state.usageLimitEventTs && this.state.usageLimitEventTs > pinnedEventTs) {
// We've processed a newer event than this one, so ignore it.
return;
}
const usageLimitEvent = events.find((e) => {
return (
e && e.getType() === 'm.room.message' &&
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
e &&
e.getType() === "m.room.message" &&
e.getContent()["server_notice_type"] === "m.server_notice.usage_limit_reached"
);
});
const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent();
const usageLimitEventContent = usageLimitEvent?.getContent<IUsageLimit>();
this.calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
this.setState({
usageLimitEventContent,
@ -378,7 +386,7 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
private onPaste = (ev: ClipboardEvent) => {
private onPaste = (ev: ClipboardEvent): void => {
const element = ev.target as HTMLElement;
const inputableElement = getInputableElement(element);
if (inputableElement === document.activeElement) return; // nothing to do
@ -386,13 +394,16 @@ class LoggedInView extends React.Component<IProps, IState> {
if (inputableElement?.focus) {
inputableElement.focus();
} else {
const inThread = !!document.activeElement.closest(".mx_ThreadView");
const inThread = !!document.activeElement?.closest(".mx_ThreadView");
// refocusing during a paste event will make the paste end up in the newly focused element,
// so dispatch synchronously before paste happens
dis.dispatch({
action: Action.FocusSendMessageComposer,
context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room,
}, true);
dis.dispatch(
{
action: Action.FocusSendMessageComposer,
context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room,
},
true,
);
}
};
@ -418,13 +429,13 @@ 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.
*/
private onReactKeyDown = (ev) => {
private onReactKeyDown = (ev: React.KeyboardEvent): void => {
// events caught while bubbling up on the root element
// of this component, so something must be focused.
this.onKeyDown(ev);
};
private onNativeKeyDown = (ev) => {
private onNativeKeyDown = (ev: KeyboardEvent): void => {
// only pass this if there is no focused element.
// if there is, onKeyDown will be called by the
// react keydown handler that respects the react bubbling order.
@ -433,7 +444,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
};
private onKeyDown = (ev) => {
private onKeyDown = (ev: React.KeyboardEvent | KeyboardEvent): void => {
let handled = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev);
@ -448,7 +459,7 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
case KeyBindingAction.SearchInRoom:
dis.dispatch({
action: 'focus_search',
action: "focus_search",
});
handled = true;
break;
@ -463,7 +474,7 @@ class LoggedInView extends React.Component<IProps, IState> {
switch (navAction) {
case KeyBindingAction.FilterRooms:
dis.dispatch({
action: 'focus_room_filter',
action: "focus_room_filter",
});
handled = true;
break;
@ -526,11 +537,11 @@ class LoggedInView extends React.Component<IProps, IState> {
});
break;
case KeyBindingAction.PreviousVisitedRoomOrSpace:
PlatformPeg.get().navigateForwardBack(true);
PlatformPeg.get()?.navigateForwardBack(true);
handled = true;
break;
case KeyBindingAction.NextVisitedRoomOrSpace:
PlatformPeg.get().navigateForwardBack(false);
PlatformPeg.get()?.navigateForwardBack(false);
handled = true;
break;
}
@ -542,13 +553,13 @@ class LoggedInView extends React.Component<IProps, IState> {
case KeyBindingAction.ToggleHiddenEventVisibility: {
const hiddenEventVisibility = SettingsStore.getValueAt(
SettingLevel.DEVICE,
'showHiddenEventsInTimeline',
"showHiddenEventsInTimeline",
undefined,
false,
);
SettingsStore.setValue(
'showHiddenEventsInTimeline',
undefined,
"showHiddenEventsInTimeline",
null,
SettingLevel.DEVICE,
!hiddenEventVisibility,
);
@ -560,14 +571,14 @@ class LoggedInView extends React.Component<IProps, IState> {
if (
!handled &&
PlatformPeg.get().overrideBrowserShortcuts() &&
PlatformPeg.get()?.overrideBrowserShortcuts() &&
ev.code.startsWith("Digit") &&
ev.code !== "Digit0" && // this is the shortcut for reset zoom, don't override it
isOnlyCtrlOrCmdKeyEvent(ev)
) {
dis.dispatch<SwitchSpacePayload>({
action: Action.SwitchSpace,
num: ev.code.slice(5), // Cut off the first 5 characters - "Digit"
num: parseInt(ev.code.slice(5), 10), // Cut off the first 5 characters - "Digit"
});
handled = true;
}
@ -582,8 +593,7 @@ class LoggedInView extends React.Component<IProps, IState> {
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);
const isClickShortcut = ev.target !== document.body && (ev.key === Key.SPACE || ev.key === Key.ENTER);
// 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
@ -593,12 +603,15 @@ class LoggedInView extends React.Component<IProps, IState> {
// If the user is entering a printable character outside of an input field
// redirect it to the composer for them.
if (!isClickShortcut && isPrintable && !getInputableElement(ev.target as HTMLElement)) {
const inThread = !!document.activeElement.closest(".mx_ThreadView");
const inThread = !!document.activeElement?.closest(".mx_ThreadView");
// synchronous dispatch so we focus before key generates input
dis.dispatch({
action: Action.FocusSendMessageComposer,
context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room,
}, true);
dis.dispatch(
{
action: Action.FocusSendMessageComposer,
context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room,
},
true,
);
ev.stopPropagation();
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
}
@ -609,27 +622,27 @@ class LoggedInView extends React.Component<IProps, IState> {
* dispatch a page-up/page-down/etc to the appropriate component
* @param {Object} ev The key event
*/
private onScrollKeyPressed = (ev) => {
if (this._roomView.current) {
this._roomView.current.handleScrollKey(ev);
}
private onScrollKeyPressed = (ev: React.KeyboardEvent | KeyboardEvent): void => {
this._roomView.current?.handleScrollKey(ev);
};
render() {
public render(): React.ReactNode {
let pageElement;
switch (this.props.page_type) {
case PageTypes.RoomView:
pageElement = <RoomView
ref={this._roomView}
onRegistered={this.props.onRegistered}
threepidInvite={this.props.threepidInvite}
oobData={this.props.roomOobData}
key={this.props.currentRoomId || 'roomview'}
resizeNotifier={this.props.resizeNotifier}
justCreatedOpts={this.props.roomJustCreatedOpts}
forceTimeline={this.props.forceTimeline}
/>;
pageElement = (
<RoomView
ref={this._roomView}
onRegistered={this.props.onRegistered}
threepidInvite={this.props.threepidInvite}
oobData={this.props.roomOobData}
key={this.props.currentRoomId || "roomview"}
resizeNotifier={this.props.resizeNotifier}
justCreatedOpts={this.props.roomJustCreatedOpts}
forceTimeline={this.props.forceTimeline}
/>
);
break;
case PageTypes.HomePage:
@ -637,23 +650,25 @@ class LoggedInView extends React.Component<IProps, IState> {
break;
case PageTypes.UserView:
pageElement = <UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />;
if (!!this.props.currentUserId) {
pageElement = (
<UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />
);
}
break;
}
const wrapperClasses = classNames({
'mx_MatrixChat_wrapper': true,
'mx_MatrixChat_useCompactLayout': this.state.useCompactLayout,
mx_MatrixChat_wrapper: true,
mx_MatrixChat_useCompactLayout: this.state.useCompactLayout,
});
const bodyClasses = classNames({
'mx_MatrixChat': true,
'mx_MatrixChat--with-avatar': this.state.backgroundImage,
"mx_MatrixChat": true,
"mx_MatrixChat--with-avatar": this.state.backgroundImage,
});
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForLegacyCall call={call} key={call.callId} />
);
return <AudioFeedArrayForLegacyCall call={call} key={call.callId} />;
});
return (
@ -666,17 +681,12 @@ class LoggedInView extends React.Component<IProps, IState> {
>
<ToastContainer />
<div className={bodyClasses}>
<div className='mx_LeftPanel_outerWrapper'>
<div className="mx_LeftPanel_outerWrapper">
<LeftPanelLiveShareWarning isMinimized={this.props.collapseLhs || false} />
<nav className='mx_LeftPanel_wrapper'>
<BackdropPanel
blurMultiplier={0.5}
backgroundImage={this.state.backgroundImage}
/>
<div className="mx_LeftPanel_wrapper">
<BackdropPanel blurMultiplier={0.5} backgroundImage={this.state.backgroundImage} />
<SpacePanel />
<BackdropPanel
backgroundImage={this.state.backgroundImage}
/>
<BackdropPanel backgroundImage={this.state.backgroundImage} />
<div
className="mx_LeftPanel_wrapper--user"
ref={this._resizeContainer}
@ -688,18 +698,15 @@ class LoggedInView extends React.Component<IProps, IState> {
resizeNotifier={this.props.resizeNotifier}
/>
</div>
</nav>
</div>
</div>
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
<div className="mx_RoomView_wrapper">
{ pageElement }
</div>
<div className="mx_RoomView_wrapper">{pageElement}</div>
</div>
</div>
<PipContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
{ audioFeedArraysForCalls }
{audioFeedArraysForCalls}
</MatrixClientContext.Provider>
);
}

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { NumberSize, Resizable } from 're-resizable';
import React, { ReactNode } from "react";
import { NumberSize, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import ResizeNotifier from "../../utils/ResizeNotifier";
@ -25,9 +25,25 @@ interface IProps {
resizeNotifier: ResizeNotifier;
collapsedRhs?: boolean;
panel?: JSX.Element;
children: ReactNode;
/**
* A unique identifier for this panel split.
*
* This is appended to the key used to store the panel size in localStorage, allowing the widths of different
* panels to be stored.
*/
sizeKey?: string;
/**
* The size to use for the panel component if one isn't persisted in storage. Defaults to 350.
*/
defaultSize: number;
}
export default class MainSplit extends React.Component<IProps> {
public static defaultProps = {
defaultSize: 350,
};
private onResizeStart = (): void => {
this.props.resizeNotifier.startResizing();
};
@ -36,18 +52,32 @@ export default class MainSplit extends React.Component<IProps> {
this.props.resizeNotifier.notifyRightHandleResized();
};
private get sizeSettingStorageKey(): string {
let key = "mx_rhs_size";
if (!!this.props.sizeKey) {
key += `_${this.props.sizeKey}`;
}
return key;
}
private onResizeStop = (
event: MouseEvent | TouchEvent, direction: Direction, elementRef: HTMLElement, delta: NumberSize,
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).toString());
window.localStorage.setItem(
this.sizeSettingStorageKey,
(this.loadSidePanelSize().width + delta.width).toString(),
);
};
private loadSidePanelSize(): {height: string | number, width: number} {
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
private loadSidePanelSize(): { height: string | number; width: number } {
let rhsSize = parseInt(window.localStorage.getItem(this.sizeSettingStorageKey)!, 10);
if (isNaN(rhsSize)) {
rhsSize = 350;
rhsSize = this.props.defaultSize;
}
return {
@ -56,7 +86,7 @@ export default class MainSplit extends React.Component<IProps> {
};
}
public render(): JSX.Element {
public render(): React.ReactNode {
const bodyView = React.Children.only(this.props.children);
const panelView = this.props.panel;
@ -64,33 +94,38 @@ export default class MainSplit extends React.Component<IProps> {
let children;
if (hasResizer) {
children = <Resizable
defaultSize={this.loadSidePanelSize()}
minWidth={264}
maxWidth="50%"
enable={{
top: false,
right: false,
bottom: false,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
onResizeStart={this.onResizeStart}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
className="mx_RightPanel_ResizeWrapper"
handleClasses={{ left: "mx_ResizeHandle_horizontal" }}
>
{ panelView }
</Resizable>;
children = (
<Resizable
key={this.props.sizeKey}
defaultSize={this.loadSidePanelSize()}
minWidth={264}
maxWidth="50%"
enable={{
top: false,
right: false,
bottom: false,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
onResizeStart={this.onResizeStart}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
className="mx_RightPanel_ResizeWrapper"
handleClasses={{ left: "mx_ResizeHandle--horizontal" }}
>
{panelView}
</Resizable>
);
}
return <div className="mx_MainSplit">
{ bodyView }
{ children }
</div>;
return (
<div className="mx_MainSplit">
{bodyView}
{children}
</div>
);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -20,16 +20,15 @@ import { ComponentClass } from "../../@types/common";
import NonUrgentToastStore from "../../stores/NonUrgentToastStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
interface IProps {
}
interface IProps {}
interface IState {
toasts: ComponentClass[];
}
export default class NonUrgentToastContainer extends React.PureComponent<IProps, IState> {
public constructor(props, context) {
super(props, context);
public constructor(props: IProps) {
super(props);
this.state = {
toasts: NonUrgentToastStore.instance.components,
@ -38,26 +37,26 @@ export default class NonUrgentToastContainer extends React.PureComponent<IProps,
NonUrgentToastStore.instance.on(UPDATE_EVENT, this.onUpdateToasts);
}
public componentWillUnmount() {
public componentWillUnmount(): void {
NonUrgentToastStore.instance.off(UPDATE_EVENT, this.onUpdateToasts);
}
private onUpdateToasts = () => {
private onUpdateToasts = (): void => {
this.setState({ toasts: NonUrgentToastStore.instance.components });
};
public render() {
public render(): React.ReactNode {
const toasts = this.state.toasts.map((t, i) => {
return (
<div className="mx_NonUrgentToastContainer_toast" key={`toast-${i}`}>
{ React.createElement(t, {}) }
{React.createElement(t, {})}
</div>
);
});
return (
<div className="mx_NonUrgentToastContainer" role="alert">
{ toasts }
{toasts}
</div>
);
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../languageHandler';
import { _t } from "../../languageHandler";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseCard from "../views/right_panel/BaseCard";
import TimelinePanel from "./TimelinePanel";
@ -25,6 +25,7 @@ import Spinner from "../views/elements/Spinner";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import Measured from "../views/elements/Measured";
import Heading from "../views/typography/Heading";
interface IProps {
onClose(): void;
@ -38,11 +39,11 @@ interface IState {
* Component which shows the global notification list using a TimelinePanel
*/
export default class NotificationPanel extends React.PureComponent<IProps, IState> {
static contextType = RoomContext;
public static contextType = RoomContext;
private card = React.createRef<HTMLDivElement>();
constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -54,14 +55,16 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
this.setState({ narrow });
};
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>
</div>);
public render(): React.ReactNode {
const emptyState = (
<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t("notif_panel|empty_heading")}</h2>
<p>{_t("notif_panel|empty_description")}</p>
</div>
);
let content;
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
let content: JSX.Element;
const timelineSet = MatrixClientPeg.safeGet().getNotifTimelineSet();
if (timelineSet) {
// wrap a TimelinePanel with the jump-to-event bits turned off.
content = (
@ -80,18 +83,34 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
content = <Spinner />;
}
return <RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Notification,
narrow: this.state.narrow,
}}>
<BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
<Measured
sensor={this.card.current}
onMeasurement={this.onMeasurement}
/>
{ content }
</BaseCard>
</RoomContext.Provider>;
return (
<RoomContext.Provider
value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Notification,
narrow: this.state.narrow,
}}
>
<BaseCard
header={
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("notifications|enable_prompt_toast_title")}
</Heading>
</div>
}
/**
* Need to rename this CSS class to something more generic
* Will be done once all the panels are using a similar layout
*/
className="mx_ThreadPanel"
onClose={this.props.onClose}
withoutScrollContainer={true}
>
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
{content}
</BaseCard>
</RoomContext.Provider>
);
}
}

View file

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import React, { createRef } from "react";
import UIStore, { UI_EVENTS } from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution';
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import { lerp } from "../../utils/AnimationUtils";
import { MarkedExecution } from "../../utils/MarkedExecution";
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
@ -33,14 +33,21 @@ const PADDING = {
right: 8,
};
/**
* The type of a callback which will create the pip content children.
*/
export type CreatePipChildren = (options: IChildrenOptions) => JSX.Element;
interface IChildrenOptions {
// a callback which is called when a mouse event (most likely mouse down) occurs at start of moving the pip around
onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void;
// a callback which is called when the content fo the pip changes in a way that is likely to cause a resize
onResize: (event: Event) => void;
}
interface IProps {
className?: string;
children: ({ onStartMoving, onResize }: IChildrenOptions) => React.ReactNode;
children: Array<CreatePipChildren>;
draggable: boolean;
onDoubleClick?: () => void;
onMove?: () => void;
@ -58,25 +65,41 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
private translationX = this.desiredTranslationX;
private translationY = this.desiredTranslationY;
private moving = false;
private scheduledUpdate = new MarkedExecution(
private mouseHeld = false;
private scheduledUpdate: MarkedExecution = new MarkedExecution(
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
private startingPositionX = 0;
private startingPositionY = 0;
public componentDidMount() {
private _moving = false;
public get moving(): boolean {
return this._moving;
}
private set moving(value: boolean) {
this._moving = value;
}
public componentDidMount(): void {
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
UIStore.instance.on(UI_EVENTS.Resize, this.onResize);
// correctly position the PiP
this.snap();
}
public componentWillUnmount() {
public componentWillUnmount(): void {
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
UIStore.instance.off(UI_EVENTS.Resize, this.onResize);
}
private animationCallback = () => {
public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (prevProps.children !== this.props.children) this.snap(true);
}
private animationCallback = (): void => {
if (
!this.moving &&
Math.abs(this.translationX - this.desiredTranslationX) <= 1 &&
@ -98,14 +121,13 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
this.props.onMove?.();
};
private setStyle = () => {
private setStyle = (): void => {
if (!this.callViewWrapper.current) return;
// Set the element's style directly, bypassing React for efficiency
this.callViewWrapper.current.style.transform =
`translateX(${this.translationX}px) translateY(${this.translationY}px)`;
this.callViewWrapper.current.style.transform = `translateX(${this.translationX}px) translateY(${this.translationY}px)`;
};
private setTranslation(inTranslationX: number, inTranslationY: number) {
private setTranslation(inTranslationX: number, inTranslationY: number): void {
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
@ -132,20 +154,16 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
this.snap(false);
};
private snap = (animate = false) => {
private snap = (animate = false): void => {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
// the position to snap to from the PiP center and not its top-left
// corner
const windowWidth = (
UIStore.instance.windowWidth -
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
);
const windowHeight = (
UIStore.instance.windowHeight -
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
);
const windowWidth =
UIStore.instance.windowWidth - (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH);
const windowHeight =
UIStore.instance.windowHeight - (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT);
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
@ -171,46 +189,83 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
this.scheduledUpdate.mark();
};
private onStartMoving = (event: React.MouseEvent | MouseEvent) => {
private onStartMoving = (event: React.MouseEvent | MouseEvent): void => {
event.preventDefault();
event.stopPropagation();
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
this.mouseHeld = true;
this.startingPositionX = event.clientX;
this.startingPositionY = event.clientY;
};
private onMoving = (event: React.MouseEvent | MouseEvent) => {
if (!this.moving) return;
private onMoving = (event: MouseEvent): void => {
if (!this.mouseHeld) return;
if (
Math.abs(this.startingPositionX - event.clientX) < 5 &&
Math.abs(this.startingPositionY - event.clientY) < 5
) {
// User needs to move the widget by at least five pixels.
// Improves click detection when using a touchpad or with nervous hands.
return;
}
event.preventDefault();
event.stopPropagation();
if (!this.moving) {
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
}
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
};
private onEndMoving = () => {
this.moving = false;
private onEndMoving = (event: MouseEvent): void => {
if (!this.mouseHeld) return;
event.preventDefault();
event.stopPropagation();
this.mouseHeld = false;
// Delaying this to the next event loop tick is necessary for click
// event cancellation to work
setImmediate(() => (this.moving = false));
this.snap(true);
};
public render() {
private onClickCapture = (event: React.MouseEvent): void => {
// To prevent mouse up events during dragging from being double-counted
// as clicks, we cancel clicks before they ever reach the target
if (this.moving) {
event.preventDefault();
event.stopPropagation();
}
};
public render(): React.ReactNode {
const style = {
transform: `translateX(${this.translationX}px) translateY(${this.translationY}px)`,
};
const children = this.props.children.map((create: CreatePipChildren) => {
return create({
onStartMoving: this.onStartMoving,
onResize: this.onResize,
});
});
return (
<aside
className={this.props.className}
style={style}
ref={this.callViewWrapper}
onClickCapture={this.onClickCapture}
onDoubleClick={this.props.onDoubleClick}
>
{ this.props.children({
onStartMoving: this.onStartMoving,
onResize: this.onResize,
}) }
{children}
</aside>
);
}

View file

@ -14,34 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, useState } from 'react';
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React, { MutableRefObject, ReactNode, useContext, useRef } from "react";
import { CallEvent, CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { logger } from "matrix-js-sdk/src/logger";
import classNames from 'classnames';
import { Room } from "matrix-js-sdk/src/models/room";
import { Optional } from "matrix-events-sdk";
import LegacyCallView from "./LegacyCallView";
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler';
import PersistentApp from "../elements/PersistentApp";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import PictureInPictureDragger from './PictureInPictureDragger';
import dis from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import { Container, WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
import LegacyCallViewHeader from './LegacyCallView/LegacyCallViewHeader';
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore';
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { UPDATE_EVENT } from '../../../stores/AsyncStore';
import { SdkContextClass } from '../../../contexts/SDKContext';
import { CallStore } from "../../../stores/CallStore";
import LegacyCallView from "../views/voip/LegacyCallView";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import PictureInPictureDragger, { CreatePipChildren } from "./PictureInPictureDragger";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWidgetStore";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { SDKContext, SdkContextClass } from "../../contexts/SDKContext";
import {
useCurrentVoiceBroadcastPreRecording,
useCurrentVoiceBroadcastRecording,
VoiceBroadcastPlayback,
VoiceBroadcastPlaybackBody,
VoiceBroadcastPreRecording,
VoiceBroadcastPreRecordingPip,
VoiceBroadcastRecording,
VoiceBroadcastRecordingPip,
VoiceBroadcastRecordingsStore,
VoiceBroadcastRecordingsStoreEvent,
} from '../../../voice-broadcast';
import { useTypedEventEmitter } from '../../../hooks/useEventEmitter';
VoiceBroadcastSmallPlaybackBody,
} from "../../voice-broadcast";
import { useCurrentVoiceBroadcastPlayback } from "../../voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback";
import { WidgetPip } from "../views/pips/WidgetPip";
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@ -53,45 +54,38 @@ const SHOW_CALL_IN_STATES = [
];
interface IProps {
voiceBroadcastRecording?: VoiceBroadcastRecording;
voiceBroadcastRecording: Optional<VoiceBroadcastRecording>;
voiceBroadcastPreRecording: Optional<VoiceBroadcastPreRecording>;
voiceBroadcastPlayback: Optional<VoiceBroadcastPlayback>;
movePersistedElement: MutableRefObject<(() => void) | undefined>;
}
interface IState {
viewedRoomId: string;
viewedRoomId?: string;
// The main call that we are displaying (ie. not including the call in the room being viewed, if any)
primaryCall: MatrixCall;
primaryCall: MatrixCall | null;
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
// they belong to
secondaryCall: MatrixCall;
// widget candidate to be displayed in the pip view.
persistentWidgetId: string;
persistentRoomId: string;
persistentWidgetId: string | null;
persistentRoomId: string | null;
showWidgetInPip: boolean;
moving: boolean;
}
const getRoomAndAppForWidget = (widgetId: string, roomId: string): [Room, IApp] => {
if (!widgetId) return;
if (!roomId) return;
const room = MatrixClientPeg.get().getRoom(roomId);
const app = WidgetStore.instance.getApps(roomId).find((app) => app.id === widgetId);
return [room, app];
};
// Splits a list of calls into one 'primary' one and a list
// (which should be a single element) of other calls.
// The primary will be the one not on hold, or an arbitrary one
// if they're all on hold)
function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall[]] {
function getPrimarySecondaryCallsForPip(roomId: Optional<string>): [MatrixCall | null, MatrixCall[]] {
if (!roomId) return [null, []];
const calls = LegacyCallHandler.instance.getAllActiveCallsForPip(roomId);
let primary: MatrixCall = null;
let primary: MatrixCall | null = null;
let secondaries: MatrixCall[] = [];
for (const call of calls) {
@ -118,15 +112,13 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall
}
/**
* PipView shows a small version of the LegacyCallView or a sticky widget hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing
* PipContainer shows a small version of the LegacyCallView or a sticky widget hovering over the UI in
* 'picture-in-picture' (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing
* and all widgets that are active but not shown in any other possible container.
*/
class PipView extends React.Component<IProps, IState> {
private movePersistedElement = createRef<() => void>();
constructor(props: IProps) {
class PipContainerInner extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
@ -134,9 +126,8 @@ class PipView extends React.Component<IProps, IState> {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId);
this.state = {
moving: false,
viewedRoomId: roomId,
primaryCall: primaryCall,
viewedRoomId: roomId || undefined,
primaryCall: primaryCall || null,
secondaryCall: secondaryCalls[0],
persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(),
persistentRoomId: ActiveWidgetStore.instance.getPersistentRoomId(),
@ -144,22 +135,21 @@ class PipView extends React.Component<IProps, IState> {
};
}
public componentDidMount() {
public componentDidMount(): void {
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId);
MatrixClientPeg.safeGet().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
const room = MatrixClientPeg.safeGet().getRoom(this.state.viewedRoomId);
if (room) {
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
}
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges);
document.addEventListener("mouseup", this.onEndMoving.bind(this));
}
public componentWillUnmount() {
public componentWillUnmount(): void {
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
const cli = MatrixClientPeg.get();
@ -172,20 +162,11 @@ class PipView extends React.Component<IProps, IState> {
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges);
document.removeEventListener("mouseup", this.onEndMoving.bind(this));
}
private onStartMoving() {
this.setState({ moving: true });
}
private onMove = (): void => this.props.movePersistedElement.current?.();
private onEndMoving() {
this.setState({ moving: false });
}
private onMove = () => this.movePersistedElement.current?.();
private onRoomViewStoreUpdate = () => {
private onRoomViewStoreUpdate = (): void => {
const newRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
const oldRoomId = this.state.viewedRoomId;
if (newRoomId === oldRoomId) return;
@ -195,7 +176,7 @@ class PipView extends React.Component<IProps, IState> {
if (oldRoom) {
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(oldRoom), this.updateCalls);
}
const newRoom = MatrixClientPeg.get()?.getRoom(newRoomId);
const newRoom = MatrixClientPeg.get()?.getRoom(newRoomId || undefined);
if (newRoom) {
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(newRoom), this.updateCalls);
}
@ -211,10 +192,7 @@ class PipView extends React.Component<IProps, IState> {
};
private onWidgetPersistence = (): void => {
this.updateShowWidgetInPip(
ActiveWidgetStore.instance.getPersistentWidgetId(),
ActiveWidgetStore.instance.getPersistentRoomId(),
);
this.updateShowWidgetInPip();
};
private onWidgetDockChanges = (): void => {
@ -232,7 +210,7 @@ class PipView extends React.Component<IProps, IState> {
this.updateShowWidgetInPip();
};
private onCallRemoteHold = () => {
private onCallRemoteHold = (): void => {
if (!this.state.viewedRoomId) return;
const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.state.viewedRoomId);
@ -247,62 +225,21 @@ class PipView extends React.Component<IProps, IState> {
if (callRoomId ?? this.state.persistentRoomId) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: callRoomId ?? this.state.persistentRoomId,
room_id: callRoomId ?? this.state.persistentRoomId ?? undefined,
metricsTrigger: "WebFloatingCallWindow",
});
}
};
private onMaximize = (): void => {
const widgetId = this.state.persistentWidgetId;
const roomId = this.state.persistentRoomId;
public updateShowWidgetInPip(): void {
const persistentWidgetId = ActiveWidgetStore.instance.getPersistentWidgetId();
const persistentRoomId = ActiveWidgetStore.instance.getPersistentRoomId();
if (this.state.showWidgetInPip && widgetId && roomId) {
const [room, app] = getRoomAndAppForWidget(widgetId, roomId);
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center);
} else {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
});
}
};
private onPin = (): void => {
if (!this.state.showWidgetInPip) return;
const [room, app] = getRoomAndAppForWidget(this.state.persistentWidgetId, this.state.persistentRoomId);
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top);
};
private onExpand = (): void => {
const widgetId = this.state.persistentWidgetId;
if (!widgetId || !this.state.showWidgetInPip) return;
dis.dispatch({
action: Action.ViewRoom,
room_id: this.state.persistentRoomId,
});
};
private onViewCall = (): void =>
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.state.persistentRoomId,
view_call: true,
metricsTrigger: undefined,
});
// Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId
public updateShowWidgetInPip(
persistentWidgetId = this.state.persistentWidgetId,
persistentRoomId = this.state.persistentRoomId,
) {
let fromAnotherRoom = false;
let notDocked = false;
// Sanity check the room - the widget may have been destroyed between render cycles, and
// thus no room is associated anymore.
if (persistentWidgetId && MatrixClientPeg.get().getRoom(persistentRoomId)) {
if (persistentWidgetId && persistentRoomId && MatrixClientPeg.safeGet().getRoom(persistentRoomId)) {
notDocked = !ActiveWidgetStore.instance.isDocked(persistentWidgetId, persistentRoomId);
fromAnotherRoom = this.state.viewedRoomId !== persistentRoomId;
}
@ -316,94 +253,117 @@ class PipView extends React.Component<IProps, IState> {
this.setState({ showWidgetInPip, persistentWidgetId, persistentRoomId });
}
public render() {
private createVoiceBroadcastPlaybackPipContent(voiceBroadcastPlayback: VoiceBroadcastPlayback): CreatePipChildren {
const content =
this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId() ? (
<VoiceBroadcastPlaybackBody playback={voiceBroadcastPlayback} pip={true} />
) : (
<VoiceBroadcastSmallPlaybackBody playback={voiceBroadcastPlayback} />
);
return ({ onStartMoving }) => (
<div key={`vb-playback-${voiceBroadcastPlayback.infoEvent.getId()}`} onMouseDown={onStartMoving}>
{content}
</div>
);
}
private createVoiceBroadcastPreRecordingPipContent(
voiceBroadcastPreRecording: VoiceBroadcastPreRecording,
): CreatePipChildren {
return ({ onStartMoving }) => (
<div key="vb-pre-recording" onMouseDown={onStartMoving}>
<VoiceBroadcastPreRecordingPip voiceBroadcastPreRecording={voiceBroadcastPreRecording} />
</div>
);
}
private createVoiceBroadcastRecordingPipContent(
voiceBroadcastRecording: VoiceBroadcastRecording,
): CreatePipChildren {
return ({ onStartMoving }) => (
<div key={`vb-recording-${voiceBroadcastRecording.infoEvent.getId()}`} onMouseDown={onStartMoving}>
<VoiceBroadcastRecordingPip recording={voiceBroadcastRecording} />
</div>
);
}
public render(): ReactNode {
const pipMode = true;
let pipContent;
let pipContent: Array<CreatePipChildren> = [];
if (this.props.voiceBroadcastRecording) {
pipContent = [this.createVoiceBroadcastRecordingPipContent(this.props.voiceBroadcastRecording)];
} else if (this.props.voiceBroadcastPreRecording) {
pipContent = [this.createVoiceBroadcastPreRecordingPipContent(this.props.voiceBroadcastPreRecording)];
} else if (this.props.voiceBroadcastPlayback) {
pipContent = [this.createVoiceBroadcastPlaybackPipContent(this.props.voiceBroadcastPlayback)];
}
if (this.state.primaryCall) {
pipContent = ({ onStartMoving, onResize }) =>
// get a ref to call inside the current scope
const call = this.state.primaryCall;
pipContent.push(({ onStartMoving, onResize }) => (
<LegacyCallView
key="call-view"
onMouseDownOnHeader={onStartMoving}
call={this.state.primaryCall}
call={call}
secondaryCall={this.state.secondaryCall}
pipMode={pipMode}
onResize={onResize}
/>;
}
if (this.state.showWidgetInPip) {
const pipViewClasses = classNames({
mx_LegacyCallView: true,
mx_LegacyCallView_pip: pipMode,
mx_LegacyCallView_large: !pipMode,
});
const roomId = this.state.persistentRoomId;
const roomForWidget = MatrixClientPeg.get().getRoom(roomId)!;
const viewingCallRoom = this.state.viewedRoomId === roomId;
const isCall = CallStore.instance.getActiveCall(roomId) !== null;
pipContent = ({ onStartMoving }) =>
<div className={pipViewClasses}>
<LegacyCallViewHeader
onPipMouseDown={(event) => { onStartMoving(event); this.onStartMoving.bind(this)(); }}
pipMode={pipMode}
callRooms={[roomForWidget]}
onExpand={!isCall && !viewingCallRoom ? this.onExpand : undefined}
onPin={!isCall && viewingCallRoom ? this.onPin : undefined}
onMaximize={isCall ? this.onViewCall : viewingCallRoom ? this.onMaximize : undefined}
/>
<PersistentApp
persistentWidgetId={this.state.persistentWidgetId}
persistentRoomId={roomId}
pointerEvents={this.state.moving ? 'none' : undefined}
movePersistedElement={this.movePersistedElement}
/>
</div>;
}
if (this.props.voiceBroadcastRecording) {
pipContent = ({ onStartMoving }) => <div onMouseDown={onStartMoving}>
<VoiceBroadcastRecordingPip
recording={this.props.voiceBroadcastRecording}
/>
</div>;
));
}
if (!!pipContent) {
return <PictureInPictureDragger
className="mx_LegacyCallPreview"
draggable={pipMode}
onDoubleClick={this.onDoubleClick}
onMove={this.onMove}
>
{ pipContent }
</PictureInPictureDragger>;
if (this.state.showWidgetInPip && this.state.persistentWidgetId) {
pipContent.push(({ onStartMoving }) => (
<WidgetPip
key="widget-pip"
widgetId={this.state.persistentWidgetId!}
room={MatrixClientPeg.safeGet().getRoom(this.state.persistentRoomId ?? undefined)!}
viewingRoom={this.state.viewedRoomId === this.state.persistentRoomId}
onStartMoving={onStartMoving}
movePersistedElement={this.props.movePersistedElement}
/>
));
}
if (pipContent.length) {
return (
<PictureInPictureDragger
className="mx_LegacyCallPreview"
draggable={pipMode}
onDoubleClick={this.onDoubleClick}
onMove={this.onMove}
>
{pipContent}
</PictureInPictureDragger>
);
}
return null;
}
}
const PipViewHOC: React.FC<IProps> = (props) => {
// TODO Michael W: extract to custom hook
export const PipContainer: React.FC = () => {
const sdkContext = useContext(SDKContext);
const voiceBroadcastPreRecordingStore = sdkContext.voiceBroadcastPreRecordingStore;
const { currentVoiceBroadcastPreRecording } = useCurrentVoiceBroadcastPreRecording(voiceBroadcastPreRecordingStore);
const voiceBroadcastRecordingsStore = VoiceBroadcastRecordingsStore.instance();
const [voiceBroadcastRecording, setVoiceBroadcastRecording] = useState(
voiceBroadcastRecordingsStore.getCurrent(),
const voiceBroadcastRecordingsStore = sdkContext.voiceBroadcastRecordingsStore;
const { currentVoiceBroadcastRecording } = useCurrentVoiceBroadcastRecording(voiceBroadcastRecordingsStore);
const voiceBroadcastPlaybacksStore = sdkContext.voiceBroadcastPlaybacksStore;
const { currentVoiceBroadcastPlayback } = useCurrentVoiceBroadcastPlayback(voiceBroadcastPlaybacksStore);
const movePersistedElement = useRef<() => void>();
return (
<PipContainerInner
voiceBroadcastPlayback={currentVoiceBroadcastPlayback}
voiceBroadcastPreRecording={currentVoiceBroadcastPreRecording}
voiceBroadcastRecording={currentVoiceBroadcastRecording}
movePersistedElement={movePersistedElement}
/>
);
useTypedEventEmitter(
voiceBroadcastRecordingsStore,
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
(recording: VoiceBroadcastRecording) => {
setVoiceBroadcastRecording(recording);
},
);
return <PipView
voiceBroadcastRecording={voiceBroadcastRecording}
{...props}
/>;
};
export default PipViewHOC;

View file

@ -15,15 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomState, RoomStateEvent } 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 { throttle } from 'lodash';
import React from "react";
import { Room, RoomState, RoomStateEvent, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { throttle } from "lodash";
import dis from '../../dispatcher/dispatcher';
import { RightPanelPhases } from '../../stores/right-panel/RightPanelStorePhases';
import dis from "../../dispatcher/dispatcher";
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
@ -38,32 +35,44 @@ import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { E2EStatus } from '../../utils/ShieldUtils';
import TimelineCard from '../views/right_panel/TimelineCard';
import { UPDATE_EVENT } from '../../stores/AsyncStore';
import { IRightPanelCard, IRightPanelCardState } from '../../stores/right-panel/RightPanelStoreIPanelState';
import { Action } from '../../dispatcher/actions';
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import { E2EStatus } from "../../utils/ShieldUtils";
import TimelineCard from "../views/right_panel/TimelineCard";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/RightPanelStoreIPanelState";
import { Action } from "../../dispatcher/actions";
import { XOR } from "../../@types/common";
interface IProps {
room?: Room; // if showing panels for a given room, this is set
interface BaseProps {
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
resizeNotifier: ResizeNotifier;
permalinkCreator?: RoomPermalinkCreator;
e2eStatus?: E2EStatus;
}
interface RoomlessProps extends BaseProps {
room?: undefined;
permalinkCreator?: undefined;
}
interface RoomProps extends BaseProps {
room: Room;
permalinkCreator: RoomPermalinkCreator;
onSearchClick?: () => void;
}
type Props = XOR<RoomlessProps, RoomProps>;
interface IState {
phase?: RightPanelPhases;
searchQuery: string;
cardState?: IRightPanelCardState;
}
export default class RightPanel extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
export default class RightPanel extends React.Component<Props, IState> {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
constructor(props, context) {
public constructor(props: Props, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
@ -71,9 +80,13 @@ export default class RightPanel extends React.Component<IProps, IState> {
};
}
private readonly delayedUpdate = throttle((): void => {
this.forceUpdate();
}, 500, { leading: true, trailing: true });
private readonly delayedUpdate = throttle(
(): void => {
this.forceUpdate();
},
500,
{ leading: true, trailing: true },
);
public componentDidMount(): void {
this.context.on(RoomStateEvent.Members, this.onRoomStateMember);
@ -85,39 +98,40 @@ export default class RightPanel extends React.Component<IProps, IState> {
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
}
public static getDerivedStateFromProps(props: IProps): Partial<IState> {
let currentCard: IRightPanelCard;
public static getDerivedStateFromProps(props: Props): Partial<IState> {
let currentCard: IRightPanelCard | undefined;
if (props.room) {
currentCard = RightPanelStore.instance.currentCardForRoom(props.room.roomId);
}
return {
cardState: currentCard?.state,
phase: currentCard?.phase,
phase: currentCard?.phase ?? undefined,
};
}
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => {
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember): void => {
if (!this.props.room || member.roomId !== this.props.room.roomId) {
return;
}
// redraw the badge on the membership list
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) {
if (this.state.phase === RightPanelPhases.RoomMemberList) {
this.delayedUpdate();
} else if (
this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId &&
member.userId === this.state.cardState.member.userId
this.state.phase === RightPanelPhases.RoomMemberInfo &&
member.userId === this.state.cardState?.member?.userId
) {
// refresh the member info (e.g. new power level)
this.delayedUpdate();
}
};
private onRightPanelStoreUpdate = () => {
this.setState({ ...RightPanel.getDerivedStateFromProps(this.props) as IState });
private onRightPanelStoreUpdate = (): void => {
this.setState({ ...(RightPanel.getDerivedStateFromProps(this.props) as IState) });
};
private onClose = () => {
private onClose = (): void => {
// 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.
@ -131,12 +145,12 @@ export default class RightPanel extends React.Component<IProps, IState> {
});
} else if (
this.state.phase === RightPanelPhases.EncryptionPanel &&
this.state.cardState.verificationRequest?.pending
this.state.cardState?.verificationRequest?.pending
) {
// When the user clicks close on the encryption panel cancel the pending request first if any
this.state.cardState.verificationRequest.cancel();
} else {
RightPanelStore.instance.togglePanel(this.props.room?.roomId);
RightPanelStore.instance.togglePanel(this.props.room?.roomId ?? null);
}
};
@ -144,53 +158,65 @@ export default class RightPanel extends React.Component<IProps, IState> {
this.setState({ searchQuery });
};
public render(): JSX.Element {
public render(): React.ReactNode {
let card = <div />;
const roomId = this.props.room?.roomId;
const phase = this.props.overwriteCard?.phase ?? this.state.phase;
const cardState = this.props.overwriteCard?.state ?? this.state.cardState;
switch (phase) {
case RightPanelPhases.RoomMemberList:
if (roomId) {
card = <MemberList
roomId={roomId}
key={roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>;
if (!!roomId) {
card = (
<MemberList
roomId={roomId}
key={roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>
);
}
break;
case RightPanelPhases.SpaceMemberList:
card = <MemberList
roomId={cardState.spaceId ? cardState.spaceId : roomId}
key={cardState.spaceId ? cardState.spaceId : roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>;
if (!!cardState?.spaceId || !!roomId) {
card = (
<MemberList
roomId={cardState?.spaceId ?? roomId!}
key={cardState?.spaceId ?? roomId!}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>
);
}
break;
case RightPanelPhases.RoomMemberInfo:
case RightPanelPhases.SpaceMemberInfo:
case RightPanelPhases.EncryptionPanel: {
const roomMember = cardState.member instanceof RoomMember
? cardState.member
: undefined;
card = <UserInfo
user={cardState.member}
room={this.context.getRoom(roomMember?.roomId) ?? this.props.room}
key={roomId || cardState.member.userId}
onClose={this.onClose}
phase={phase}
verificationRequest={cardState.verificationRequest}
verificationRequestPromise={cardState.verificationRequestPromise}
/>;
if (!!cardState?.member) {
const roomMember = cardState.member instanceof RoomMember ? cardState.member : undefined;
card = (
<UserInfo
user={cardState.member}
room={this.context.getRoom(roomMember?.roomId) ?? this.props.room}
key={roomId ?? cardState.member.userId}
onClose={this.onClose}
phase={phase}
verificationRequest={cardState.verificationRequest}
verificationRequestPromise={cardState.verificationRequestPromise}
/>
);
}
break;
}
case RightPanelPhases.Room3pidMemberInfo:
case RightPanelPhases.Space3pidMemberInfo:
card = <ThirdPartyMemberInfo event={cardState.memberInfoEvent} key={roomId} />;
if (!!cardState?.memberInfoEvent) {
card = (
<ThirdPartyMemberInfo event={cardState.memberInfoEvent} key={roomId} onClose={this.onClose} />
);
}
break;
case RightPanelPhases.NotificationPanel:
@ -198,68 +224,94 @@ export default class RightPanel extends React.Component<IProps, IState> {
break;
case RightPanelPhases.PinnedMessages:
if (SettingsStore.getValue("feature_pinning")) {
card = <PinnedMessagesCard
room={this.props.room}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>;
if (!!this.props.room && SettingsStore.getValue("feature_pinning")) {
card = (
<PinnedMessagesCard
room={this.props.room}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>
);
}
break;
case RightPanelPhases.Timeline:
card = <TimelineCard
classNames="mx_ThreadPanel mx_TimelineCard"
room={this.props.room}
timelineSet={this.props.room.getUnfilteredTimelineSet()}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
/>;
if (!!this.props.room) {
card = (
<TimelineCard
classNames="mx_ThreadPanel mx_TimelineCard"
room={this.props.room}
timelineSet={this.props.room.getUnfilteredTimelineSet()}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
/>
);
}
break;
case RightPanelPhases.FilePanel:
card = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
if (!!roomId) {
card = (
<FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />
);
}
break;
case RightPanelPhases.ThreadView:
card = <ThreadView
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
mxEvent={cardState.threadHeadEvent}
initialEvent={cardState.initialEvent}
isInitialEventHighlighted={cardState.isInitialEventHighlighted}
initialEventScrollIntoView={cardState.initialEventScrollIntoView}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
/>;
if (!!this.props.room && !!cardState?.threadHeadEvent) {
card = (
<ThreadView
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
mxEvent={cardState.threadHeadEvent}
initialEvent={cardState.initialEvent}
isInitialEventHighlighted={cardState.isInitialEventHighlighted}
initialEventScrollIntoView={cardState.initialEventScrollIntoView}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
/>
);
}
break;
case RightPanelPhases.ThreadPanel:
card = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>;
if (!!this.props.room) {
card = (
<ThreadPanel
roomId={this.props.room.roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>
);
}
break;
case RightPanelPhases.RoomSummary:
card = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
if (!!this.props.room) {
card = (
<RoomSummaryCard
room={this.props.room}
onClose={this.onClose}
// whenever RightPanel is passed a room it is passed a permalinkcreator
permalinkCreator={this.props.permalinkCreator!}
onSearchClick={this.props.onSearchClick}
/>
);
}
break;
case RightPanelPhases.Widget:
card = <WidgetCard
room={this.props.room}
widgetId={cardState.widgetId}
onClose={this.onClose}
/>;
if (!!this.props.room && !!cardState?.widgetId) {
card = <WidgetCard room={this.props.room} widgetId={cardState.widgetId} onClose={this.onClose} />;
}
break;
}
return (
<aside className="mx_RightPanel dark-panel" id="mx_RightPanel">
{ card }
<aside className="mx_RightPanel" id="mx_RightPanel">
{card}
</aside>
);
}

View file

@ -1,560 +0,0 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 { IFieldType, 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 { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal";
import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId, ALL_ROOMS, Protocols } from '../../utils/DirectoryUtils';
import SettingsStore from "../../settings/SettingsStore";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import { IPublicRoomDirectoryConfig, NetworkDropdown } from "../views/directory/NetworkDropdown";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import QuestionDialog from "../views/dialogs/QuestionDialog";
import BaseDialog from "../views/dialogs/BaseDialog";
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
import ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner";
import { getDisplayAliasForAliasSet } from "../../Rooms";
import PosthogTrackers from "../../PosthogTrackers";
import { PublicRoomTile } from "../views/rooms/PublicRoomTile";
import { getFieldsForThirdPartyLocation, joinRoomByAlias, showRoom } from "../../utils/rooms";
import { GenericError } from "../../utils/error";
const LAST_SERVER_KEY = "mx_last_room_directory_server";
const LAST_INSTANCE_KEY = "mx_last_room_directory_instance";
interface IProps extends IDialogProps {
initialText?: string;
}
interface IState {
publicRooms: IPublicRoomsChunkRoom[];
loading: boolean;
protocolsLoading: boolean;
error?: string | null;
serverConfig: IPublicRoomDirectoryConfig | null;
filterString: string;
}
export default class RoomDirectory extends React.Component<IProps, IState> {
private unmounted = false;
private nextBatch: string | null = null;
private filterTimeout: number | null;
private protocols: Protocols;
constructor(props) {
super(props);
let protocolsLoading = true;
if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page
protocolsLoading = false;
} else {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response;
const myHomeserver = MatrixClientPeg.getHomeserverName();
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY) ?? undefined;
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined;
let roomServer: string | undefined = myHomeserver;
if (
SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) ||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
) {
roomServer = lsRoomServer;
}
let instanceId: string | undefined = undefined;
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.serverConfig?.instanceId !== instanceId ||
this.state.serverConfig?.roomServer !== roomServer) {
this.setState({
protocolsLoading: false,
serverConfig: roomServer ? { instanceId, roomServer } : null,
});
this.refreshRoomList();
return;
}
this.setState({ protocolsLoading: false });
}, (err) => {
logger.warn(`error loading third party protocols: ${err}`);
this.setState({ protocolsLoading: false });
if (MatrixClientPeg.get().isGuest()) {
// Guests currently aren't allowed to use this API, so
// ignore this as otherwise this error is literally the
// thing you see when loading the client!
return;
}
const brand = SdkConfig.get().brand;
this.setState({
error: _t(
'%(brand)s failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.',
{ brand },
),
});
});
}
let serverConfig: IPublicRoomDirectoryConfig | null = null;
const roomServer = localStorage.getItem(LAST_SERVER_KEY);
if (roomServer) {
serverConfig = {
roomServer,
instanceId: localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined,
};
}
this.state = {
publicRooms: [],
loading: true,
error: null,
serverConfig,
filterString: this.props.initialText || "",
protocolsLoading,
};
}
componentDidMount() {
this.refreshRoomList();
}
componentWillUnmount() {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
this.unmounted = true;
}
private refreshRoomList = () => {
this.nextBatch = null;
this.setState({
publicRooms: [],
loading: true,
});
this.getMoreRooms();
};
private getMoreRooms(): Promise<boolean> {
if (!MatrixClientPeg.get()) return Promise.resolve(false);
this.setState({
loading: true,
});
const filterString = this.state.filterString;
const roomServer = this.state.serverConfig?.roomServer;
// 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: IRoomDirectoryOptions = { limit: 20 };
if (roomServer != MatrixClientPeg.getHomeserverName()) {
opts.server = roomServer;
}
if (this.state.serverConfig?.instanceId === ALL_ROOMS) {
opts.include_all_networks = true;
} else if (this.state.serverConfig?.instanceId) {
opts.third_party_instance_id = this.state.serverConfig?.instanceId as string;
}
if (this.nextBatch) opts.since = this.nextBatch;
if (filterString) opts.filter = { generic_search_term: filterString };
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
if (
filterString != this.state.filterString ||
roomServer != this.state.serverConfig?.roomServer ||
nextBatch != this.nextBatch) {
// if the filter or server has changed since this request was sent,
// throw away the result (don't even clear the busy flag
// since we must still have a request in flight)
return false;
}
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return false;
}
this.nextBatch = data.next_batch ?? null;
this.setState((s) => ({
...s,
publicRooms: [...s.publicRooms, ...(data.chunk || [])],
loading: false,
}));
return Boolean(data.next_batch);
}, (err) => {
if (
filterString != this.state.filterString ||
roomServer != this.state.serverConfig?.roomServer ||
nextBatch != this.nextBatch) {
// as above: we don't care about errors for old requests either
return false;
}
if (this.unmounted) {
// if we've been unmounted, we don't care either.
return false;
}
logger.error("Failed to get publicRooms: %s", JSON.stringify(err));
const brand = SdkConfig.get().brand;
this.setState({
loading: false,
error: (
_t('%(brand)s failed to get the public room list.', { brand }) +
(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')
),
});
return false;
});
}
/**
* A limited interface for removing rooms from the directory.
* Will set the room to not be publicly visible and delete the
* default alias. In the long term, it would be better to allow
* HS admins to do this through the RoomSettings interface, but
* this needs SPEC-417.
*/
private removeFromDirectory = (room: IPublicRoomsChunkRoom) => {
const alias = getDisplayAliasForRoom(room);
const name = room.name || alias || _t('Unnamed room');
let desc;
if (alias) {
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', { alias, name });
} else {
desc = _t('Remove %(name)s from the directory?', { name: name });
}
Modal.createDialog(QuestionDialog, {
title: _t('Remove from Directory'),
description: desc,
onFinished: (shouldDelete: boolean) => {
if (!shouldDelete) return;
const modal = Modal.createDialog(Spinner);
let step = _t('remove %(name)s from the directory.', { name: name });
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => {
if (!alias) return;
step = _t('delete the address.');
return MatrixClientPeg.get().deleteAlias(alias);
}).then(() => {
modal.close();
this.refreshRoomList();
}, (err) => {
modal.close();
this.refreshRoomList();
logger.error("Failed to " + step + ": " + err);
Modal.createDialog(ErrorDialog, {
title: _t('Error'),
description: (err && err.message)
? err.message
: _t('The server may be unavailable or overloaded'),
});
});
},
});
};
private onOptionChange = (serverConfig: IPublicRoomDirectoryConfig) => {
// clear next batch so we don't try to load more rooms
this.nextBatch = null;
this.setState({
// Clear the public rooms out here otherwise we needlessly
// spend time filtering lots of rooms when we're about to
// to clear the list anyway.
publicRooms: [],
serverConfig,
error: null,
}, this.refreshRoomList);
// We also refresh the room list each time even though this
// filtering is client-side. It hopefully won't be client side
// for very long, and we may have fetched a thousand rooms to
// 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, serverConfig.roomServer);
if (serverConfig.instanceId) {
localStorage.setItem(LAST_INSTANCE_KEY, serverConfig.instanceId);
} else {
localStorage.removeItem(LAST_INSTANCE_KEY);
}
};
private onFillRequest = (backwards: boolean) => {
if (backwards || !this.nextBatch) return Promise.resolve(false);
return this.getMoreRooms();
};
private onFilterChange = (alias: string) => {
this.setState({
filterString: alias?.trim() || "",
});
// don't send the request for a little bit,
// no point hammering the server with a
// request for every keystroke, let the
// user finish typing.
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
this.filterTimeout = setTimeout(() => {
this.filterTimeout = null;
this.refreshRoomList();
}, 700);
};
private onFilterClear = () => {
// update immediately
this.setState({
filterString: "",
}, this.refreshRoomList);
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
};
private onJoinFromSearchClick = (alias: string) => {
const cli = MatrixClientPeg.get();
try {
joinRoomByAlias(cli, alias, {
instanceId: this.state.serverConfig?.instanceId,
roomServer: this.state.serverConfig?.roomServer,
protocols: this.protocols,
metricsTrigger: "RoomDirectory",
});
} catch (e) {
if (e instanceof GenericError) {
Modal.createDialog(ErrorDialog, {
title: e.message,
description: e.description,
});
} else {
throw e;
}
}
};
private onCreateRoomClick = (ev: ButtonEvent) => {
this.onFinished();
dis.dispatch({
action: 'view_create_room',
public: true,
defaultName: this.state.filterString.trim(),
});
PosthogTrackers.trackInteraction("WebRoomDirectoryCreateRoomButton", ev);
};
private onRoomClick = (room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) => {
this.onFinished();
const cli = MatrixClientPeg.get();
showRoom(cli, room, {
roomAlias,
autoJoin,
shouldPeek,
roomServer: this.state.serverConfig?.roomServer,
metricsTrigger: "RoomDirectory",
});
};
private stringLooksLikeId(s: string, fieldType: IFieldType) {
let pat = /^#[^\s]+:[^\s]/;
if (fieldType && fieldType.regexp) {
pat = new RegExp(fieldType.regexp);
}
return pat.test(s);
}
private onFinished = () => {
this.props.onFinished(false);
};
public render() {
let content;
if (this.state.error) {
content = this.state.error;
} else if (this.state.protocolsLoading) {
content = <Spinner />;
} else {
const cells = (this.state.publicRooms || [])
.map(room =>
<PublicRoomTile
key={room.room_id}
room={room}
showRoom={this.onRoomClick}
removeFromDirectory={this.removeFromDirectory}
/>,
);
// we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one
let spinner;
if (this.state.loading) {
spinner = <Spinner />;
}
const createNewButton = <>
<hr />
<AccessibleButton kind="primary" onClick={this.onCreateRoomClick} className="mx_RoomDirectory_newRoom">
{ _t("Create new room") }
</AccessibleButton>
</>;
let scrollPanelContent;
let footer;
if (cells.length === 0 && !this.state.loading) {
footer = <>
<h5>{ _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }</h5>
<p>
{ _t("Try different words or check for typos. " +
"Some results may not be visible as they're private and you need an invite to join them.") }
</p>
{ createNewButton }
</>;
} else {
scrollPanelContent = <div className="mx_RoomDirectory_table">
{ cells }
</div>;
if (!this.state.loading && !this.nextBatch) {
footer = createNewButton;
}
}
content = <ScrollPanel
className="mx_RoomDirectory_tableWrapper"
onFillRequest={this.onFillRequest}
stickyBottom={false}
startAtBottom={false}
>
{ scrollPanelContent }
{ spinner }
{ footer && <div className="mx_RoomDirectory_footer">
{ footer }
</div> }
</ScrollPanel>;
}
let listHeader;
if (!this.state.protocolsLoading) {
const protocolName = protocolNameForInstanceId(this.protocols, this.state.serverConfig?.instanceId);
let instanceExpectedFieldType;
if (
protocolName &&
this.protocols &&
this.protocols[protocolName] &&
this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types
) {
const lastField = this.protocols[protocolName].location_fields.slice(-1)[0];
instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField];
}
let placeholder = _t('Find a room…');
if (!this.state.serverConfig?.instanceId || this.state.serverConfig?.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
exampleRoom: "#example:" + this.state.serverConfig?.roomServer,
});
} else if (instanceExpectedFieldType) {
placeholder = instanceExpectedFieldType.placeholder;
}
let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.serverConfig?.instanceId);
if (!instance || getFieldsForThirdPartyLocation(
this.state.filterString,
this.protocols[protocolName],
instance,
) === null) {
showJoinButton = false;
}
}
listHeader = <div className="mx_RoomDirectory_listheader">
<DirectorySearchBox
className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange}
onClear={this.onFilterClear}
onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder}
showJoinButton={showJoinButton}
initialText={this.props.initialText}
/>
<NetworkDropdown
protocols={this.protocols}
config={this.state.serverConfig}
setConfig={this.onOptionChange}
/>
</div>;
}
const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", {},
{ a: sub => (
<AccessibleButton kind="link_inline" onClick={this.onCreateRoomClick}>
{ sub }
</AccessibleButton>
) },
);
const title = _t("Explore rooms");
return (
<BaseDialog
className="mx_RoomDirectory_dialog"
hasCancel={true}
onFinished={this.onFinished}
title={title}
screenName="RoomDirectory"
>
<div className="mx_RoomDirectory">
{ explanation }
<div className="mx_RoomDirectory_list">
{ listHeader }
{ content }
</div>
</div>
</BaseDialog>
);
}
}
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list
export function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) {
return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases);
}

View file

@ -22,9 +22,8 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { IS_MAC, Key } from "../../Keyboard";
import { _t } from "../../languageHandler";
import Modal from "../../Modal";
import SpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions";
interface IProps {
isMinimized: boolean;
@ -33,46 +32,51 @@ interface IProps {
export default class RoomSearch extends React.PureComponent<IProps> {
private readonly dispatcherRef: string;
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
public componentWillUnmount() {
public componentWillUnmount(): void {
defaultDispatcher.unregister(this.dispatcherRef);
}
private openSpotlight() {
Modal.createDialog(SpotlightDialog, {}, "mx_SpotlightDialog_wrapper", false, true);
private openSpotlight(): void {
defaultDispatcher.fire(Action.OpenSpotlight);
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'focus_room_filter') {
private onAction = (payload: ActionPayload): void => {
if (payload.action === "focus_room_filter") {
this.openSpotlight();
}
};
public render(): React.ReactNode {
const classes = classNames({
'mx_RoomSearch': true,
'mx_RoomSearch_minimized': this.props.isMinimized,
}, 'mx_RoomSearch_spotlightTrigger');
const icon = (
<div className="mx_RoomSearch_icon" />
const classes = classNames(
{
mx_RoomSearch: true,
mx_RoomSearch_minimized: this.props.isMinimized,
},
"mx_RoomSearch_spotlightTrigger",
);
const shortcutPrompt = <kbd className="mx_RoomSearch_shortcutPrompt">
{ IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K" }
</kbd>;
const icon = <div className="mx_RoomSearch_icon" />;
return <AccessibleButton onClick={this.openSpotlight} className={classes}>
{ icon }
{ (!this.props.isMinimized) && <div className="mx_RoomSearch_spotlightTriggerText">
{ _t("Search") }
</div> }
{ shortcutPrompt }
</AccessibleButton>;
const shortcutPrompt = (
<kbd className="mx_RoomSearch_shortcutPrompt">
{IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"}
</kbd>
);
return (
<AccessibleButton onClick={this.openSpotlight} className={classes}>
{icon}
{!this.props.isMinimized && (
<div className="mx_RoomSearch_spotlightTriggerText">{_t("action|search")}</div>
)}
{shortcutPrompt}
</AccessibleButton>
);
}
}

View file

@ -0,0 +1,336 @@
/*
Copyright 2015 - 2023 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, { forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react";
import {
ISearchResults,
IThreadBundledRelationship,
MatrixEvent,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import ScrollPanel from "./ScrollPanel";
import { SearchScope } from "../views/rooms/SearchBar";
import Spinner from "../views/elements/Spinner";
import { _t } from "../../languageHandler";
import { haveRendererForEvent } from "../../events/EventTileFactory";
import SearchResultTile from "../views/rooms/SearchResultTile";
import { searchPagination } from "../../Searching";
import Modal from "../../Modal";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import ResizeNotifier from "../../utils/ResizeNotifier";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import RoomContext from "../../contexts/RoomContext";
const DEBUG = false;
let debuglog = function (msg: string): void {};
/* istanbul ignore next */
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
debuglog = logger.log.bind(console);
}
interface Props {
term: string;
scope: SearchScope;
promise: Promise<ISearchResults>;
abortController?: AbortController;
resizeNotifier: ResizeNotifier;
className: string;
onUpdate(inProgress: boolean, results: ISearchResults | null): void;
}
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
export const RoomSearchView = forwardRef<ScrollPanel, Props>(
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate }: Props, ref) => {
const client = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const [inProgress, setInProgress] = useState(true);
const [highlights, setHighlights] = useState<string[] | null>(null);
const [results, setResults] = useState<ISearchResults | null>(null);
const aborted = useRef(false);
// A map from room ID to permalink creator
const permalinkCreators = useRef(new Map<string, RoomPermalinkCreator>()).current;
const innerRef = useRef<ScrollPanel | null>();
useEffect(() => {
return () => {
permalinkCreators.forEach((pc) => pc.stop());
permalinkCreators.clear();
};
}, [permalinkCreators]);
const handleSearchResult = useCallback(
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
setInProgress(true);
return searchPromise
.then(
async (results): Promise<boolean> => {
debuglog("search complete");
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
// postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting.
//
// In either case, we want to highlight the literal search term
// whether it was used by the search engine or not.
let highlights = results.highlights;
if (!highlights.includes(term)) {
highlights = highlights.concat(term);
}
// For overlapping highlights,
// favour longer (more specific) terms first
highlights = highlights.sort(function (a, b) {
return b.length - a.length;
});
for (const result of results.results) {
for (const event of result.context.getTimeline()) {
const bundledRelationship =
event.getServerAggregatedRelation<IThreadBundledRelationship>(
THREAD_RELATION_TYPE.name,
);
if (!bundledRelationship || event.getThread()) continue;
const room = client.getRoom(event.getRoomId());
const thread = room?.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
} else {
room?.createThread(event.getId()!, event, [], true);
}
}
}
setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh
return false;
},
(error) => {
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
logger.error("Search failed", error);
Modal.createDialog(ErrorDialog, {
title: _t("error_dialog|search_failed|title"),
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
});
return false;
},
)
.finally(() => {
setInProgress(false);
});
},
[client, term],
);
// Mount & unmount effect
useEffect(() => {
aborted.current = false;
handleSearchResult(promise);
return () => {
aborted.current = true;
abortController?.abort();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// show searching spinner
if (results?.count === undefined) {
return (
<div
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
data-testid="messagePanelSearchSpinner"
/>
);
}
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
if (!backwards) {
return false;
}
if (!results.next_batch) {
debuglog("no more search results");
return false;
}
debuglog("requesting more search results");
const searchPromise = searchPagination(client, results);
return handleSearchResult(searchPromise);
};
const ret: JSX.Element[] = [];
if (inProgress) {
ret.push(
<li key="search-spinner">
<Spinner />
</li>,
);
}
if (!results.next_batch) {
if (!results?.results?.length) {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2>
</li>,
);
} else {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
</li>,
);
}
}
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onHeightChanged = (): void => {
innerRef.current?.checkScroll();
};
const onRef = (e: ScrollPanel | null): void => {
if (typeof ref === "function") {
ref(e);
} else if (!!ref) {
ref.current = e;
}
innerRef.current = e;
};
let lastRoomId: string | undefined;
let mergedTimeline: MatrixEvent[] = [];
let ourEventsIndexes: number[] = [];
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
const result = results.results[i];
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId()!;
const room = client.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// 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.
logger.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (scope === SearchScope.All) {
if (roomId !== lastRoomId) {
ret.push(
<li key={mxEv.getId() + "-room"}>
<h2>
{_t("common|room")}: {room.name}
</h2>
</li>,
);
lastRoomId = roomId;
}
}
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
// merging two successive search result if the query is present in both of them
const currentTimeline = result.context.getTimeline();
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
// if this is the first searchResult we merge then add all values of the current searchResult
if (mergedTimeline.length == 0) {
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
mergedTimeline.push(currentTimeline[j]);
}
ourEventsIndexes.push(result.context.getOurEventIndex());
}
// merge the events of the next searchResult
for (let j = 1; j < nextTimeline.length; j++) {
mergedTimeline.push(nextTimeline[j]);
}
// add the index of the matching event of the next searchResult
ourEventsIndexes.push(
ourEventsIndexes[ourEventsIndexes.length - 1] +
results.results[i - 1].context.getOurEventIndex() +
1,
);
continue;
}
if (mergedTimeline.length == 0) {
mergedTimeline = result.context.getTimeline();
ourEventsIndexes = [];
ourEventsIndexes.push(result.context.getOurEventIndex());
}
let permalinkCreator = permalinkCreators.get(roomId);
if (!permalinkCreator) {
permalinkCreator = new RoomPermalinkCreator(room);
permalinkCreator.start();
permalinkCreators.set(roomId, permalinkCreator);
}
ret.push(
<SearchResultTile
key={mxEv.getId()}
timeline={mergedTimeline}
ourEventsIndexes={ourEventsIndexes}
searchHighlights={highlights ?? []}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
onHeightChanged={onHeightChanged}
/>,
);
ourEventsIndexes = [];
mergedTimeline = [];
}
return (
<ScrollPanel
ref={onRef}
className={"mx_RoomView_searchResultsPanel " + className}
onFillRequest={onSearchResultsFillRequest}
resizeNotifier={resizeNotifier}
>
<li className="mx_RoomView_scrollheader" />
{ret}
</ScrollPanel>
);
},
);

View file

@ -14,29 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SyncState, ISyncStateData } from "matrix-js-sdk/src/sync";
import { Room } from "matrix-js-sdk/src/models/room";
import React, { ReactNode } from "react";
import { EventStatus, MatrixEvent, Room, MatrixError, SyncState, SyncStateData } from "matrix-js-sdk/src/matrix";
import { _t, _td } from '../../languageHandler';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
import { Icon as WarningIcon } from "../../../res/img/feather-customised/warning-triangle.svg";
import { _t, _td } from "../../languageHandler";
import Resend from "../../Resend";
import dis from "../../dispatcher/dispatcher";
import { messageForResourceLimitError } from "../../utils/ErrorUtils";
import { Action } from "../../dispatcher/actions";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import AccessibleButton from "../views/elements/AccessibleButton";
import InlineSpinner from "../views/elements/InlineSpinner";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages';
import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages";
import ExternalLink from "../views/elements/ExternalLink";
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
if (!room) {
return [];
}
return room.getPendingEvents().filter(function (ev) {
const isNotSent = ev.status === EventStatus.NOT_SENT;
const belongsToTheThread = threadId === ev.threadRootId;
return isNotSent && (!threadId || belongsToTheThread);
@ -78,7 +80,7 @@ interface IProps {
interface IState {
syncState: SyncState;
syncStateData: ISyncStateData;
syncStateData: SyncStateData;
unsentMessages: MatrixEvent[];
isResending: boolean;
}
@ -87,7 +89,7 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
private unmounted = false;
public static contextType = MatrixClientContext;
constructor(props: IProps, context: typeof MatrixClientContext) {
public constructor(props: IProps, context: typeof MatrixClientContext) {
super(props, context);
this.state = {
@ -120,7 +122,7 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
}
}
private onSyncStateChange = (state: SyncState, prevState: SyncState, data: ISyncStateData): void => {
private onSyncStateChange = (state: SyncState, prevState: SyncState, data: SyncStateData): void => {
if (state === "SYNCING" && prevState === "SYNCING") {
return;
}
@ -144,7 +146,7 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
dis.fire(Action.FocusSendMessageComposer);
};
private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room): void => {
if (room.roomId !== this.props.room.roomId) return;
const messages = getUnsentMessages(this.props.room);
this.setState({
@ -181,8 +183,8 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
// if it's a resource limit exceeded error: those are shown in the top bar.
const errorIsMauError = Boolean(
this.state.syncStateData &&
this.state.syncStateData.error &&
this.state.syncStateData.error.name === 'M_RESOURCE_LIMIT_EXCEEDED',
this.state.syncStateData.error &&
this.state.syncStateData.error.name === "M_RESOURCE_LIMIT_EXCEEDED",
);
return this.state.syncState === "ERROR" && !errorIsMauError;
}
@ -190,29 +192,29 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
private getUnsentMessageContent(): JSX.Element {
const unsentMessages = this.state.unsentMessages;
let title;
let title: ReactNode;
let consentError = null;
let resourceLimitError = null;
let consentError: MatrixError | null = null;
let resourceLimitError: MatrixError | null = null;
for (const m of unsentMessages) {
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
if (m.error && m.error.errcode === "M_CONSENT_NOT_GIVEN") {
consentError = m.error;
break;
} else if (m.error && m.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
} else if (m.error && m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
resourceLimitError = m.error;
break;
}
}
if (consentError) {
title = _t(
"You can't send any messages until you review and agree to " +
"<consentLink>our terms and conditions</consentLink>.",
"room|status_bar|requires_consent_agreement",
{},
{
'consentLink': (sub) =>
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
{ sub }
</a>,
consentLink: (sub) => (
<ExternalLink href={consentError!.data?.consent_uri} target="_blank" rel="noreferrer noopener">
{sub}
</ExternalLink>
),
},
);
} else if (resourceLimitError) {
@ -220,66 +222,58 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
resourceLimitError.data.limit_type,
resourceLimitError.data.admin_contact,
{
'monthly_active_user': _td(
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'hs_disabled': _td(
"Your message wasn't sent because this homeserver has been blocked by its administrator. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'': _td(
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
"monthly_active_user": _td("room|status_bar|monthly_user_limit_reached"),
"hs_disabled": _td("room|status_bar|homeserver_blocked"),
"": _td("room|status_bar|exceeded_resource_limit"),
},
);
} else {
title = _t('Some of your messages have not been sent');
title = _t("room|status_bar|some_messages_not_sent");
}
let buttonRow = <>
<AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{ _t("Delete all") }
</AccessibleButton>
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentRetry">
{ _t("Retry all") }
</AccessibleButton>
</>;
let buttonRow = (
<>
<AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
{_t("room|status_bar|delete_all")}
</AccessibleButton>
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentRetry">
{_t("room|status_bar|retry_all")}
</AccessibleButton>
</>
);
if (this.state.isResending) {
buttonRow = <>
<InlineSpinner w={20} h={20} />
{ /* span for css */ }
<span>{ _t("Sending") }</span>
</>;
buttonRow = (
<>
<InlineSpinner w={20} h={20} />
{/* span for css */}
<span>{_t("forward|sending")}</span>
</>
);
}
return <RoomStatusBarUnsentMessages
title={title}
description={_t("You can select all or individual messages to retry or delete")}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={buttonRow}
/>;
return (
<RoomStatusBarUnsentMessages
title={title}
description={_t("room|status_bar|select_messages_to_retry")}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={buttonRow}
/>
);
}
public render(): JSX.Element {
public render(): React.ReactNode {
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").default}
width="24"
height="24"
title="/!\ "
alt="/!\ " />
<WarningIcon width="24" height="24" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
{_t("room|status_bar|server_connectivity_lost_title")}
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
{_t("room|status_bar|server_connectivity_lost_description")}
</div>
</div>
</div>

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactElement } from "react";
import React, { ReactElement, ReactNode } from "react";
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
import NotificationBadge from "../views/rooms/NotificationBadge";
interface RoomStatusBarUnsentMessagesProps {
title: string;
title: ReactNode;
description?: string;
notificationState: StaticNotificationState;
buttons: ReactElement;
@ -31,24 +31,13 @@ export const RoomStatusBarUnsentMessages = (props: RoomStatusBarUnsentMessagesPr
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge
notification={props.notificationState}
/>
<NotificationBadge notification={props.notificationState} />
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ props.title }
</div>
{
props.description &&
<div className="mx_RoomStatusBar_unsentDescription">
{ props.description }
</div>
}
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{ props.buttons }
<div className="mx_RoomStatusBar_unsentTitle">{props.title}</div>
{props.description && <div className="mx_RoomStatusBar_unsentDescription">{props.description}</div>}
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">{props.buttons}</div>
</div>
</div>
);

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
/*
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, CSSProperties, ReactNode, KeyboardEvent } from "react";
import React, { createRef, CSSProperties, ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from '../../settings/SettingsStore';
import Timer from '../../utils/Timer';
import SettingsStore from "../../settings/SettingsStore";
import Timer from "../../utils/Timer";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import ResizeNotifier from "../../utils/ResizeNotifier";
@ -30,12 +30,12 @@ const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest,
// to prevent many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
// updateHeight makes the height a ceiled multiple of this so we don't have to update the height too often.
// It also allows the user to scroll past the pagination spinner a bit so they don't feel blocked so
// updateHeight makes the height a `Math.ceil` multiple of this, so we don't have to update the height too often.
// It also allows the user to scroll past the pagination spinner a bit, so they don't feel blocked so
// much while the content loads.
const PAGE_SIZE = 400;
const debuglog = (...args: any[]) => {
const debuglog = (...args: any[]): void => {
if (SettingsStore.getValue("debug_scroll_panel")) {
logger.log.call(console, "ScrollPanel debuglog:", ...args);
}
@ -74,6 +74,7 @@ interface IProps {
* of the wrapper
*/
fixedChildren?: ReactNode;
children?: ReactNode;
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
@ -134,7 +135,7 @@ interface IProps {
*
* - fixed, in which the viewport is conceptually tied at a specific scroll
* offset. We don't save the absolute scroll offset, because that would be
* affected by window width, zoom level, amount of scrollback, etc. Instead
* affected by window width, zoom level, amount of scrollback, etc. Instead,
* we save an identifier for the last fully-visible message, and the number
* of pixels the window was scrolled below it - which is hopefully near
* enough.
@ -148,7 +149,7 @@ interface IProps {
*/
export interface IScrollState {
stuckAtBottom: boolean;
stuckAtBottom?: boolean;
trackedNode?: HTMLElement;
trackedScrollToken?: string;
bottomOffset?: number;
@ -161,60 +162,63 @@ interface IPreventShrinkingState {
}
export default class ScrollPanel extends React.Component<IProps> {
static defaultProps = {
// noinspection JSUnusedLocalSymbols
public static defaultProps = {
stickyBottom: true,
startAtBottom: true,
onFillRequest: function(backwards: boolean) { return Promise.resolve(false); },
onUnfillRequest: function(backwards: boolean, scrollToken: string) {},
onScroll: function() {},
onFillRequest: function (backwards: boolean) {
return Promise.resolve(false);
},
onUnfillRequest: function (backwards: boolean, scrollToken: string) {},
onScroll: function () {},
};
private readonly pendingFillRequests: Record<"b" | "f", boolean> = {
private readonly pendingFillRequests: Record<"b" | "f", boolean | null> = {
b: null,
f: null,
};
private readonly itemlist = createRef<HTMLOListElement>();
private unmounted = false;
private scrollTimeout: Timer;
private scrollTimeout?: Timer;
// Are we currently trying to backfill?
private isFilling: boolean;
private isFilling = false;
// 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;
private fillRequestWhileRunning = false;
// Is that next fill request scheduled because of a props update?
private pendingFillDueToPropsUpdate: boolean;
private scrollState: IScrollState;
private preventShrinkingState: IPreventShrinkingState;
private unfillDebouncer: number;
private bottomGrowth: number;
private minListHeight: number;
private heightUpdateInProgress: boolean;
private divScroll: HTMLDivElement;
private pendingFillDueToPropsUpdate = false;
private scrollState!: IScrollState;
private preventShrinkingState: IPreventShrinkingState | null = null;
private unfillDebouncer: number | null = null;
private bottomGrowth!: number;
private minListHeight!: number;
private heightUpdateInProgress = false;
private divScroll: HTMLDivElement | null = null;
constructor(props, context) {
super(props, context);
public constructor(props: IProps) {
super(props);
this.props.resizeNotifier?.on("middlePanelResizedNoisy", this.onResize);
this.resetScrollState();
}
componentDidMount() {
public componentDidMount(): void {
this.checkScroll();
}
componentDidUpdate() {
public componentDidUpdate(): void {
// after adding event tiles, we may need to tweak the scroll (either to
// keep at the bottom of the timeline, or to maintain the view after
// adding events to the top).
//
// This will also re-check the fill state, in case the paginate was inadequate
// This will also re-check the fill state, in case the pagination was inadequate
this.checkScroll(true);
this.updatePreventShrinking();
}
componentWillUnmount() {
public componentWillUnmount(): void {
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
//
@ -222,21 +226,24 @@ export default class ScrollPanel extends React.Component<IProps> {
this.unmounted = true;
this.props.resizeNotifier?.removeListener("middlePanelResizedNoisy", this.onResize);
this.divScroll = null;
}
private onScroll = ev => {
private onScroll = (ev: Event): void => {
// skip scroll events caused by resizing
if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
debuglog("onScroll", this.getScrollNode().scrollTop);
this.scrollTimeout.restart();
debuglog("onScroll called past resize gate; scroll node top:", this.getScrollNode().scrollTop);
this.scrollTimeout?.restart();
this.saveScrollState();
this.updatePreventShrinking();
this.props.onScroll(ev);
this.props.onScroll?.(ev);
// noinspection JSIgnoredPromiseFromCall
this.checkFillState();
};
private onResize = () => {
debuglog("onResize");
private onResize = (): void => {
debuglog("onResize called");
this.checkScroll();
// update preventShrinkingState if present
if (this.preventShrinkingState) {
@ -246,11 +253,14 @@ 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 = (isFromPropsUpdate = false) => {
public checkScroll = (isFromPropsUpdate = false): void => {
if (this.unmounted) {
return;
}
// We don't care if these two conditions race - they're different trees.
// noinspection JSIgnoredPromiseFromCall
this.restoreSavedScrollState();
// noinspection JSIgnoredPromiseFromCall
this.checkFillState(0, isFromPropsUpdate);
};
@ -259,7 +269,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// note that this is independent of the 'stuckAtBottom' state - it is simply
// about whether the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update.
public isAtBottom = () => {
public isAtBottom = (): boolean => {
const sn = this.getScrollNode();
// fractional values (both too big and too small)
// for scrollTop happen on certain browsers/platforms
@ -277,7 +287,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// returns the vertical height in the given direction that can be removed from
// the content box (which has a height of scrollHeight, see checkFillState) without
// pagination occuring.
// pagination occurring.
//
// padding* = UNPAGINATION_PADDING
//
@ -316,7 +326,7 @@ export default class ScrollPanel extends React.Component<IProps> {
if (backwards) {
return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else {
return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
return contentHeight - (unclippedScrollTop + 2 * sn.clientHeight) - UNPAGINATION_PADDING;
}
}
@ -329,7 +339,7 @@ export default class ScrollPanel extends React.Component<IProps> {
const isFirstCall = depth === 0;
const sn = this.getScrollNode();
// if there is less than a screenful of messages above or below the
// if there is less than a screen's worth of messages above or below the
// viewport, try to get some more messages.
//
// scrollTop is the number of pixels between the top of the content and
@ -374,19 +384,18 @@ export default class ScrollPanel extends React.Component<IProps> {
}
const itemlist = this.itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = [];
const firstTile = itemlist?.firstElementChild as HTMLElement | undefined;
const fillPromises: Promise<void>[] = [];
// if scrollTop gets to 1 screen from the top of the first tile,
// try backward filling
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
if (!firstTile || sn.scrollTop - firstTile.offsetTop < sn.clientHeight) {
// need to back-fill
fillPromises.push(this.maybeFill(depth, true));
}
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
// try forward filling
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
if (sn.scrollHeight - sn.scrollTop < sn.clientHeight * 2) {
// need to forward-fill
fillPromises.push(this.maybeFill(depth, false));
}
@ -408,6 +417,7 @@ export default class ScrollPanel extends React.Component<IProps> {
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
this.fillRequestWhileRunning = false;
this.pendingFillDueToPropsUpdate = false;
// noinspection ES6MissingAwait
this.checkFillState(0, refillDueToPropsUpdate);
}
};
@ -415,7 +425,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// check if unfilling is possible and send an unfill request if necessary
private checkUnfillState(backwards: boolean): void {
let excessHeight = this.getExcessHeight(backwards);
if (excessHeight <= 0) {
if (excessHeight <= 0 || !this.itemlist.current) {
return;
}
@ -424,7 +434,7 @@ export default class ScrollPanel extends React.Component<IProps> {
const tiles = this.itemlist.current.children;
// The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null;
let markerScrollToken: string | null = null;
// Subtract heights of tiles to simulate the tiles being unpaginated until the
// excess height is less than the height of the next tile to subtract. This
@ -432,9 +442,9 @@ export default class ScrollPanel extends React.Component<IProps> {
// pagination.
//
// If backwards is true, we unpaginate (remove) tiles from the back (top).
let tile;
let tile: HTMLElement;
for (let i = 0; i < tiles.length; i++) {
tile = tiles[backwards ? i : tiles.length - 1 - i];
tile = tiles[backwards ? i : tiles.length - 1 - i] as HTMLElement;
// Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight;
//If removing the tile would lead to future pagination, break before setting scroll token
@ -443,7 +453,7 @@ export default class ScrollPanel extends React.Component<IProps> {
}
// The tile may not have a scroll token, so guard it
if (tile.dataset.scrollTokens) {
markerScrollToken = tile.dataset.scrollTokens.split(',')[0];
markerScrollToken = tile.dataset.scrollTokens.split(",")[0];
}
}
@ -453,23 +463,23 @@ export default class ScrollPanel extends React.Component<IProps> {
if (this.unfillDebouncer) {
clearTimeout(this.unfillDebouncer);
}
this.unfillDebouncer = setTimeout(() => {
this.unfillDebouncer = window.setTimeout(() => {
this.unfillDebouncer = null;
debuglog("unfilling now", backwards, origExcessHeight);
this.props.onUnfillRequest(backwards, markerScrollToken);
debuglog("unfilling now", { backwards, origExcessHeight });
this.props.onUnfillRequest?.(backwards, markerScrollToken!);
}, UNFILL_REQUEST_DEBOUNCE_MS);
}
}
// check if there is already a pending fill request. If not, set one off.
private maybeFill(depth: number, backwards: boolean): Promise<void> {
const dir = backwards ? 'b' : 'f';
const dir = backwards ? "b" : "f";
if (this.pendingFillRequests[dir]) {
debuglog("Already a "+dir+" fill in progress - not starting another");
return;
debuglog("Already a fill in progress - not starting another; direction=", dir);
return Promise.resolve();
}
debuglog("starting "+dir+" fill");
debuglog("starting fill; direction=", dir);
// onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call.
@ -479,25 +489,28 @@ export default class ScrollPanel extends React.Component<IProps> {
// this will block the scroll event handler for +700ms
// if messages are already cached in memory,
// This would cause jumping to happen on Chrome/macOS.
return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
return this.props.onFillRequest(backwards);
}).finally(() => {
this.pendingFillRequests[dir] = false;
}).then((hasMoreResults) => {
if (this.unmounted) {
return;
}
// Unpaginate once filling is complete
this.checkUnfillState(!backwards);
return new Promise((resolve) => window.setTimeout(resolve, 1))
.then(() => {
return this.props.onFillRequest?.(backwards);
})
.finally(() => {
this.pendingFillRequests[dir] = false;
})
.then((hasMoreResults) => {
if (this.unmounted) {
return;
}
// Unpaginate once filling is complete
this.checkUnfillState(!backwards);
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) {
// further pagination requests have been disabled until now, so
// it's time to check the fill state again in case the pagination
// was insufficient.
return this.checkFillState(depth + 1);
}
});
debuglog("fill complete; hasMoreResults=", hasMoreResults, "direction=", dir);
if (hasMoreResults) {
// further pagination requests have been disabled until now, so
// it's time to check the fill state again in case the pagination
// was insufficient.
return this.checkFillState(depth + 1);
}
});
}
/* get the current scroll state. This returns an object with the following
@ -562,11 +575,12 @@ export default class ScrollPanel extends React.Component<IProps> {
/**
* Page up/down.
*
* @param {number} mult: -1 to page up, +1 to page down
* @param {number} multiple: -1 to page up, +1 to page down
*/
public scrollRelative = (mult: number): void => {
public scrollRelative = (multiple: -1 | 1): void => {
const scrollNode = this.getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.9;
// TODO: Document what magic number 0.9 is doing
const delta = multiple * scrollNode.clientHeight * 0.9;
scrollNode.scrollBy(0, delta);
this.saveScrollState();
};
@ -575,7 +589,7 @@ export default class ScrollPanel extends React.Component<IProps> {
* Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event
*/
public handleScrollKey = (ev: KeyboardEvent) => {
public handleScrollKey = (ev: React.KeyboardEvent | KeyboardEvent): void => {
const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
case KeyBindingAction.ScrollUp:
@ -604,11 +618,8 @@ export default class ScrollPanel extends React.Component<IProps> {
* node (specifically, the bottom of it) will be positioned. If omitted, it
* defaults to 0.
*/
public scrollToToken = (scrollToken: string, pixelOffset: number, offsetBase: number): void => {
pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0;
// set the trackedScrollToken so we can get the node through getTrackedNode
public scrollToToken = (scrollToken: string, pixelOffset = 0, offsetBase = 0): void => {
// set the trackedScrollToken, so we can get the node through getTrackedNode
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: scrollToken,
@ -621,9 +632,9 @@ export default class ScrollPanel extends React.Component<IProps> {
// would position the trackedNode towards the top of the viewport.
// This because when setting the scrollTop only 10 or so events might be loaded,
// not giving enough content below the trackedNode to scroll downwards
// enough so it ends up in the top of the viewport.
// enough, so it ends up in the top of the viewport.
debuglog("scrollToken: setting scrollTop", { offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop });
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
scrollNode.scrollTop = trackedNode.offsetTop - scrollNode.clientHeight * offsetBase + pixelOffset;
this.saveScrollState();
}
};
@ -639,16 +650,19 @@ export default class ScrollPanel extends React.Component<IProps> {
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this.itemlist.current;
if (!itemlist) return;
const messages = itemlist.children;
let node = null;
let node: HTMLElement | null = null;
// TODO: do a binary search here, as items are sorted by offsetTop
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length - 1; i >= 0; --i) {
if (!(messages[i] as HTMLElement).dataset.scrollTokens) {
const htmlMessage = messages[i] as HTMLElement;
if (!htmlMessage.dataset?.scrollTokens) {
// dataset is only specified on HTMLElements
continue;
}
node = messages[i];
node = htmlMessage;
// break at the first message (coming from the bottom)
// that has it's offsetTop above the bottom of the viewport.
if (this.topFromBottom(node) > viewportBottom) {
@ -661,8 +675,8 @@ export default class ScrollPanel extends React.Component<IProps> {
debuglog("unable to save scroll state: found no children in the viewport");
return;
}
const scrollToken = node.dataset.scrollTokens.split(',')[0];
debuglog("saving anchored scroll state to message", node.innerText, scrollToken);
const scrollToken = node!.dataset.scrollTokens?.split(",")[0];
debuglog("saving anchored scroll state to message", scrollToken);
const bottomOffset = this.topFromBottom(node);
this.scrollState = {
stuckAtBottom: false,
@ -686,11 +700,11 @@ export default class ScrollPanel extends React.Component<IProps> {
const trackedNode = this.getTrackedNode();
if (trackedNode) {
const newBottomOffset = this.topFromBottom(trackedNode);
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
const bottomDiff = newBottomOffset - (scrollState.bottomOffset ?? 0);
this.bottomGrowth += bottomDiff;
scrollState.bottomOffset = newBottomOffset;
const newHeight = `${this.getListHeight()}px`;
if (itemlist.style.height !== newHeight) {
if (itemlist && itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight;
}
debuglog("balancing height because messages below viewport grew by", bottomDiff);
@ -711,15 +725,17 @@ export default class ScrollPanel extends React.Component<IProps> {
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
private async updateHeight(): Promise<void> {
// wait until user has stopped scrolling
if (this.scrollTimeout.isRunning()) {
if (this.scrollTimeout?.isRunning()) {
debuglog("updateHeight waiting for scrolling to end ... ");
await this.scrollTimeout.finished();
debuglog("updateHeight actually running now");
} else {
debuglog("updateHeight getting straight to business, no scrolling going on.");
debuglog("updateHeight running without delay");
}
// We might have unmounted since the timer finished, so abort if so.
if (this.unmounted) {
debuglog("updateHeight: abort due to unmount");
return;
}
@ -738,7 +754,7 @@ export default class ScrollPanel extends React.Component<IProps> {
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
if (itemlist.style.height !== newHeight) {
if (itemlist && itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight;
}
if (sn.scrollTop !== sn.scrollHeight) {
@ -753,7 +769,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// the currently filled piece of the timeline
if (trackedNode) {
const oldTop = trackedNode.offsetTop;
if (itemlist.style.height !== newHeight) {
if (itemlist && itemlist.style.height !== newHeight) {
itemlist.style.height = newHeight;
}
const newTop = trackedNode.offsetTop;
@ -768,12 +784,12 @@ export default class ScrollPanel extends React.Component<IProps> {
}
}
private getTrackedNode(): HTMLElement {
private getTrackedNode(): HTMLElement | undefined {
const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode;
if (!trackedNode?.parentElement) {
let node: HTMLElement;
if (!trackedNode?.parentElement && this.itemlist.current) {
let node: HTMLElement | undefined = undefined;
const messages = this.itemlist.current.children;
const scrollToken = scrollState.trackedScrollToken;
@ -781,19 +797,19 @@ export default class ScrollPanel extends React.Component<IProps> {
const m = messages[i] as HTMLElement;
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens?.split(',').includes(scrollToken)) {
if (scrollToken && m.dataset.scrollTokens?.split(",").includes(scrollToken!)) {
node = m;
break;
}
}
if (node) {
debuglog("had to find tracked node again for " + scrollState.trackedScrollToken);
debuglog("had to find tracked node again for token:", scrollState.trackedScrollToken);
}
scrollState.trackedNode = node;
}
if (!scrollState.trackedNode) {
debuglog("No node with ; '"+scrollState.trackedScrollToken+"'");
debuglog("No node with token:", scrollState.trackedScrollToken);
return;
}
@ -806,14 +822,15 @@ export default class ScrollPanel extends React.Component<IProps> {
private getMessagesHeight(): number {
const itemlist = this.itemlist.current;
const lastNode = itemlist.lastElementChild as HTMLElement;
const lastNode = itemlist?.lastElementChild as HTMLElement;
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
const firstNodeTop = itemlist.firstElementChild ? (itemlist.firstElementChild as HTMLElement).offsetTop : 0;
const firstNodeTop = (itemlist?.firstElementChild as HTMLElement)?.offsetTop ?? 0;
// 18 is itemlist padding
return lastNodeBottom - firstNodeTop + (18 * 2);
return lastNodeBottom - firstNodeTop + 18 * 2;
}
private topFromBottom(node: HTMLElement): number {
if (!this.itemlist.current) return -1;
// current capped height - distance from top = distance from bottom of container to top of tracked element
return this.itemlist.current.clientHeight - node.offsetTop;
}
@ -837,19 +854,19 @@ export default class ScrollPanel extends React.Component<IProps> {
return this.divScroll;
}
private collectScroll = (divScroll: HTMLDivElement) => {
private collectScroll = (divScroll: HTMLDivElement | null): void => {
this.divScroll = divScroll;
};
/**
Mark the bottom offset of the last tile so we can balance it out when
Mark the bottom offset of the last tile, so we can balance it out when
anything below it changes, by calling updatePreventShrinking, to keep
the same minimum bottom offset, effectively preventing the timeline to shrink.
*/
public preventShrinking = (): void => {
const messageList = this.itemlist.current;
const tiles = messageList && messageList.children;
if (!messageList) {
const tiles = messageList?.children;
if (!tiles) {
return;
}
let lastTileNode;
@ -876,7 +893,7 @@ export default class ScrollPanel extends React.Component<IProps> {
public clearPreventShrinking = (): void => {
const messageList = this.itemlist.current;
const balanceElement = messageList && messageList.parentElement;
if (balanceElement) balanceElement.style.paddingBottom = null;
if (balanceElement) balanceElement.style.removeProperty("paddingBottom");
this.preventShrinkingState = null;
debuglog("prevent shrinking cleared");
};
@ -890,7 +907,7 @@ export default class ScrollPanel extends React.Component<IProps> {
what it was when marking.
*/
public updatePreventShrinking = (): void => {
if (this.preventShrinkingState) {
if (this.preventShrinkingState && this.itemlist.current) {
const sn = this.getScrollNode();
const scrollState = this.scrollState;
const messageList = this.itemlist.current;
@ -908,7 +925,7 @@ export default class ScrollPanel extends React.Component<IProps> {
if (!shouldClear) {
const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight);
const offsetDiff = offsetFromBottom - currentOffset;
if (offsetDiff > 0) {
if (offsetDiff > 0 && balanceElement) {
balanceElement.style.paddingBottom = `${offsetDiff}px`;
debuglog("update prevent shrinking ", offsetDiff, "px from bottom");
} else if (offsetDiff < 0) {
@ -921,7 +938,7 @@ export default class ScrollPanel extends React.Component<IProps> {
}
};
render() {
public render(): ReactNode {
// TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway.
@ -935,10 +952,10 @@ export default class ScrollPanel extends React.Component<IProps> {
className={`mx_ScrollPanel ${this.props.className}`}
style={this.props.style}
>
{ this.props.fixedChildren }
{this.props.fixedChildren}
<div className="mx_RoomView_messageListWrapper">
<ol ref={this.itemlist} className="mx_RoomView_MessageList" aria-live="polite">
{ this.props.children }
{this.props.children}
</ol>
</div>
</AutoHideScrollbar>

View file

@ -15,16 +15,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, HTMLProps } from 'react';
import { throttle } from 'lodash';
import classNames from 'classnames';
import React, { createRef, HTMLProps } from "react";
import { throttle } from "lodash";
import classNames from "classnames";
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
interface IProps extends HTMLProps<HTMLInputElement> {
onSearch?: (query: string) => void;
onSearch: (query: string) => void;
onCleared?: (source?: string) => void;
onKeyDown?: (ev: React.KeyboardEvent) => void;
onFocus?: (ev: React.FocusEvent) => void;
@ -45,7 +45,7 @@ interface IState {
export default class SearchBox extends React.Component<IProps, IState> {
private search = createRef<HTMLInputElement>();
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -60,9 +60,13 @@ export default class SearchBox extends React.Component<IProps, IState> {
this.onSearch();
};
private onSearch = throttle((): void => {
this.props.onSearch(this.search.current.value);
}, 200, { trailing: true, leading: true });
private onSearch = throttle(
(): void => {
this.props.onSearch(this.search.current?.value ?? "");
},
200,
{ trailing: true, leading: true },
);
private onKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
@ -90,17 +94,27 @@ export default class SearchBox extends React.Component<IProps, IState> {
};
private clearSearch(source?: string): void {
this.search.current.value = "";
if (this.search.current) this.search.current.value = "";
this.onChange();
if (this.props.onCleared) {
this.props.onCleared(source);
}
this.props.onCleared?.(source);
}
public render(): JSX.Element {
public render(): React.ReactNode {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { onSearch, onCleared, onKeyDown, onFocus, onBlur, className = "", placeholder, blurredPlaceholder,
autoFocus, initialValue, collapsed, ...props } = this.props;
const {
onSearch,
onCleared,
onKeyDown,
onFocus,
onBlur,
className = "",
placeholder,
blurredPlaceholder,
autoFocus,
initialValue,
collapsed,
...props
} = this.props;
// check for collapsed here and
// not at parent so we keep
@ -109,19 +123,23 @@ export default class SearchBox extends React.Component<IProps, IState> {
if (collapsed) {
return null;
}
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
(<AccessibleButton
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton"
onClick={() => {this.clearSearch("button"); }}
/>) : undefined;
const clearButton =
!this.state.blurred || this.state.searchTerm ? (
<AccessibleButton
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton"
onClick={() => {
this.clearSearch("button");
}}
/>
) : undefined;
// show a shorter placeholder when blurred, if requested
// this is used for the room filter field that has
// the explore button next to it when blurred
return (
<div className={classNames("mx_SearchBox", "mx_textinput", { "mx_SearchBox_blurred": this.state.blurred })}>
<div className={classNames("mx_SearchBox", "mx_textinput", { mx_SearchBox_blurred: this.state.blurred })}>
<input
{...props}
key="searchfield"
@ -133,11 +151,12 @@ export default class SearchBox extends React.Component<IProps, IState> {
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onBlur={this.onBlur}
placeholder={this.state.blurred ? (blurredPlaceholder || placeholder) : placeholder}
placeholder={this.state.blurred ? blurredPlaceholder || placeholder : placeholder}
autoComplete="off"
autoFocus={this.props.autoFocus}
data-testid="searchbox-input"
/>
{ clearButton }
{clearButton}
</div>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,11 @@ interface Props extends DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLEleme
children?: ReactNode;
}
export default function SplashPage({ children, className, ...other }: Props) {
export default function SplashPage({ children, className, ...other }: Props): JSX.Element {
const classes = classNames(className, "mx_SplashPage");
return <main {...other} className={classes}>
{ children }
</main>;
return (
<main {...other} className={classes}>
{children}
</main>
);
}

View file

@ -20,15 +20,16 @@ import * as React from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../languageHandler';
import AutoHideScrollbar from './AutoHideScrollbar';
import AccessibleButton from "../views/elements/AccessibleButton";
import { _t, TranslationKey } from "../../languageHandler";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { PosthogScreenTracker, ScreenName } from "../../PosthogTrackers";
import { NonEmptyArray } from "../../@types/common";
import { RovingAccessibleButton, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
/**
* Represents a tab for the TabbedView.
*/
export class Tab {
export class Tab<T extends string> {
/**
* Creates a new tab.
* @param {string} id The tab's ID.
@ -37,48 +38,48 @@ export class Tab {
* @param {React.ReactNode} body The JSX for the tab container.
* @param {string} screenName The screen name to report to Posthog.
*/
constructor(
public readonly id: string,
public readonly label: string,
public readonly icon: string,
public constructor(
public readonly id: T,
public readonly label: TranslationKey,
public readonly icon: string | null,
public readonly body: React.ReactNode,
public readonly screenName?: ScreenName,
) {}
}
export enum TabLocation {
LEFT = 'left',
TOP = 'top',
LEFT = "left",
TOP = "top",
}
interface IProps {
tabs: Tab[];
initialTabId?: string;
interface IProps<T extends string> {
tabs: NonEmptyArray<Tab<T>>;
initialTabId?: T;
tabLocation: TabLocation;
onChange?: (tabId: string) => void;
onChange?: (tabId: T) => void;
screenName?: ScreenName;
}
interface IState {
activeTabId: string;
interface IState<T extends string> {
activeTabId: T;
}
export default class TabbedView extends React.Component<IProps, IState> {
constructor(props: IProps) {
export default class TabbedView<T extends string> extends React.Component<IProps<T>, IState<T>> {
public constructor(props: IProps<T>) {
super(props);
const initialTabIdIsValid = props.tabs.find(tab => tab.id === props.initialTabId);
const initialTabIdIsValid = props.tabs.find((tab) => tab.id === props.initialTabId);
this.state = {
activeTabId: initialTabIdIsValid ? props.initialTabId : props.tabs[0]?.id,
activeTabId: initialTabIdIsValid ? props.initialTabId! : props.tabs[0].id,
};
}
static defaultProps = {
public static defaultProps = {
tabLocation: TabLocation.LEFT,
};
private getTabById(id: string): Tab | undefined {
return this.props.tabs.find(tab => tab.id === id);
private getTabById(id: T): Tab<T> | undefined {
return this.props.tabs.find((tab) => tab.id === id);
}
/**
@ -86,7 +87,7 @@ 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<T>): void {
// make sure this tab is still in available tabs
if (!!this.getTabById(tab.id)) {
if (this.props.onChange) this.props.onChange(tab.id);
@ -96,62 +97,87 @@ export default class TabbedView extends React.Component<IProps, IState> {
}
}
private renderTabLabel(tab: Tab) {
let classes = "mx_TabbedView_tabLabel ";
private renderTabLabel(tab: Tab<T>): JSX.Element {
const isActive = this.state.activeTabId === tab.id;
const classes = classNames("mx_TabbedView_tabLabel", {
mx_TabbedView_tabLabel_active: isActive,
});
if (this.state.activeTabId === tab.id) classes += "mx_TabbedView_tabLabel_active";
let tabIcon = null;
let tabIcon: JSX.Element | undefined;
if (tab.icon) {
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
}
const onClickHandler = () => this.setActiveTab(tab);
const onClickHandler = (): void => this.setActiveTab(tab);
const id = this.getTabId(tab);
const label = _t(tab.label);
return (
<AccessibleButton
<RovingAccessibleButton
className={classes}
key={"tab_label_" + tab.label}
onClick={onClickHandler}
data-testid={`settings-tab-${tab.id}`}
role="tab"
aria-selected={isActive}
aria-controls={id}
element="li"
>
{ tabIcon }
<span className="mx_TabbedView_tabLabel_text">
{ label }
{tabIcon}
<span className="mx_TabbedView_tabLabel_text" id={`${id}_label`}>
{label}
</span>
</AccessibleButton>
</RovingAccessibleButton>
);
}
private renderTabPanel(tab: Tab): React.ReactNode {
private getTabId(tab: Tab<T>): string {
return `mx_tabpanel_${tab.id}`;
}
private renderTabPanel(tab: Tab<T>): React.ReactNode {
const id = this.getTabId(tab);
return (
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
<AutoHideScrollbar className='mx_TabbedView_tabPanelContent'>
{ tab.body }
</AutoHideScrollbar>
<div className="mx_TabbedView_tabPanel" key={id} id={id} aria-labelledby={`${id}_label`}>
<AutoHideScrollbar className="mx_TabbedView_tabPanelContent">{tab.body}</AutoHideScrollbar>
</div>
);
}
public render(): React.ReactNode {
const labels = this.props.tabs.map(tab => this.renderTabLabel(tab));
const labels = this.props.tabs.map((tab) => this.renderTabLabel(tab));
const tab = this.getTabById(this.state.activeTabId);
const panel = tab ? this.renderTabPanel(tab) : null;
const tabbedViewClasses = classNames({
'mx_TabbedView': true,
'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT,
'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP,
mx_TabbedView: true,
mx_TabbedView_tabsOnLeft: this.props.tabLocation == TabLocation.LEFT,
mx_TabbedView_tabsOnTop: this.props.tabLocation == TabLocation.TOP,
});
const screenName = tab?.screenName ?? this.props.screenName;
return (
<div className={tabbedViewClasses}>
<PosthogScreenTracker screenName={tab?.screenName ?? this.props.screenName} />
<div className="mx_TabbedView_tabLabels">
{ labels }
</div>
{ panel }
{screenName && <PosthogScreenTracker screenName={screenName} />}
<RovingTabIndexProvider
handleLoop
handleHomeEnd
handleLeftRight={this.props.tabLocation == TabLocation.TOP}
handleUpDown={this.props.tabLocation == TabLocation.LEFT}
>
{({ onKeyDownHandler }) => (
<ul
className="mx_TabbedView_tabLabels"
role="tablist"
aria-orientation={this.props.tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"}
onKeyDown={onKeyDownHandler}
>
{labels}
</ul>
)}
</RovingTabIndexProvider>
{panel}
</div>
);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,33 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useEffect, useRef, useState } from 'react';
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { Room } from 'matrix-js-sdk/src/models/room';
import { Optional } from "matrix-events-sdk";
import React, { useContext, useEffect, useRef, useState } from "react";
import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix";
import BaseCard from "../views/right_panel/BaseCard";
import ResizeNotifier from '../../utils/ResizeNotifier';
import MatrixClientContext from '../../contexts/MatrixClientContext';
import { _t } from '../../languageHandler';
import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton';
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from './ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import TimelinePanel from './TimelinePanel';
import { Layout } from '../../settings/enums/Layout';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import Measured from '../views/elements/Measured';
import ResizeNotifier from "../../utils/ResizeNotifier";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { _t } from "../../languageHandler";
import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from "./ContextMenu";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import TimelinePanel from "./TimelinePanel";
import { Layout } from "../../settings/enums/Layout";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Measured from "../views/elements/Measured";
import PosthogTrackers from "../../PosthogTrackers";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import { BetaPill } from '../views/beta/BetaCard';
import Modal from '../../Modal';
import BetaFeedbackDialog from '../views/dialogs/BetaFeedbackDialog';
import { Action } from '../../dispatcher/actions';
import { UserTab } from '../views/dialogs/UserTab';
import dis from '../../dispatcher/dispatcher';
import { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import Heading from '../views/typography/Heading';
import { shouldShowFeedback } from "../../utils/Feedback";
import Heading from "../views/typography/Heading";
interface IProps {
roomId: string;
@ -51,7 +43,7 @@ interface IProps {
export enum ThreadFilterType {
"My",
"All"
"All",
}
type ThreadPanelHeaderOption = {
@ -60,81 +52,86 @@ type ThreadPanelHeaderOption = {
key: ThreadFilterType;
};
export const ThreadPanelHeaderFilterOptionItem = ({
label,
description,
onClick,
isSelected,
}: ThreadPanelHeaderOption & {
onClick: () => void;
isSelected: boolean;
}) => {
return <MenuItemRadio
active={isSelected}
className="mx_ThreadPanel_Header_FilterOptionItem"
onClick={onClick}
>
<span>{ label }</span>
<span>{ description }</span>
</MenuItemRadio>;
export const ThreadPanelHeaderFilterOptionItem: React.FC<
ThreadPanelHeaderOption & {
onClick: () => void;
isSelected: boolean;
}
> = ({ label, description, onClick, isSelected }) => {
return (
<MenuItemRadio active={isSelected} className="mx_ThreadPanel_Header_FilterOptionItem" onClick={onClick}>
<span>{label}</span>
<span>{description}</span>
</MenuItemRadio>
);
};
export const ThreadPanelHeader = ({ filterOption, setFilterOption, empty }: {
export const ThreadPanelHeader: React.FC<{
filterOption: ThreadFilterType;
setFilterOption: (filterOption: ThreadFilterType) => void;
empty: boolean;
}) => {
}> = ({ filterOption, setFilterOption, empty }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
const options: readonly ThreadPanelHeaderOption[] = [
{
label: _t("All threads"),
description: _t('Shows all threads from current room'),
label: _t("threads|all_threads"),
description: _t("threads|all_threads_description"),
key: ThreadFilterType.All,
},
{
label: _t("My threads"),
description: _t("Shows all threads you've participated in"),
label: _t("threads|my_threads"),
description: _t("threads|my_threads_description"),
key: ThreadFilterType.My,
},
];
const value = options.find(option => option.key === filterOption);
const contextMenuOptions = options.map(opt => <ThreadPanelHeaderFilterOptionItem
key={opt.key}
label={opt.label}
description={opt.description}
onClick={() => {
setFilterOption(opt.key);
closeMenu();
}}
isSelected={opt === value}
/>);
const contextMenu = menuDisplayed ? <ContextMenu
top={108}
right={33}
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
wrapperClassName="mx_BaseCard_header_title"
>
{ contextMenuOptions }
</ContextMenu> : null;
return <div className="mx_BaseCard_header_title">
<Heading size="h4" className="mx_BaseCard_header_title_heading">{ _t("Threads") }</Heading>
{ !empty && <>
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
inputRef={button}
isExpanded={menuDisplayed}
onClick={(ev: ButtonEvent) => {
openMenu();
PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev);
}}
>
{ `${_t('Show:')} ${value.label}` }
</ContextMenuButton>
{ contextMenu }
</> }
</div>;
const value = options.find((option) => option.key === filterOption);
const contextMenuOptions = options.map((opt) => (
<ThreadPanelHeaderFilterOptionItem
key={opt.key}
label={opt.label}
description={opt.description}
onClick={() => {
setFilterOption(opt.key);
closeMenu();
}}
isSelected={opt === value}
/>
));
const contextMenu = menuDisplayed ? (
<ContextMenu
top={108}
right={33}
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
wrapperClassName="mx_BaseCard_header_title"
>
{contextMenuOptions}
</ContextMenu>
) : null;
return (
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("common|threads")}
</Heading>
{!empty && (
<>
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
ref={button}
isExpanded={menuDisplayed}
onClick={(ev: ButtonEvent) => {
openMenu();
PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev);
}}
>
{`${_t("threads|show_thread_filter")} ${value?.label}`}
</ContextMenuButton>
{contextMenu}
</>
)}
</div>
);
};
interface EmptyThreadIProps {
@ -146,141 +143,108 @@ interface EmptyThreadIProps {
const EmptyThread: React.FC<EmptyThreadIProps> = ({ hasThreads, filterOption, showAllThreadsCallback }) => {
let body: JSX.Element;
if (hasThreads) {
body = <>
<p>
{ _t("Reply to an ongoing thread or use “%(replyInThread)s” "
+ "when hovering over a message to start a new one.", {
replyInThread: _t("Reply in thread"),
}) }
</p>
<p>
{ /* Always display that paragraph to prevent layout shift when hiding the button */ }
{ (filterOption === ThreadFilterType.My)
? <button onClick={showAllThreadsCallback}>{ _t("Show all threads") }</button>
: <>&nbsp;</>
}
</p>
</>;
body = (
<>
<p>
{_t("threads|empty_has_threads_tip", {
replyInThread: _t("action|reply_in_thread"),
})}
</p>
<p>
{/* Always display that paragraph to prevent layout shift when hiding the button */}
{filterOption === ThreadFilterType.My ? (
<button onClick={showAllThreadsCallback}>{_t("threads|show_all_threads")}</button>
) : (
<>&nbsp;</>
)}
</p>
</>
);
} else {
body = <>
<p>{ _t("Threads help keep your conversations on-topic and easy to track.") }</p>
<p className="mx_ThreadPanel_empty_tip">
{ _t('<b>Tip:</b> Use “%(replyInThread)s” when hovering over a message.', {
replyInThread: _t("Reply in thread"),
}, {
b: sub => <b>{ sub }</b>,
}) }
</p>
</>;
body = (
<>
<p>{_t("threads|empty_explainer")}</p>
<p className="mx_ThreadPanel_empty_tip">
{_t(
"threads|empty_tip",
{
replyInThread: _t("action|reply_in_thread"),
},
{
b: (sub) => <b>{sub}</b>,
},
)}
</p>
</>
);
}
return <aside className="mx_ThreadPanel_empty">
<div className="mx_ThreadPanel_largeIcon" />
<h2>{ _t("Keep discussions organised with threads") }</h2>
{ body }
</aside>;
return (
<div className="mx_ThreadPanel_empty">
<div className="mx_ThreadPanel_largeIcon" />
<h2>{_t("threads|empty_heading")}</h2>
{body}
</div>
);
};
const ThreadPanel: React.FC<IProps> = ({
roomId,
onClose,
permalinkCreator,
}) => {
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) => {
const mxClient = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const timelinePanel = useRef<TimelinePanel>();
const card = useRef<HTMLDivElement>();
const timelinePanel = useRef<TimelinePanel | null>(null);
const card = useRef<HTMLDivElement | null>(null);
const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
const [room, setRoom] = useState<Room | null>(null);
const [timelineSet, setTimelineSet] = useState<EventTimelineSet | null>(null);
const [narrow, setNarrow] = useState<boolean>(false);
const timelineSet: Optional<EventTimelineSet> =
filterOption === ThreadFilterType.My ? room?.threadsTimelineSets[1] : room?.threadsTimelineSets[0];
const hasThreads = Boolean(room?.threadsTimelineSets?.[0]?.getLiveTimeline()?.getEvents()?.length);
useEffect(() => {
const room = mxClient.getRoom(roomId);
room.createThreadsTimelineSets().then(() => {
return room.fetchRoomThreads();
}).then(() => {
setFilterOption(ThreadFilterType.All);
setRoom(room);
});
room
?.createThreadsTimelineSets()
.then(() => room.fetchRoomThreads())
.then(() => {
setFilterOption(ThreadFilterType.All);
setRoom(room);
});
}, [mxClient, roomId]);
useEffect(() => {
function refreshTimeline() {
timelinePanel?.current.refreshTimeline();
}
room?.on(ThreadEvent.Update, refreshTimeline);
return () => {
room?.removeListener(ThreadEvent.Update, refreshTimeline);
};
}, [room, mxClient, timelineSet]);
useEffect(() => {
if (room) {
if (filterOption === ThreadFilterType.My) {
setTimelineSet(room.threadsTimelineSets[1]);
} else {
setTimelineSet(room.threadsTimelineSets[0]);
}
}
}, [room, filterOption]);
useEffect(() => {
if (timelineSet && !Thread.hasServerSideSupport) {
timelinePanel.current.refreshTimeline();
timelinePanel.current?.refreshTimeline();
}
}, [timelineSet, timelinePanel]);
const openFeedback = shouldShowFeedback() ? () => {
Modal.createDialog(BetaFeedbackDialog, {
featureId: "feature_thread",
});
} : null;
return (
<RoomContext.Provider value={{
...roomContext,
timelineRenderingType: TimelineRenderingType.ThreadsList,
showHiddenEvents: true,
narrow,
}}>
<RoomContext.Provider
value={{
...roomContext,
timelineRenderingType: TimelineRenderingType.ThreadsList,
showHiddenEvents: true,
narrow,
}}
>
<BaseCard
header={<ThreadPanelHeader
filterOption={filterOption}
setFilterOption={setFilterOption}
empty={!timelineSet?.getLiveTimeline()?.getEvents().length}
/>}
footer={<>
<BetaPill
tooltipTitle={_t("Threads are a beta feature")}
tooltipCaption={_t("Click for more info")}
onClick={() => {
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}
header={
<ThreadPanelHeader
filterOption={filterOption}
setFilterOption={setFilterOption}
empty={!hasThreads}
/>
{ openFeedback && _t("<a>Give feedback</a>", {}, {
a: sub =>
<AccessibleButton kind="link_inline" onClick={openFeedback}>{ sub }</AccessibleButton>,
}) }
</>}
}
className="mx_ThreadPanel"
onClose={onClose}
withoutScrollContainer={true}
ref={card}
>
<Measured
sensor={card.current}
onMeasurement={setNarrow}
/>
{ timelineSet
? <TimelinePanel
key={timelineSet.getFilter()?.filterId ?? (roomId + ":" + filterOption)}
{card.current && <Measured sensor={card.current} onMeasurement={setNarrow} />}
{timelineSet ? (
<TimelinePanel
key={filterOption + ":" + (timelineSet.getFilter()?.filterId ?? roomId)}
ref={timelinePanel}
showReadReceipts={false} // No RR support in thread's list
manageReadReceipts={false} // No RR support in thread's list
@ -288,11 +252,13 @@ const ThreadPanel: React.FC<IProps> = ({
sendReadReceiptOnLoad={false} // No RR support in thread's list
timelineSet={timelineSet}
showUrlPreview={false} // No URL previews at the threads list level
empty={<EmptyThread
hasThreads={room.threadsTimelineSets?.[0]?.getLiveTimeline().getEvents().length > 0}
filterOption={filterOption}
showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)}
/>}
empty={
<EmptyThread
hasThreads={hasThreads}
filterOption={filterOption}
showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)}
/>
}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
@ -303,10 +269,11 @@ const ThreadPanel: React.FC<IProps> = ({
permalinkCreator={permalinkCreator}
disableGrouping={true}
/>
: <div className="mx_AutoHideScrollbar">
) : (
<div className="mx_AutoHideScrollbar">
<Spinner />
</div>
}
)}
</BaseCard>
</RoomContext.Provider>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,45 +14,51 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, KeyboardEvent } from 'react';
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room';
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { logger } from 'matrix-js-sdk/src/logger';
import classNames from 'classnames';
import React, { createRef, KeyboardEvent } from "react";
import {
Thread,
THREAD_RELATION_TYPE,
ThreadEvent,
Room,
RoomEvent,
IEventRelation,
MatrixEvent,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import classNames from "classnames";
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
import ResizeNotifier from '../../utils/ResizeNotifier';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/enums/Layout';
import TimelinePanel from './TimelinePanel';
import ResizeNotifier from "../../utils/ResizeNotifier";
import MessageComposer from "../views/rooms/MessageComposer";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import { Layout } from "../../settings/enums/Layout";
import TimelinePanel from "./TimelinePanel";
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';
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';
import ContentMessages from '../../ContentMessages';
import UploadBar from './UploadBar';
import { _t } from '../../languageHandler';
import ThreadListContextMenu from '../views/context_menus/ThreadListContextMenu';
import RightPanelStore from '../../stores/right-panel/RightPanelStore';
import { ActionPayload } from "../../dispatcher/payloads";
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";
import ContentMessages from "../../ContentMessages";
import UploadBar from "./UploadBar";
import { _t } from "../../languageHandler";
import ThreadListContextMenu from "../views/context_menus/ThreadListContextMenu";
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import SettingsStore from "../../settings/SettingsStore";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import FileDropTarget from "./FileDropTarget";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import Measured from '../views/elements/Measured';
import Measured from "../views/elements/Measured";
import PosthogTrackers from "../../PosthogTrackers";
import { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload";
import Heading from '../views/typography/Heading';
import { SdkContextClass } from '../../contexts/SDKContext';
import { ThreadPayload } from '../../dispatcher/payloads/ThreadPayload';
import Heading from "../views/typography/Heading";
import { SdkContextClass } from "../../contexts/SDKContext";
import { ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
interface IProps {
room: Room;
@ -76,18 +82,22 @@ interface IState {
}
export default class ThreadView extends React.Component<IProps, IState> {
static contextType = RoomContext;
public static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
private dispatcherRef: string;
private dispatcherRef: string | null = null;
private readonly layoutWatcherRef: string;
private timelinePanel = createRef<TimelinePanel>();
private card = createRef<HTMLDivElement>();
constructor(props: IProps) {
// Set by setEventId in ctor.
private eventId!: string;
public constructor(props: IProps) {
super(props);
const thread = this.props.room.getThread(this.props.mxEvent.getId());
this.setEventId(this.props.mxEvent);
const thread = this.props.room.getThread(this.eventId) ?? undefined;
this.setupThreadListeners(thread);
this.state = {
@ -99,7 +109,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
}),
};
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, (...[,,, value]) =>
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, (...[, , , value]) =>
this.setState({ layout: value as Layout }),
);
}
@ -108,22 +118,20 @@ export default class ThreadView extends React.Component<IProps, IState> {
if (this.state.thread) {
this.postThreadUpdate(this.state.thread);
}
this.setupThread(this.props.mxEvent);
this.dispatcherRef = dis.register(this.onAction);
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
room.on(ThreadEvent.New, this.onNewThread);
this.props.room.on(ThreadEvent.New, this.onNewThread);
}
public componentWillUnmount(): void {
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
const roomId = this.props.mxEvent.getRoomId();
const room = MatrixClientPeg.get().getRoom(roomId);
room.removeListener(ThreadEvent.New, this.onNewThread);
SettingsStore.unwatchSetting(this.layoutWatcherRef);
const hasRoomChanged = SdkContextClass.instance.roomViewStore.getRoomId() !== roomId;
if (this.props.isInitialEventHighlighted && !hasRoomChanged) {
if (this.props.initialEvent && !hasRoomChanged) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
@ -135,10 +143,15 @@ export default class ThreadView extends React.Component<IProps, IState> {
action: Action.ViewThread,
thread_id: null,
});
this.state.thread?.off(ThreadEvent.NewReply, this.updateThreadRelation);
this.props.room.off(RoomEvent.LocalEchoUpdated, this.updateThreadRelation);
this.props.room.removeListener(ThreadEvent.New, this.onNewThread);
}
public componentDidUpdate(prevProps) {
public componentDidUpdate(prevProps: IProps): void {
if (prevProps.mxEvent !== this.props.mxEvent) {
this.setEventId(this.props.mxEvent);
this.setupThread(this.props.mxEvent);
}
@ -147,6 +160,14 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
}
private setEventId(event: MatrixEvent): void {
if (!event.getId()) {
throw new Error("Got thread event without id");
}
this.eventId = event.getId()!;
}
private onAction = (payload: ActionPayload): void => {
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
this.setupThread(payload.event);
@ -169,15 +190,18 @@ export default class ThreadView extends React.Component<IProps, IState> {
if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return;
// Quit early if that's not a thread event
if (payload.event && !payload.event.getThread()) return;
this.setState({
editState: payload.event ? new EditorStateTransfer(payload.event) : null,
}, () => {
if (payload.event) {
this.timelinePanel.current?.scrollToEventIfNeeded(payload.event.getId());
}
});
this.setState(
{
editState: payload.event ? new EditorStateTransfer(payload.event) : undefined,
},
() => {
if (payload.event) {
this.timelinePanel.current?.scrollToEventIfNeeded(payload.event.getId());
}
},
);
break;
case 'reply_to_event':
case "reply_to_event":
if (payload.context === TimelineRenderingType.Thread) {
this.setState({
replyToEvent: payload.event,
@ -189,15 +213,25 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
};
private setupThread = (mxEv: MatrixEvent) => {
let thread = this.props.room.getThread(mxEv.getId());
private setupThread = (mxEv: MatrixEvent): void => {
/** presence of event Id has been ensured by {@link setEventId} */
const eventId = mxEv.getId()!;
let thread = this.props.room.getThread(eventId);
if (!thread) {
thread = this.props.room.createThread(mxEv.getId(), mxEv, [mxEv], true);
const events = [];
// if the event is still being sent, don't include it in the Thread yet - otherwise the timeline panel
// will attempt to show it twice (once as a regular event, once as a pending event) and everything will
// blow up
if (mxEv.status === null) events.push(mxEv);
thread = this.props.room.createThread(eventId, mxEv, events, true);
}
this.updateThread(thread);
};
private onNewThread = (thread: Thread) => {
private onNewThread = (thread: Thread): void => {
if (thread.id === this.props.mxEvent.getId()) {
this.setupThread(this.props.mxEvent);
}
@ -210,20 +244,25 @@ export default class ThreadView extends React.Component<IProps, IState> {
};
private get threadLastReply(): MatrixEvent | undefined {
return this.state.thread?.lastReply((ev: MatrixEvent) => {
return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
});
return (
this.state.thread?.lastReply((ev: MatrixEvent) => {
return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
}) ?? undefined
);
}
private updateThread = (thread?: Thread) => {
private updateThread = (thread?: Thread): void => {
if (this.state.thread === thread) return;
this.setupThreadListeners(thread, this.state.thread);
if (thread) {
this.setState({
thread,
lastReply: this.threadLastReply,
}, async () => this.postThreadUpdate(thread));
this.setState(
{
thread,
lastReply: this.threadLastReply,
},
async () => this.postThreadUpdate(thread),
);
}
};
@ -239,7 +278,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void {
if (oldThread) {
this.state.thread.off(ThreadEvent.NewReply, this.updateThreadRelation);
this.state.thread?.off(ThreadEvent.NewReply, this.updateThreadRelation);
this.props.room.off(RoomEvent.LocalEchoUpdated, this.updateThreadRelation);
}
if (thread) {
@ -249,8 +288,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
private resetJumpToEvent = (event?: string): void => {
if (this.props.initialEvent && this.props.initialEventScrollIntoView &&
event === this.props.initialEvent?.getId()) {
if (
this.props.initialEvent &&
this.props.initialEventScrollIntoView &&
event === this.props.initialEvent?.getId()
) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
@ -267,16 +309,19 @@ export default class ThreadView extends React.Component<IProps, IState> {
this.setState({ narrow });
};
private onKeyDown = (ev: KeyboardEvent) => {
private onKeyDown = (ev: KeyboardEvent): void => {
let handled = false;
const action = getKeyBindingsManager().getRoomAction(ev);
switch (action) {
case KeyBindingAction.UploadFile: {
dis.dispatch({
action: "upload_file",
context: TimelineRenderingType.Thread,
}, true);
dis.dispatch(
{
action: "upload_file",
context: TimelineRenderingType.Thread,
},
true,
);
handled = true;
break;
}
@ -288,14 +333,14 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
};
private onFileDrop = (dataTransfer: DataTransfer) => {
private onFileDrop = (dataTransfer: DataTransfer): void => {
const roomId = this.props.mxEvent.getRoomId();
if (roomId) {
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(dataTransfer.files),
roomId,
this.threadRelation,
MatrixClientPeg.get(),
MatrixClientPeg.safeGet(),
TimelineRenderingType.Thread,
);
} else {
@ -304,16 +349,16 @@ export default class ThreadView extends React.Component<IProps, IState> {
};
private get threadRelation(): IEventRelation {
const relation = {
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": this.state.thread?.id,
"is_falling_back": true,
const relation: IEventRelation = {
rel_type: THREAD_RELATION_TYPE.name,
event_id: this.state.thread?.id,
is_falling_back: true,
};
const fallbackEventId = this.state.lastReply?.getId() ?? this.state.thread?.id;
if (fallbackEventId) {
relation["m.in_reply_to"] = {
"event_id": fallbackEventId,
event_id: fallbackEventId,
};
}
@ -321,71 +366,78 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
private renderThreadViewHeader = (): JSX.Element => {
return <div className="mx_BaseCard_header_title">
<Heading size="h4" className="mx_BaseCard_header_title_heading">{ _t("Thread") }</Heading>
<ThreadListContextMenu
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} />
</div>;
return (
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("common|thread")}
</Heading>
<ThreadListContextMenu mxEvent={this.props.mxEvent} permalinkCreator={this.props.permalinkCreator} />
</div>
);
};
public render(): JSX.Element {
const highlightedEventId = this.props.isInitialEventHighlighted
? this.props.initialEvent?.getId()
: null;
public render(): React.ReactNode {
const highlightedEventId = this.props.isInitialEventHighlighted ? this.props.initialEvent?.getId() : undefined;
const threadRelation = this.threadRelation;
let timeline: JSX.Element | null;
if (this.state.thread) {
if (this.props.initialEvent && this.props.initialEvent.getRoomId() !== this.state.thread.roomId) {
logger.warn("ThreadView attempting to render TimelinePanel with mismatched initialEvent",
logger.warn(
"ThreadView attempting to render TimelinePanel with mismatched initialEvent",
this.state.thread.roomId,
this.props.initialEvent.getRoomId(),
this.props.initialEvent.getId(),
);
}
timeline = <>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} />
<TimelinePanel
key={this.state.thread.id}
ref={this.timelinePanel}
showReadReceipts={true}
manageReadReceipts={true}
manageReadMarkers={true}
sendReadReceiptOnLoad={true}
timelineSet={this.state.thread.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// ThreadView doesn't support IRC layout at this time
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.props.initialEvent?.getId()}
highlightedEventId={highlightedEventId}
eventScrollIntoView={this.props.initialEventScrollIntoView}
onEventScrolledIntoView={this.resetJumpToEvent}
/>
</>;
timeline = (
<>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} />
<TimelinePanel
key={this.state.thread.id}
ref={this.timelinePanel}
showReadReceipts={this.context.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={true}
sendReadReceiptOnLoad={true}
timelineSet={this.state.thread.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// ThreadView doesn't support IRC layout at this time
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.props.initialEvent?.getId()}
highlightedEventId={highlightedEventId}
eventScrollIntoView={this.props.initialEventScrollIntoView}
onEventScrolledIntoView={this.resetJumpToEvent}
/>
</>
);
} else {
timeline = <div className="mx_RoomView_messagePanelSpinner">
<Spinner />
</div>;
timeline = (
<div className="mx_RoomView_messagePanelSpinner">
<Spinner />
</div>
);
}
return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Thread,
threadId: this.state.thread?.id,
liveTimeline: this.state?.thread?.timelineSet?.getLiveTimeline(),
narrow: this.state.narrow,
}}>
<RoomContext.Provider
value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Thread,
threadId: this.state.thread?.id,
liveTimeline: this.state?.thread?.timelineSet?.getLiveTimeline(),
narrow: this.state.narrow,
}}
>
<BaseCard
className={classNames("mx_ThreadView mx_ThreadPanel", {
mx_ThreadView_narrow: this.state.narrow,
@ -399,27 +451,24 @@ export default class ThreadView extends React.Component<IProps, IState> {
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
}}
>
<Measured
sensor={this.card.current}
onMeasurement={this.onMeasurement}
/>
<div className="mx_ThreadView_timelinePanelWrapper">
{ timeline }
</div>
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
{ ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />
) }
)}
{ this.state.thread?.timelineSet && (<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>) }
{this.state.thread?.timelineSet && (
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
</BaseCard>
</RoomContext.Provider>
);

File diff suppressed because it is too large Load diff

View file

@ -25,8 +25,8 @@ interface IState {
}
export default class ToastContainer extends React.Component<{}, IState> {
constructor(props, context) {
super(props, context);
public constructor(props: {}) {
super(props);
this.state = {
toasts: ToastStore.sharedInstance().getToasts(),
countSeen: ToastStore.sharedInstance().getCountSeen(),
@ -36,21 +36,21 @@ 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);
public componentWillUnmount(): void {
ToastStore.sharedInstance().removeListener("update", this.onToastStoreUpdate);
}
private onToastStoreUpdate = () => {
private onToastStoreUpdate = (): void => {
this.setState({
toasts: ToastStore.sharedInstance().getToasts(),
countSeen: ToastStore.sharedInstance().getCountSeen(),
});
};
render() {
public render(): React.ReactNode {
const totalCount = this.state.toasts.length;
const isStacked = totalCount > 1;
let toast;
@ -60,7 +60,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
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_hasIcon: icon,
[`mx_Toast_icon_${icon}`]: icon,
});
const toastProps = Object.assign({}, props, {
@ -70,7 +70,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
const content = React.createElement(component, toastProps);
let countIndicator;
if (title && isStacked || this.state.countSeen > 0) {
if ((title && isStacked) || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
@ -78,29 +78,27 @@ export default class ToastContainer extends React.Component<{}, IState> {
if (title) {
titleElement = (
<div className="mx_Toast_title">
<h2>{ title }</h2>
<span className="mx_Toast_title_countIndicator">{ countIndicator }</span>
<h2>{title}</h2>
<span className="mx_Toast_title_countIndicator">{countIndicator}</span>
</div>
);
}
toast = (
<div className={toastClasses}>
{ titleElement }
<div className={bodyClasses}>{ content }</div>
{titleElement}
<div className={bodyClasses}>{content}</div>
</div>
);
containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
mx_ToastContainer_stacked: isStacked,
});
}
return toast
? (
<div className={containerClasses} role="alert">
{ toast }
</div>
)
: null;
return toast ? (
<div className={containerClasses} role="alert">
{toast}
</div>
) : null;
}
}

View file

@ -14,21 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import filesize from "filesize";
import { IEventRelation } from 'matrix-js-sdk/src/matrix';
import React from "react";
import { Room, IEventRelation } from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import ContentMessages from '../../ContentMessages';
import ContentMessages from "../../ContentMessages";
import dis from "../../dispatcher/dispatcher";
import { _t } from '../../languageHandler';
import { _t } from "../../languageHandler";
import { Action } from "../../dispatcher/actions";
import ProgressBar from "../views/elements/ProgressBar";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import { RoomUpload } from "../../models/RoomUpload";
import { ActionPayload } from '../../dispatcher/payloads';
import { ActionPayload } from "../../dispatcher/payloads";
import { UploadPayload } from "../../dispatcher/payloads/UploadPayload";
import { fileSize } from "../../utils/FileUtils";
interface IProps {
room: Room;
@ -57,7 +56,7 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
private dispatcherRef: Optional<string>;
private mounted = false;
constructor(props) {
public constructor(props: IProps) {
super(props);
// Set initial state to any available upload in this room - we might be mounting
@ -65,19 +64,19 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
this.state = this.calculateState();
}
componentDidMount() {
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
this.mounted = true;
}
componentWillUnmount() {
public componentWillUnmount(): void {
this.mounted = false;
dis.unregister(this.dispatcherRef!);
}
private getUploadsInRoom(): RoomUpload[] {
const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation);
return uploads.filter(u => u.roomId === this.props.room.roomId);
return uploads.filter((u) => u.roomId === this.props.room.roomId);
}
private calculateState(): IState {
@ -91,36 +90,43 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
};
}
private onAction = (payload: ActionPayload) => {
private onAction = (payload: ActionPayload): void => {
if (!this.mounted) return;
if (isUploadPayload(payload)) {
this.setState(this.calculateState());
}
};
private onCancelClick = (ev: ButtonEvent) => {
private onCancelClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload!);
};
render() {
public render(): React.ReactNode {
if (!this.state.currentFile) {
return null;
}
// MUST use var name 'count' for pluralization to kick in
const uploadText = _t(
"Uploading %(filename)s and %(count)s others", {
let uploadText: string;
if (this.state.countFiles > 1) {
// MUST use var name 'count' for pluralization to kick in
uploadText = _t("room|upload|uploading_multiple_file", {
filename: this.state.currentFile,
count: this.state.countFiles - 1,
},
);
});
} else {
uploadText = _t("room|upload|uploading_single_file", {
filename: this.state.currentFile,
});
}
const uploadSize = filesize(this.state.currentTotal!);
const uploadSize = fileSize(this.state.currentTotal!);
return (
<div className="mx_UploadBar">
<div className="mx_UploadBar_filename">{ uploadText } ({ uploadSize })</div>
<AccessibleButton onClick={this.onCancelClick} className='mx_UploadBar_cancel' />
<div className="mx_UploadBar_filename">
{uploadText} ({uploadSize})
</div>
<AccessibleButton onClick={this.onCancelClick} className="mx_UploadBar_cancel" />
<ProgressBar value={this.state.currentLoaded!} max={this.state.currentTotal!} />
</div>
);

View file

@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import React, { createRef, ReactNode } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler";
import { ChevronFace, ContextMenuButton } from "./ContextMenu";
import { ChevronFace, ContextMenuButton, MenuProps } from "./ContextMenu";
import { UserTab } from "../views/dialogs/UserTab";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
@ -30,42 +30,45 @@ import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
import {
RovingAccessibleTooltipButton,
} from "../../accessibility/RovingTabIndex";
import { RovingAccessibleTooltipButton } from "../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import { getHomePageUrl } from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import BaseAvatar from '../views/avatars/BaseAvatar';
import BaseAvatar from "../views/avatars/BaseAvatar";
import { SettingLevel } from "../../settings/SettingLevel";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { UIFeature } from "../../settings/UIFeature";
import HostSignupAction from "./HostSignupAction";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import UserIdentifierCustomisations from "../../customisations/UserIdentifier";
import PosthogTrackers from "../../PosthogTrackers";
import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
import { Icon as LiveIcon } from "../../../res/img/compound/live-8px.svg";
import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast";
import { SDKContext } from "../../contexts/SDKContext";
import { shouldShowFeedback } from "../../utils/Feedback";
interface IProps {
isPanelCollapsed: boolean;
children?: ReactNode;
}
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
interface IState {
contextMenuPosition: PartialDOMRect;
contextMenuPosition: PartialDOMRect | null;
isDarkTheme: boolean;
isHighContrast: boolean;
selectedSpace?: Room;
selectedSpace?: Room | null;
showLiveAvatarAddon: boolean;
}
const toRightOf = (rect: PartialDOMRect) => {
const toRightOf = (rect: PartialDOMRect): MenuProps => {
return {
left: rect.width + rect.left + 8,
top: rect.top,
@ -73,7 +76,7 @@ const toRightOf = (rect: PartialDOMRect) => {
};
};
const below = (rect: PartialDOMRect) => {
const below = (rect: PartialDOMRect): MenuProps => {
return {
left: rect.left,
top: rect.top + rect.height,
@ -82,19 +85,24 @@ const below = (rect: PartialDOMRect) => {
};
export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private readonly dndWatcherRef: string;
public static contextType = SDKContext;
public context!: React.ContextType<typeof SDKContext>;
private dispatcherRef?: string;
private themeWatcherRef?: string;
private readonly dndWatcherRef?: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
this.context = context;
this.state = {
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
selectedSpace: SpaceStore.instance.activeSpaceRoom,
showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(),
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
@ -102,20 +110,34 @@ export default class UserMenu extends React.Component<IProps, IState> {
}
private get hasHomePage(): boolean {
return !!getHomePageUrl(SdkConfig.get());
return !!getHomePageUrl(SdkConfig.get(), this.context.client!);
}
public componentDidMount() {
private onCurrentVoiceBroadcastRecordingChanged = (recording: VoiceBroadcastRecording | null): void => {
this.setState({
showLiveAvatarAddon: recording !== null,
});
};
public componentDidMount(): void {
this.context.voiceBroadcastRecordingsStore.on(
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
this.onCurrentVoiceBroadcastRecordingChanged,
);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
}
public componentWillUnmount() {
public componentWillUnmount(): void {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
this.context.voiceBroadcastRecordingsStore.off(
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
this.onCurrentVoiceBroadcastRecordingChanged,
);
}
private isUserOnDarkTheme(): boolean {
@ -124,7 +146,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
} else {
const theme = SettingsStore.getValue("theme");
if (theme.startsWith("custom-")) {
return getCustomTheme(theme.substring("custom-".length)).is_dark;
return !!getCustomTheme(theme.substring("custom-".length)).is_dark;
}
return theme === "dark";
}
@ -142,27 +164,26 @@ export default class UserMenu extends React.Component<IProps, IState> {
}
}
private onProfileUpdate = async () => {
private onProfileUpdate = async (): Promise<void> => {
// the store triggered an update, so force a layout update. We don't
// have any state to store here for that to magically happen.
this.forceUpdate();
};
private onSelectedSpaceUpdate = async () => {
private onSelectedSpaceUpdate = async (): Promise<void> => {
this.setState({
selectedSpace: SpaceStore.instance.activeSpaceRoom,
});
};
private onThemeChanged = () => {
this.setState(
{
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
});
private onThemeChanged = (): void => {
this.setState({
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
});
};
private onAction = (payload: ActionPayload) => {
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case Action.ToggleUserMenu:
if (this.state.contextMenuPosition) {
@ -174,13 +195,13 @@ export default class UserMenu extends React.Component<IProps, IState> {
}
};
private onOpenMenuClick = (ev: React.MouseEvent) => {
private onOpenMenuClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.setState({ contextMenuPosition: ev.currentTarget.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent) => {
private onContextMenu = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
@ -193,11 +214,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
});
};
private onCloseMenu = () => {
private onCloseMenu = (): void => {
this.setState({ contextMenuPosition: null });
};
private onSwitchThemeClick = (ev: React.MouseEvent) => {
private onSwitchThemeClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
@ -216,7 +237,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
};
private onSettingsOpen = (ev: ButtonEvent, tabId: string) => {
private onSettingsOpen = (ev: ButtonEvent, tabId?: string): void => {
ev.preventDefault();
ev.stopPropagation();
@ -225,7 +246,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onProvideFeedback = (ev: ButtonEvent) => {
private onProvideFeedback = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
@ -233,32 +254,51 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSignOutClick = async (ev: ButtonEvent) => {
private onSignOutClick = async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
const cli = MatrixClientPeg.get();
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
// log out without user prompt if they have no local megolm sessions
defaultDispatcher.dispatch({ action: 'logout' });
} else {
if (await this.shouldShowLogoutDialog()) {
Modal.createDialog(LogoutDialog);
} else {
defaultDispatcher.dispatch({ action: "logout" });
}
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSignInClick = () => {
defaultDispatcher.dispatch({ action: 'start_login' });
/**
* Checks if the `LogoutDialog` should be shown instead of the simple logout flow.
* The `LogoutDialog` will check the crypto recovery status of the account and
* help the user setup recovery properly if needed.
* @private
*/
private async shouldShowLogoutDialog(): Promise<boolean> {
const cli = MatrixClientPeg.get();
const crypto = cli?.getCrypto();
if (!crypto) return false;
// If any room is encrypted, we need to show the advanced logout flow
const allRooms = cli!.getRooms();
for (const room of allRooms) {
const isE2e = await crypto.isEncryptionEnabledInRoom(room.roomId);
if (isE2e) return true;
}
return false;
}
private onSignInClick = (): void => {
defaultDispatcher.dispatch({ action: "start_login" });
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onRegisterClick = () => {
defaultDispatcher.dispatch({ action: 'start_registration' });
private onRegisterClick = (): void => {
defaultDispatcher.dispatch({ action: "start_registration" });
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => {
private onHomeClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
@ -269,96 +309,98 @@ export default class UserMenu extends React.Component<IProps, IState> {
private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null;
let topSection;
const hostSignupConfig = SdkConfig.getObject("host_signup");
if (MatrixClientPeg.get().isGuest()) {
let topSection: JSX.Element | undefined;
if (MatrixClientPeg.safeGet().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
{ _t("Got an account? <a>Sign in</a>", {}, {
a: sub => (
<AccessibleButton kind="link_inline" onClick={this.onSignInClick}>
{ sub }
</AccessibleButton>
),
}) }
{ _t("New here? <a>Create an account</a>", {}, {
a: sub => (
<AccessibleButton kind="link_inline" onClick={this.onRegisterClick}>
{ sub }
</AccessibleButton>
),
}) }
{_t(
"auth|sign_in_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onSignInClick}>
{sub}
</AccessibleButton>
),
},
)}
{SettingsStore.getValue(UIFeature.Registration)
? _t(
"auth|create_account_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onRegisterClick}>
{sub}
</AccessibleButton>
),
},
)
: null}
</div>
);
} else if (hostSignupConfig?.get("url")) {
// If hostSignup.domains is set to a non-empty array, only show
// dialog if the user is on the domain or a subdomain.
const hostSignupDomains = hostSignupConfig.get("domains") || [];
const mxDomain = MatrixClientPeg.get().getDomain();
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
if (!hostSignupConfig.get("domains") || validDomains.length > 0) {
topSection = <HostSignupAction onClick={this.onCloseMenu} />;
}
}
let homeButton = null;
let homeButton: JSX.Element | undefined;
if (this.hasHomePage) {
homeButton = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconHome"
label={_t("Home")}
label={_t("common|home")}
onClick={this.onHomeClick}
/>
);
}
let feedbackButton;
if (SettingsStore.getValue(UIFeature.Feedback)) {
feedbackButton = <IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")}
onClick={this.onProvideFeedback}
/>;
let feedbackButton: JSX.Element | undefined;
if (shouldShowFeedback()) {
feedbackButton = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMessage"
label={_t("common|feedback")}
onClick={this.onProvideFeedback}
/>
);
}
let primaryOptionList = (
<IconizedContextMenuOptionList>
{ homeButton }
{homeButton}
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notifications")}
label={_t("notifications|enable_prompt_toast_title")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & Privacy")}
label={_t("room_settings|security|title")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
label={_t("user_menu|settings")}
onClick={(e) => this.onSettingsOpen(e)}
/>
{ feedbackButton }
{feedbackButton}
<IconizedContextMenuOption
className="mx_IconizedContextMenu_option_red"
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
label={_t("action|sign_out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
);
if (MatrixClientPeg.get().isGuest()) {
if (MatrixClientPeg.safeGet().isGuest()) {
primaryOptionList = (
<IconizedContextMenuOptionList>
{ homeButton }
{homeButton}
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
label={_t("common|settings")}
onClick={(e) => this.onSettingsOpen(e)}
/>
{ feedbackButton }
{feedbackButton}
</IconizedContextMenuOptionList>
);
}
@ -367,78 +409,90 @@ export default class UserMenu extends React.Component<IProps, IState> {
? toRightOf(this.state.contextMenuPosition)
: below(this.state.contextMenuPosition);
return <IconizedContextMenu
{...position}
onFinished={this.onCloseMenu}
className="mx_UserMenu_contextMenu"
>
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{ UserIdentifierCustomisations.getDisplayUserIdentifier(
MatrixClientPeg.get().getUserId(), { withDisplayName: true }) }
</span>
</div>
return (
<IconizedContextMenu {...position} onFinished={this.onCloseMenu} className="mx_UserMenu_contextMenu">
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenu_contextMenu_userId">
{UserIdentifierCustomisations.getDisplayUserIdentifier(
MatrixClientPeg.safeGet().getSafeUserId(),
{
withDisplayName: true,
},
)}
</span>
</div>
<RovingAccessibleTooltipButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/element-icons/roomlist/dark-light-mode.svg").default}
alt={_t("Switch theme")}
width={16}
/>
</RovingAccessibleTooltipButton>
</div>
{ topSection }
{ primaryOptionList }
</IconizedContextMenu>;
<RovingAccessibleTooltipButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={
this.state.isDarkTheme
? _t("user_menu|switch_theme_light")
: _t("user_menu|switch_theme_dark")
}
>
<img
src={require("../../../res/img/element-icons/roomlist/dark-light-mode.svg").default}
role="presentation"
alt=""
width={16}
/>
</RovingAccessibleTooltipButton>
</div>
{topSection}
{primaryOptionList}
</IconizedContextMenu>
);
};
public render() {
public render(): React.ReactNode {
const avatarSize = 32; // should match border-radius of the avatar
const userId = MatrixClientPeg.get().getUserId();
const userId = MatrixClientPeg.safeGet().getSafeUserId();
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
let name: JSX.Element;
let name: JSX.Element | undefined;
if (!this.props.isPanelCollapsed) {
name = <div className="mx_UserMenu_name">
{ displayName }
</div>;
name = <div className="mx_UserMenu_name">{displayName}</div>;
}
return <div className="mx_UserMenu">
<ContextMenuButton
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("User menu")}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
>
<div className="mx_UserMenu_userAvatar">
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar_BaseAvatar"
/>
</div>
{ name }
const liveAvatarAddon = this.state.showLiveAvatarAddon ? (
<div className="mx_UserMenu_userAvatarLive" data-testid="user-menu-live-vb">
<LiveIcon className="mx_Icon_8" />
</div>
) : null;
{ this.renderContextMenu() }
</ContextMenuButton>
return (
<div className="mx_UserMenu">
<ContextMenuButton
className="mx_UserMenu_contextMenuButton"
onClick={this.onOpenMenuClick}
ref={this.buttonRef}
label={_t("a11y|user_menu")}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
>
<div className="mx_UserMenu_userAvatar">
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
size={avatarSize + "px"}
className="mx_UserMenu_userAvatar_BaseAvatar"
/>
{liveAvatarAddon}
</div>
{name}
{this.renderContextMenu()}
</ContextMenuButton>
{ this.props.children }
</div>;
{this.props.children}
</div>
);
}
}

View file

@ -16,12 +16,10 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent, RoomMember, MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import Modal from "../../Modal";
import { _t } from "../../languageHandler";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import MainSplit from "./MainSplit";
import RightPanel from "./RightPanel";
@ -29,9 +27,10 @@ import Spinner from "../views/elements/Spinner";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
import MatrixClientContext from "../../contexts/MatrixClientContext";
interface IProps {
userId?: string;
userId: string;
resizeNotifier: ResizeNotifier;
}
@ -41,7 +40,10 @@ interface IState {
}
export default class UserView extends React.Component<IProps, IState> {
constructor(props: IProps) {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) {
super(props);
this.state = {
loading: true,
@ -64,38 +66,42 @@ export default class UserView extends React.Component<IProps, IState> {
}
private async loadProfileInfo(): Promise<void> {
const cli = MatrixClientPeg.get();
this.setState({ loading: true });
let profileInfo;
let profileInfo: Awaited<ReturnType<MatrixClient["getProfileInfo"]>>;
try {
profileInfo = await cli.getProfileInfo(this.props.userId);
profileInfo = await this.context.getProfileInfo(this.props.userId);
} catch (err) {
Modal.createDialog(ErrorDialog, {
title: _t('Could not load user profile'),
description: ((err && err.message) ? err.message : _t("Operation failed")),
title: _t("error_dialog|error_loading_user_profile"),
description: err instanceof Error ? err.message : _t("invite|failed_generic"),
});
this.setState({ loading: false });
return;
}
const fakeEvent = new MatrixEvent({ type: "m.room.member", content: profileInfo });
const member = new RoomMember(null, this.props.userId);
// We pass an empty string room ID here, this is slight abuse of the class to simplify code
const member = new RoomMember("", this.props.userId);
member.setMembershipEvent(fakeEvent);
this.setState({ member, loading: false });
}
public render(): JSX.Element {
public render(): React.ReactNode {
if (this.state.loading) {
return <Spinner />;
} else if (this.state.member) {
const panel = <RightPanel
overwriteCard={{ phase: RightPanelPhases.RoomMemberInfo, state: { member: this.state.member } }}
resizeNotifier={this.props.resizeNotifier}
/>;
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
<UserOnboardingPage />
</MainSplit>);
const panel = (
<RightPanel
overwriteCard={{ phase: RightPanelPhases.RoomMemberInfo, state: { member: this.state.member } }}
resizeNotifier={this.props.resizeNotifier}
/>
);
return (
<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
<UserOnboardingPage />
</MainSplit>
);
} else {
return (<div />);
return <div />;
}
}
}

View file

@ -1,7 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2019, 2023 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,22 +16,23 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import SyntaxHighlight from "../views/elements/SyntaxHighlight";
import { _t } from "../../languageHandler";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { canEditContent } from "../../utils/EventUtils";
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { IDialogProps } from "../views/dialogs/IDialogProps";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseDialog from "../views/dialogs/BaseDialog";
import { DevtoolsContext } from "../views/dialogs/devtools/BaseTool";
import { StateEventEditor } from "../views/dialogs/devtools/RoomState";
import { stringify, TimelineEventEditor } from "../views/dialogs/devtools/Event";
import CopyableText from "../views/elements/CopyableText";
interface IProps extends IDialogProps {
interface IProps {
mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu
ignoreEdits?: boolean;
onFinished(): void;
}
interface IState {
@ -40,7 +40,7 @@ interface IState {
}
export default class ViewSource extends React.Component<IProps, IState> {
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -59,7 +59,11 @@ export default class ViewSource extends React.Component<IProps, IState> {
// returns the dialog body for viewing the event source
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
let mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
if (this.props.ignoreEdits) {
mxEvent = this.props.mxEvent;
}
const isEncrypted = mxEvent.isEncrypted();
// @ts-ignore
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
@ -69,32 +73,30 @@ export default class ViewSource extends React.Component<IProps, IState> {
};
if (isEncrypted) {
const copyDecryptedFunc = (): string => {
return stringify(decryptedEventSource);
return stringify(decryptedEventSource || {});
};
return (
<>
<details open className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">
{ _t("Decrypted event source") }
{_t("devtools|view_source_decrypted_event_source")}
</span>
</summary>
<CopyableText getTextToCopy={copyDecryptedFunc}>
<SyntaxHighlight language="json">
{ stringify(decryptedEventSource) }
</SyntaxHighlight>
</CopyableText>
{decryptedEventSource ? (
<CopyableText getTextToCopy={copyDecryptedFunc}>
<SyntaxHighlight language="json">{stringify(decryptedEventSource)}</SyntaxHighlight>
</CopyableText>
) : (
<div>{_t("devtools|view_source_decrypted_event_source_unavailable")}</div>
)}
</details>
<details className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">
{ _t("Original event source") }
</span>
<span className="mx_ViewSource_heading">{_t("devtools|original_event_source")}</span>
</summary>
<CopyableText getTextToCopy={copyOriginalFunc}>
<SyntaxHighlight language="json">
{ stringify(originalEventSource) }
</SyntaxHighlight>
<SyntaxHighlight language="json">{stringify(originalEventSource)}</SyntaxHighlight>
</CopyableText>
</details>
</>
@ -102,13 +104,9 @@ export default class ViewSource extends React.Component<IProps, IState> {
} else {
return (
<>
<div className="mx_ViewSource_heading">
{ _t("Original event source") }
</div>
<div className="mx_ViewSource_heading">{_t("devtools|original_event_source")}</div>
<CopyableText getTextToCopy={copyOriginalFunc}>
<SyntaxHighlight language="json">
{ stringify(originalEventSource) }
</SyntaxHighlight>
<SyntaxHighlight language="json">{stringify(originalEventSource)}</SyntaxHighlight>
</CopyableText>
</>
);
@ -125,55 +123,64 @@ export default class ViewSource extends React.Component<IProps, IState> {
if (isStateEvent) {
return (
<MatrixClientContext.Consumer>
{ (cli) => (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId) }}>
{(cli) => (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)! }}>
<StateEventEditor onBack={this.onBack} mxEvent={mxEvent} />
</DevtoolsContext.Provider>
) }
)}
</MatrixClientContext.Consumer>
);
}
return (
<MatrixClientContext.Consumer>
{ (cli) => (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId) }}>
{(cli) => (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)! }}>
<TimelineEventEditor onBack={this.onBack} mxEvent={mxEvent} />
</DevtoolsContext.Provider>
) }
)}
</MatrixClientContext.Consumer>
);
}
private canSendStateEvent(mxEvent: MatrixEvent): boolean {
const cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(mxEvent.getRoomId());
return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
return !!room?.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
}
public render(): JSX.Element {
public render(): React.ReactNode {
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;
const roomId = mxEvent.getRoomId();
const eventId = mxEvent.getId();
const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent);
const roomId = mxEvent.getRoomId()!;
const eventId = mxEvent.getId()!;
const canEdit = mxEvent.isState()
? this.canSendStateEvent(mxEvent)
: canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent);
return (
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("action|view_source")}>
<div className="mx_ViewSource_header">
<CopyableText getTextToCopy={() => roomId} border={false}>
{ _t("Room ID: %(roomId)s", { roomId }) }
{_t("devtools|room_id", { roomId })}
</CopyableText>
<CopyableText getTextToCopy={() => eventId} border={false}>
{ _t("Event ID: %(eventId)s", { eventId }) }
{_t("devtools|event_id", { eventId })}
</CopyableText>
{mxEvent.threadRootId && (
<CopyableText getTextToCopy={() => mxEvent.threadRootId!} border={false}>
{_t("devtools|thread_root_id", {
threadRootId: mxEvent.threadRootId,
})}
</CopyableText>
)}
</div>
{ isEditing ? this.editSourceContent() : this.viewSourceContent() }
{ !isEditing && canEdit && (
{isEditing ? this.editSourceContent() : this.viewSourceContent()}
{!isEditing && canEdit && (
<div className="mx_Dialog_buttons">
<button onClick={() => this.onEdit()}>{ _t("Edit") }</button>
<button onClick={() => this.onEdit()}>{_t("action|edit")}</button>
</div>
) }
)}
</BaseDialog>
);
}

View file

@ -0,0 +1,87 @@
/*
Copyright 2023 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, { RefObject } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { useRoomContext } from "../../contexts/RoomContext";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { E2EStatus } from "../../utils/ShieldUtils";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
import RoomHeader from "../views/rooms/RoomHeader";
import ScrollPanel from "./ScrollPanel";
import EventTileBubble from "../views/messages/EventTileBubble";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import { UnwrappedEventTile } from "../views/rooms/EventTile";
import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
import SettingsStore from "../../settings/SettingsStore";
interface Props {
roomView: RefObject<HTMLElement>;
resizeNotifier: ResizeNotifier;
inviteEvent: MatrixEvent;
}
/**
* Component that displays a waiting room for an encrypted DM with a third party invite.
* If encryption by default is enabled, DMs with a third party invite should be encrypted as well.
* To avoid UTDs, users are shown a waiting room until the others have joined.
*/
export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resizeNotifier, inviteEvent }) => {
const context = useRoomContext();
const brand = SdkConfig.get().brand;
return (
<div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary>
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
<RoomHeader room={context.room!} />
) : (
<LegacyRoomHeader
room={context.room}
inRoom={true}
onSearchClick={null}
onInviteClick={null}
onForgetClick={null}
e2eStatus={E2EStatus.Normal}
onAppsClick={null}
appsShown={false}
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
)}
<main className="mx_RoomView_body" ref={roomView}>
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={resizeNotifier}>
<EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={_t("room|waiting_for_join_title", { brand })}
subtitle={_t("room|waiting_for_join_subtitle", { brand })}
/>
<NewRoomIntro />
<UnwrappedEventTile mxEvent={inviteEvent} />
</ScrollPanel>
</div>
</main>
</ErrorBoundary>
</div>
);
};

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { _t } from '../../../languageHandler';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import { _t } from "../../../languageHandler";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
import SetupEncryptionBody from "./SetupEncryptionBody";
import AccessibleButton from '../../views/elements/AccessibleButton';
import AccessibleButton from "../../views/elements/AccessibleButton";
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import AuthPage from "../../views/auth/AuthPage";
@ -28,12 +28,12 @@ interface IProps {
}
interface IState {
phase: Phase;
phase?: Phase;
lostKeys: boolean;
}
export default class CompleteSecurity extends React.Component<IProps, IState> {
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this.onStoreUpdate);
@ -57,7 +57,7 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
store.stop();
}
public render() {
public render(): React.ReactNode {
const { phase, lostKeys } = this.state;
let icon;
let title;
@ -67,23 +67,23 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
} else if (phase === Phase.Intro) {
if (lostKeys) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Unable to verify this device");
title = _t("encryption|verification|after_new_login|unable_to_verify");
} else {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this device");
title = _t("encryption|verification|after_new_login|verify_this_device");
}
} else if (phase === Phase.Done) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Device verified");
title = _t("encryption|verification|after_new_login|device_verified");
} else if (phase === Phase.ConfirmSkip) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Are you sure?");
title = _t("common|are_you_sure");
} else if (phase === Phase.Busy) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this device");
title = _t("encryption|verification|after_new_login|verify_this_device");
} else if (phase === Phase.ConfirmReset) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Really reset verification keys?");
title = _t("encryption|verification|after_new_login|reset_confirmation");
} else if (phase === Phase.Finished) {
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
} else {
@ -93,7 +93,11 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
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")} />
<AccessibleButton
onClick={this.onSkipClick}
className="mx_CompleteSecurity_skip"
aria-label={_t("encryption|verification|after_new_login|skip_verification")}
/>
);
}
@ -101,9 +105,9 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
<AuthPage>
<CompleteSecurityBody>
<h1 className="mx_CompleteSecurity_header">
{ icon }
{ title }
{ skipButton }
{icon}
{title}
{skipButton}
</h1>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} />

View file

@ -0,0 +1,46 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import AccessibleButton from "../../views/elements/AccessibleButton";
interface Props {
/** Callback which the view will call if the user confirms they want to use this window */
onConfirm: () => void;
}
/**
* Component shown by {@link MatrixChat} when another session is already active in the same browser and we need to
* confirm if we should steal its lock
*/
export function ConfirmSessionLockTheftView(props: Props): JSX.Element {
const brand = SdkConfig.get().brand;
return (
<div className="mx_ConfirmSessionLockTheftView">
<div className="mx_ConfirmSessionLockTheftView_body">
<p>{_t("error_app_opened_in_another_window", { brand, label: _t("action|continue") })}</p>
<AccessibleButton kind="primary" onClick={props.onConfirm}>
{_t("action|continue")}
</AccessibleButton>
</div>
</div>
);
}

View file

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import AuthPage from '../../views/auth/AuthPage';
import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody';
import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog';
import AuthPage from "../../views/auth/AuthPage";
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import CreateCrossSigningDialog from "../../views/dialogs/security/CreateCrossSigningDialog";
interface IProps {
onFinished: () => void;
@ -27,7 +27,7 @@ interface IProps {
}
export default class E2eSetup extends React.Component<IProps> {
render() {
public render(): React.ReactNode {
return (
<AuthPage>
<CompleteSecurityBody>

View file

@ -16,232 +16,198 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import React, { ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { createClient } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { _t, _td } from '../../../languageHandler';
import { _t, _td } from "../../../languageHandler";
import Modal from "../../../Modal";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import AuthPage from "../../views/auth/AuthPage";
import ServerPicker from "../../views/elements/ServerPicker";
import EmailField from "../../views/auth/EmailField";
import PassphraseField from '../../views/auth/PassphraseField';
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
import InlineSpinner from '../../views/elements/InlineSpinner';
import Spinner from "../../views/elements/Spinner";
import QuestionDialog from "../../views/dialogs/QuestionDialog";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
import PassphraseField from "../../views/auth/PassphraseField";
import { PASSWORD_MIN_SCORE } from "../../views/auth/RegistrationForm";
import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField";
import AccessibleButton from '../../views/elements/AccessibleButton';
import StyledCheckbox from '../../views/elements/StyledCheckbox';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
import StyledCheckbox from "../../views/elements/StyledCheckbox";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { Icon as CheckboxIcon } from "../../../../res/img/compound/checkbox-32px.svg";
import { Icon as LockIcon } from "../../../../res/img/compound/padlock-32px.svg";
import QuestionDialog from "../../views/dialogs/QuestionDialog";
import { EnterEmail } from "./forgot-password/EnterEmail";
import { CheckEmail } from "./forgot-password/CheckEmail";
import Field from "../../views/elements/Field";
import { ErrorMessage } from "../ErrorMessage";
import { VerifyEmailModal } from "./forgot-password/VerifyEmailModal";
import Spinner from "../../views/elements/Spinner";
import { formatSeconds } from "../../../DateUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
const emailCheckInterval = 2000;
enum Phase {
// Show the forgot password inputs
Forgot = 1,
// Show email input
EnterEmail = 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,
// Show new password input
PasswordInput = 4,
// Password is in the process of being reset
ResettingPassword = 5,
// All done
Done = 6,
}
interface IProps {
interface Props {
serverConfig: ValidatedServerConfig;
onServerConfigChange: (serverConfig: ValidatedServerConfig) => void;
onLoginClick?: () => void;
onLoginClick: () => void;
onComplete: () => void;
}
interface IState {
interface State {
phase: Phase;
email: string;
password: string;
password2: string;
errorText: string;
errorText: string | ReactNode | null;
// 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;
currentHttpRequest?: Promise<any>;
serverSupportsControlOfDevicesLogout: boolean;
logoutDevices: boolean;
}
enum ForgotPasswordField {
Email = 'field_email',
Password = 'field_password',
PasswordConfirm = 'field_password_confirm',
}
export default class ForgotPassword extends React.Component<IProps, IState> {
export default class ForgotPassword extends React.Component<Props, State> {
private reset: PasswordReset;
private fieldPassword: Field | null = null;
private fieldPasswordConfirm: Field | null = null;
state: IState = {
phase: Phase.Forgot,
email: "",
password: "",
password2: "",
errorText: null,
// 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: true,
serverErrorIsFatal: false,
serverDeadError: "",
serverSupportsControlOfDevicesLogout: false,
logoutDevices: false,
};
public componentDidMount() {
this.reset = null;
this.checkServerLiveliness(this.props.serverConfig);
this.checkServerCapabilities(this.props.serverConfig);
public constructor(props: Props) {
super(props);
this.state = {
phase: Phase.EnterEmail,
email: "",
password: "",
password2: "",
errorText: null,
// 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: true,
serverDeadError: "",
logoutDevices: false,
};
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// 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);
// Do capabilities check on new URLs
this.checkServerCapabilities(newProps.serverConfig);
public componentDidUpdate(prevProps: Readonly<Props>): void {
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
// Do a liveliness check on the new URLs
this.checkServerLiveliness(this.props.serverConfig);
}
}
private async checkServerLiveliness(serverConfig): Promise<void> {
private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise<void> {
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl,
serverConfig.isUrl,
);
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(serverConfig.hsUrl, serverConfig.isUrl);
this.setState({
serverIsAlive: true,
});
} catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState);
} catch (e: any) {
const { serverIsAlive, serverDeadError } = AutoDiscoveryUtils.authComponentStateForError(
e,
"forgot_password",
);
this.setState({
serverIsAlive,
errorText: serverDeadError,
});
}
}
private async checkServerCapabilities(serverConfig: ValidatedServerConfig): Promise<void> {
const tempClient = createClient({
baseUrl: serverConfig.hsUrl,
});
private async onPhaseEmailInputSubmit(): Promise<void> {
this.phase = Phase.SendingEmail;
const serverSupportsControlOfDevicesLogout = await tempClient.doesServerSupportLogoutDevices();
this.setState({
logoutDevices: !serverSupportsControlOfDevicesLogout,
serverSupportsControlOfDevicesLogout,
});
}
public submitPasswordReset(email: string, password: string, logoutDevices = true): void {
this.setState({
phase: Phase.SendingEmail,
});
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password, logoutDevices).then(() => {
this.setState({
phase: Phase.EmailSent,
});
}, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
phase: Phase.Forgot,
});
});
}
private onVerify = async (ev: React.MouseEvent): Promise<void> => {
ev.preventDefault();
if (!this.reset) {
logger.error("onVerify called before submitPasswordReset!");
if (await this.sendVerificationMail()) {
this.phase = Phase.EmailSent;
return;
}
if (this.state.currentHttpRequest) return;
this.phase = Phase.EnterEmail;
}
private sendVerificationMail = async (): Promise<boolean> => {
try {
await this.handleHttpRequest(this.reset.checkEmailLinkClicked());
this.setState({ phase: Phase.Done });
} catch (err) {
this.showErrorDialog(err.message);
await this.reset.requestResetToken(this.state.email);
return true;
} catch (err: any) {
this.handleError(err);
}
return false;
};
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
if (this.state.currentHttpRequest) return;
private handleError(err: any): void {
if (err?.httpStatus === 429) {
// 429: rate limit
const retryAfterMs = parseInt(err?.data?.retry_after_ms, 10);
// refresh the server errors, just in case the server came back online
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
const errorText = isNaN(retryAfterMs)
? _t("auth|reset_password|rate_limit_error")
: _t("auth|reset_password|rate_limit_error_with_time", {
timeout: formatSeconds(retryAfterMs / 1000),
});
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
this.setState({
errorText,
});
return;
}
if (this.state.logoutDevices) {
const { finished } = Modal.createDialog<[boolean]>(QuestionDialog, {
title: _t('Warning!'),
description:
<div>
<p>{ !this.state.serverSupportsControlOfDevicesLogout ?
_t(
"Resetting your password on this homeserver will cause all of your devices to be " +
"signed out. This will delete the message encryption keys stored on them, " +
"making encrypted chat history unreadable.",
) :
_t(
"Signing out your devices will delete the message encryption keys stored on them, " +
"making encrypted chat history unreadable.",
)
}</p>
<p>{ _t(
"If you want to retain access to your chat history in encrypted rooms, set up Key Backup " +
"or export your message keys from one of your other devices before proceeding.",
) }</p>
</div>,
button: _t('Continue'),
if (err?.name === "ConnectionError") {
this.setState({
errorText: _t("cannot_reach_homeserver") + ": " + _t("cannot_reach_homeserver_detail"),
});
const [confirmed] = await finished;
if (!confirmed) return;
return;
}
this.submitPasswordReset(this.state.email, this.state.password, this.state.logoutDevices);
};
this.setState({
errorText: err.message,
});
}
private async verifyFieldsBeforeSubmit() {
const fieldIdsInDisplayOrder = [
ForgotPasswordField.Email,
ForgotPasswordField.Password,
ForgotPasswordField.PasswordConfirm,
];
private async onPhaseEmailSentSubmit(): Promise<void> {
this.setState({
phase: Phase.PasswordInput,
});
}
const invalidFields = [];
for (const fieldId of fieldIdsInDisplayOrder) {
const valid = await this[fieldId].validate({ allowEmpty: false });
private set phase(phase: Phase) {
this.setState({ phase });
}
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
const fieldIdsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
const invalidFields: Field[] = [];
for (const field of fieldIdsInDisplayOrder) {
if (!field) continue;
const valid = await field.validate({ allowEmpty: false });
if (!valid) {
invalidFields.push(this[fieldId]);
invalidFields.push(field);
}
}
@ -257,189 +223,252 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
return false;
}
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
private async onPhasePasswordInputSubmit(): Promise<void> {
if (!(await this.verifyFieldsBeforeSubmit())) return;
if (this.state.logoutDevices) {
const logoutDevicesConfirmation = await this.renderConfirmLogoutDevicesDialog();
if (!logoutDevicesConfirmation) return;
}
this.phase = Phase.ResettingPassword;
this.reset.setLogoutDevices(this.state.logoutDevices);
try {
await this.reset.setNewPassword(this.state.password);
this.setState({ phase: Phase.Done });
return;
} catch (err: any) {
if (err.httpStatus !== 401) {
// 401 = waiting for email verification, else unknown error
this.handleError(err);
return;
}
}
const modal = Modal.createDialog(
VerifyEmailModal,
{
email: this.state.email,
errorText: this.state.errorText,
onCloseClick: () => {
modal.close();
this.setState({ phase: Phase.PasswordInput });
},
onReEnterEmailClick: () => {
modal.close();
this.setState({ phase: Phase.EnterEmail });
},
onResendClick: this.sendVerificationMail,
},
"mx_VerifyEMailDialog",
false,
false,
{
onBeforeClose: async (reason?: string): Promise<boolean> => {
if (reason === "backgroundClick") {
// Modal dismissed by clicking the background.
// Go one phase back.
this.setState({ phase: Phase.PasswordInput });
}
return true;
},
},
);
// Don't retry if the phase changed. For example when going back to email input.
while (this.state.phase === Phase.ResettingPassword) {
try {
await this.reset.setNewPassword(this.state.password);
this.setState({ phase: Phase.Done });
modal.close();
} catch (e) {
// Email not confirmed, yet. Retry after a while.
await sleep(emailCheckInterval);
}
}
}
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
// Should not happen because of disabled forms, but just return if currently doing an action.
if ([Phase.SendingEmail, Phase.ResettingPassword].includes(this.state.phase)) return;
this.setState({
errorText: "",
});
// Refresh the server errors. Just in case the server came back online of went offline.
await this.checkServerLiveliness(this.props.serverConfig);
// Server error
if (!this.state.serverIsAlive) return;
switch (this.state.phase) {
case Phase.EnterEmail:
this.onPhaseEmailInputSubmit();
break;
case Phase.EmailSent:
this.onPhaseEmailSentSubmit();
break;
case Phase.PasswordInput:
this.onPhasePasswordInputSubmit();
break;
}
};
private onInputChanged = (
stateKey: "email" | "password" | "password2",
ev: React.FormEvent<HTMLInputElement>,
): void => {
let value = ev.currentTarget.value;
if (stateKey === "email") value = value.trim();
this.setState({
[stateKey]: value,
} as any);
} as Pick<State, typeof stateKey>);
};
private onLoginClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
public showErrorDialog(description: string, title?: string) {
Modal.createDialog(ErrorDialog, {
title,
description,
});
}
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
this.setState({
currentHttpRequest: request,
});
return request.finally(() => {
this.setState({
currentHttpRequest: undefined,
});
});
}
renderForgot() {
let errorText = null;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{ this.state.serverDeadError }
</div>
);
}
return <div>
{ errorText }
{ serverDeadSection }
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
public renderEnterEmail(): JSX.Element {
return (
<EnterEmail
email={this.state.email}
errorText={this.state.errorText}
homeserver={this.props.serverConfig.hsName}
loading={this.state.phase === Phase.SendingEmail}
onInputChanged={this.onInputChanged}
onLoginClick={this.props.onLoginClick!} // set by default props
onSubmitForm={this.onSubmitForm}
/>
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<EmailField
name="reset_email" // define a name so browser's password autofill gets less confused
labelRequired={_td('The email address linked to your account must be entered.')}
labelInvalid={_td("The email address doesn't appear to be valid.")}
value={this.state.email}
fieldRef={field => this[ForgotPasswordField.Email] = field}
autoFocus={true}
onChange={this.onInputChanged.bind(this, "email")}
/>
);
}
public async renderConfirmLogoutDevicesDialog(): Promise<boolean> {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("common|warning"),
description: (
<div>
<p>{_t("auth|reset_password|other_devices_logout_warning_1")}</p>
<p>{_t("auth|reset_password|other_devices_logout_warning_2")}</p>
</div>
<div className="mx_AuthBody_fieldRow">
<PassphraseField
name="reset_password"
type="password"
label={_td('New Password')}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
fieldRef={field => this[ForgotPasswordField.Password] = field}
onChange={this.onInputChanged.bind(this, "password")}
autoComplete="new-password"
/>
<PassphraseConfirmField
name="reset_password_confirm"
label={_td('Confirm')}
labelRequired={_td("A new password must be entered.")}
labelInvalid={_td("New passwords must match each other.")}
value={this.state.password2}
password={this.state.password}
fieldRef={field => this[ForgotPasswordField.PasswordConfirm] = field}
onChange={this.onInputChanged.bind(this, "password2")}
autoComplete="new-password"
/>
</div>
{ this.state.serverSupportsControlOfDevicesLogout ?
<div className="mx_AuthBody_fieldRow">
<StyledCheckbox onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}>
{ _t("Sign out all devices") }
</StyledCheckbox>
</div> : null
}
<span>{ _t(
'A verification email will be sent to your inbox to confirm ' +
'setting your new password.',
) }</span>
),
button: _t("action|continue"),
});
const [confirmed] = await finished;
return !!confirmed;
}
public renderCheckEmail(): JSX.Element {
return (
<CheckEmail
email={this.state.email}
errorText={this.state.errorText}
onReEnterEmailClick={() => this.setState({ phase: Phase.EnterEmail })}
onResendClick={this.sendVerificationMail}
onSubmitForm={this.onSubmitForm}
/>
);
}
public renderSetPassword(): JSX.Element {
const submitButtonChild =
this.state.phase === Phase.ResettingPassword ? <Spinner w={16} h={16} /> : _t("auth|reset_password_action");
return (
<>
<LockIcon className="mx_AuthBody_lockIcon" />
<h1>{_t("auth|reset_password_title")}</h1>
<form onSubmit={this.onSubmitForm}>
<fieldset disabled={this.state.phase === Phase.ResettingPassword}>
<div className="mx_AuthBody_fieldRow">
<PassphraseField
name="reset_password"
type="password"
label={_td("auth|change_password_new_label")}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
fieldRef={(field) => (this.fieldPassword = field)}
onChange={this.onInputChanged.bind(this, "password")}
autoComplete="new-password"
/>
<PassphraseConfirmField
name="reset_password_confirm"
label={_td("auth|reset_password|confirm_new_password")}
labelRequired={_td("auth|reset_password|password_not_entered")}
labelInvalid={_td("auth|reset_password|passwords_mismatch")}
value={this.state.password2}
password={this.state.password}
fieldRef={(field) => (this.fieldPasswordConfirm = field)}
onChange={this.onInputChanged.bind(this, "password2")}
autoComplete="new-password"
/>
</div>
<div className="mx_AuthBody_fieldRow">
<StyledCheckbox
onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })}
checked={this.state.logoutDevices}
>
{_t("auth|reset_password|sign_out_other_devices")}
</StyledCheckbox>
</div>
{this.state.errorText && <ErrorMessage message={this.state.errorText} />}
<button type="submit" className="mx_Login_submit">
{submitButtonChild}
</button>
</fieldset>
</form>
</>
);
}
public renderDone(): JSX.Element {
return (
<>
<CheckboxIcon className="mx_Icon mx_Icon_32 mx_Icon_accent" />
<h1>{_t("auth|reset_password|reset_successful")}</h1>
{this.state.logoutDevices ? <p>{_t("auth|reset_password|devices_logout_success")}</p> : null}
<input
className="mx_Login_submit"
type="submit"
value={_t('Send Reset Email')}
type="button"
onClick={this.props.onComplete}
value={_t("auth|reset_password|return_to_login")}
/>
</form>
<AccessibleButton kind='link' className="mx_AuthBody_changeFlow" onClick={this.onLoginClick}>
{ _t('Sign in instead') }
</AccessibleButton>
</div>;
</>
);
}
renderSendingEmail() {
return <Spinner />;
}
public render(): React.ReactNode {
let resetPasswordJsx: JSX.Element;
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 }) }
<br />
<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>
{ this.state.logoutDevices ?
<p>{ _t(
"You have been logged out of all devices and will no longer receive " +
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
) }</p>
: null
}
<input
className="mx_Login_submit"
type="button"
onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>;
}
render() {
let resetPasswordJsx;
switch (this.state.phase) {
case Phase.Forgot:
resetPasswordJsx = this.renderForgot();
break;
case Phase.EnterEmail:
case Phase.SendingEmail:
resetPasswordJsx = this.renderSendingEmail();
resetPasswordJsx = this.renderEnterEmail();
break;
case Phase.EmailSent:
resetPasswordJsx = this.renderEmailSent();
resetPasswordJsx = this.renderCheckEmail();
break;
case Phase.PasswordInput:
case Phase.ResettingPassword:
resetPasswordJsx = this.renderSetPassword();
break;
case Phase.Done:
resetPasswordJsx = this.renderDone();
break;
default:
resetPasswordJsx = <div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>;
// This should not happen. However, it is logged and the user is sent to the start.
logger.warn(`unknown forgot password phase ${this.state.phase}`);
this.setState({
phase: Phase.EnterEmail,
});
return;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h1> { _t('Set a new password') } </h1>
{ resetPasswordJsx }
</AuthBody>
<AuthBody className="mx_AuthBody_forgot-password">{resetPasswordJsx}</AuthBody>
</AuthPage>
);
}

View file

@ -14,19 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from 'react';
import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api";
import React, { ReactNode } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix";
import { _t, _td } from '../../../languageHandler';
import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import { _t, UserFriendlyError } from "../../../languageHandler";
import Login, { ClientLoginFlow, OidcNativeFlow } from "../../../Login";
import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import AuthPage from "../../views/auth/AuthPage";
import PlatformPeg from '../../../PlatformPeg';
import PlatformPeg from "../../../PlatformPeg";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { IMatrixClientCreds } from "../../../MatrixClientPeg";
@ -37,19 +35,11 @@ import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
import AccessibleButton from '../../views/elements/AccessibleButton';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
// 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.
_td("Invalid homeserver discovery response");
_td("Failed to get autodiscovery configuration from server");
_td("Invalid base_url for m.homeserver");
_td("Homeserver URL does not appear to be a valid Matrix homeserver");
_td("Invalid identity server discovery response");
_td("Invalid base_url for m.identity_server");
_td("Identity server URL does not appear to be a valid identity server");
_td("General failure");
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { filterBoolean } from "../../../utils/arrays";
import { Features } from "../../../settings/Settings";
import { startOidcLogin } from "../../../utils/oidc/authorize";
interface IProps {
serverConfig: ValidatedServerConfig;
@ -85,11 +75,11 @@ interface IState {
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
flows?: LoginFlow[];
flows?: ClientLoginFlow[];
// used for preserving form values when changing homeserver
username: string;
phoneCountry?: string;
phoneCountry: string;
phoneNumber: string;
// We perform liveliness checks later, but for now suppress the errors.
@ -101,29 +91,35 @@ interface IState {
serverDeadError?: ReactNode;
}
type OnPasswordLogin = {
(username: string, phoneCountry: undefined, phoneNumber: undefined, password: string): Promise<void>;
(username: undefined, phoneCountry: string, phoneNumber: string, password: string): Promise<void>;
};
/*
* A wire component which glues together login UI components and Login logic
*/
export default class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic: Login;
private oidcNativeFlowEnabled = false;
private loginLogic!: Login;
private readonly stepRendererMap: Record<string, () => ReactNode>;
constructor(props) {
public constructor(props: IProps) {
super(props);
// only set on a config level, so we don't need to watch
this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow);
this.state = {
busy: false,
busyLoggingIn: null,
errorText: null,
loginIncorrect: false,
canTryLogin: true,
flows: null,
username: props.defaultUsername? props.defaultUsername: '',
phoneCountry: null,
username: props.defaultUsername ? props.defaultUsername : "",
phoneCountry: "",
phoneNumber: "",
serverIsAlive: true,
@ -134,39 +130,46 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
// map from login step type to a function which will render a control
// letting you do that login type
this.stepRendererMap = {
'm.login.password': this.renderPasswordStep,
"m.login.password": this.renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to
// eslint-disable-next-line @typescript-eslint/naming-convention
'm.login.cas': () => this.renderSsoStep("cas"),
"m.login.cas": () => this.renderSsoStep("cas"),
// eslint-disable-next-line @typescript-eslint/naming-convention
'm.login.sso': () => this.renderSsoStep("sso"),
"m.login.sso": () => this.renderSsoStep("sso"),
"oidcNativeFlow": () => this.renderOidcNativeStep(),
};
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillMount() {
public componentDidMount(): void {
this.initLoginLogic(this.props.serverConfig);
}
componentWillUnmount() {
public componentWillUnmount(): void {
this.unmounted = true;
}
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Ensure that we end up actually logging in to the right place
this.initLoginLogic(newProps.serverConfig);
public componentDidUpdate(prevProps: IProps): void {
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl ||
// delegatedAuthentication is only set by buildValidatedConfigFromDiscovery and won't be modified
// so shallow comparison is fine
prevProps.serverConfig.delegatedAuthentication !== this.props.serverConfig.delegatedAuthentication
) {
// Ensure that we end up actually logging in to the right place
this.initLoginLogic(this.props.serverConfig);
}
}
isBusy = () => this.state.busy || this.props.busy;
public isBusy = (): boolean => !!this.state.busy || !!this.props.busy;
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
public onPasswordLogin: OnPasswordLogin = async (
username: string | undefined,
phoneCountry: string | undefined,
phoneNumber: string | undefined,
password: string,
): Promise<void> => {
if (!this.state.serverIsAlive) {
this.setState({ busy: true });
// Do a quick liveliness check on the URLs
@ -200,91 +203,41 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
loginIncorrect: false,
});
this.loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({ serverIsAlive: true }); // it must be, we logged in.
this.props.onLoggedIn(data, password);
}, (error) => {
if (this.unmounted) {
return;
}
let errorText;
this.loginLogic.loginViaPassword(username, phoneCountry, phoneNumber, password).then(
(data) => {
this.setState({ serverIsAlive: true }); // it must be, we logged in.
this.props.onLoggedIn(data, password);
},
(error) => {
if (this.unmounted) return;
// Some error strings only apply for logging in
const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus === 400 && usingEmail) {
errorText = _t('This homeserver does not support login using email address.');
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact,
{
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'hs_blocked': _td(
"This homeserver has been blocked by its administrator.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
},
);
const errorDetail = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
errorText = (
<div>
<div>{ errorTop }</div>
<div className="mx_Login_smallError">{ errorDetail }</div>
</div>
);
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (error.errcode === 'M_USER_DEACTIVATED') {
errorText = _t('This account has been deactivated.');
} else if (SdkConfig.get("disable_custom_urls")) {
errorText = (
<div>
<div>{ _t('Incorrect username and/or password.') }</div>
<div className="mx_Login_smallError">
{ _t(
'Please note you are logging into the %(hs)s server, not matrix.org.',
{ hs: this.props.serverConfig.hsName },
) }
</div>
</div>
);
let errorText: ReactNode;
// Some error strings only apply for logging in
if (error.httpStatus === 400 && username && username.indexOf("@") > 0) {
errorText = _t("auth|unsupported_auth_email");
} else {
errorText = _t('Incorrect username and/or password.');
errorText = messageForLoginError(error, this.props.serverConfig);
}
} else {
// other errors, not specific to doing a password login
errorText = this.errorTextFromError(error);
}
this.setState({
busy: false,
busyLoggingIn: false,
errorText: errorText,
// 401 would be the sensible status code for 'incorrect password'
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
// mentions this (although the bug is for UI auth which is not this)
// We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
});
});
this.setState({
busy: false,
busyLoggingIn: false,
errorText,
// 401 would be the sensible status code for 'incorrect password'
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
// mentions this (although the bug is for UI auth which is not this)
// We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
});
},
);
};
onUsernameChanged = username => {
this.setState({ username: username });
public onUsernameChanged = (username: string): void => {
this.setState({ username });
};
onUsernameBlur = async username => {
public onUsernameBlur = async (username: string): Promise<void> => {
const doWellknownLookup = username[0] === "@";
this.setState({
username: username,
@ -293,7 +246,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
canTryLogin: true,
});
if (doWellknownLookup) {
const serverName = username.split(':').slice(1).join(':');
const serverName = username.split(":").slice(1).join(":");
try {
const result = await AutoDiscoveryUtils.validateServerName(serverName);
this.props.onServerConfigChange(result);
@ -310,8 +263,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
} catch (e) {
logger.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
let message = _t("Failed to perform homeserver discovery");
if (e.translatedMessage) {
let message = _t("auth|failed_homeserver_discovery");
if (e instanceof UserFriendlyError && e.translatedMessage) {
message = e.translatedMessage;
}
@ -331,64 +284,50 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
};
onPhoneCountryChanged = phoneCountry => {
this.setState({ phoneCountry: phoneCountry });
public onPhoneCountryChanged = (phoneCountry: string): void => {
this.setState({ phoneCountry });
};
onPhoneNumberChanged = phoneNumber => {
this.setState({
phoneNumber: phoneNumber,
});
public onPhoneNumberChanged = (phoneNumber: string): void => {
this.setState({ phoneNumber });
};
onRegisterClick = ev => {
public onRegisterClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
};
onTryRegisterClick = ev => {
const hasPasswordFlow = this.state.flows?.find(flow => flow.type === "m.login.password");
const ssoFlow = this.state.flows?.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas");
public onTryRegisterClick = (ev: ButtonEvent): void => {
const hasPasswordFlow = this.state.flows?.find((flow) => flow.type === "m.login.password");
const ssoFlow = this.state.flows?.find((flow) => flow.type === "m.login.sso" || flow.type === "m.login.cas");
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
// TODO: instead hide the Register button if registration is disabled by checking with the server,
// has no specific errCode currently and uses M_FORBIDDEN.
if (ssoFlow && !hasPasswordFlow) {
ev.preventDefault();
ev.stopPropagation();
const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas';
PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind,
this.props.fragmentAfterLogin);
const ssoKind = ssoFlow.type === "m.login.sso" ? "sso" : "cas";
PlatformPeg.get()?.startSingleSignOn(
this.loginLogic.createTemporaryClient(),
ssoKind,
this.props.fragmentAfterLogin,
undefined,
SSOAction.REGISTER,
);
} else {
// Don't intercept - just go through to the register page
this.onRegisterClick(ev);
}
};
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig) {
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
&& isUrl === this.props.serverConfig.isUrl) {
isDefaultServer = true;
}
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl : null;
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
this.loginLogic = loginLogic;
this.setState({
busy: true,
loginIncorrect: false,
});
private async checkServerLiveliness({
hsUrl,
isUrl,
}: Pick<ValidatedServerConfig, "hsUrl" | "isUrl">): Promise<void> {
// Do a quick liveliness check on the URLs
try {
const { warning } =
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
const { warning } = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
if (warning) {
this.setState({
...AutoDiscoveryUtils.authComponentStateForError(warning),
@ -403,39 +342,72 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
} catch (e) {
this.setState({
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e),
...AutoDiscoveryUtils.authComponentStateForError(e as Error),
});
}
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
const supportedFlows = flows.filter(this.isSupportedFlow);
if (supportedFlows.length > 0) {
this.setState({
flows: supportedFlows,
});
return;
}
// we got to the end of the list without finding a suitable flow.
this.setState({
errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."),
});
}, (err) => {
this.setState({
errorText: this.errorTextFromError(err),
loginIncorrect: false,
canTryLogin: false,
});
}).finally(() => {
this.setState({
busy: false,
});
});
}
private isSupportedFlow = (flow: LoginFlow): boolean => {
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise<void> {
let isDefaultServer = false;
if (
this.props.serverConfig.isDefault &&
hsUrl === this.props.serverConfig.hsUrl &&
isUrl === this.props.serverConfig.isUrl
) {
isDefaultServer = true;
}
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl! : null;
this.setState({
busy: true,
loginIncorrect: false,
});
await this.checkServerLiveliness({ hsUrl, isUrl });
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
// if native OIDC is enabled in the client pass the server's delegated auth settings
delegatedAuthentication: this.oidcNativeFlowEnabled
? this.props.serverConfig.delegatedAuthentication
: undefined,
});
this.loginLogic = loginLogic;
loginLogic
.getFlows()
.then(
(flows) => {
// look for a flow where we understand all of the steps.
const supportedFlows = flows.filter(this.isSupportedFlow);
this.setState({
flows: supportedFlows,
});
if (supportedFlows.length === 0) {
this.setState({
errorText: _t("auth|unsupported_auth"),
});
}
},
(err) => {
this.setState({
errorText: messageForConnectionError(err, this.props.serverConfig),
loginIncorrect: false,
canTryLogin: false,
});
},
)
.finally(() => {
this.setState({
busy: false,
});
});
}
private isSupportedFlow = (flow: ClientLoginFlow): boolean => {
// 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]) {
@ -445,72 +417,24 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
return true;
};
private errorTextFromError(err: MatrixError): ReactNode {
let errCode = err.errcode;
if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus;
}
let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " +
"please try again later.") + (errCode ? " (" + errCode + ")" : "");
if (err instanceof ConnectionError) {
if (window.location.protocol === 'https:' &&
(this.props.serverConfig.hsUrl.startsWith("http:") ||
!this.props.serverConfig.hsUrl.startsWith("http"))
) {
errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a
target="_blank"
rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
}) }
</span>;
} else {
errorText = <span>
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
"is not blocking requests.", {},
{
'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>,
}) }
</span>;
}
}
return errorText;
}
renderLoginComponentForFlows() {
public renderLoginComponentForFlows(): ReactNode {
if (!this.state.flows) return null;
// this is the ideal order we want to show the flows in
const order = [
"m.login.password",
"m.login.sso",
];
const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"];
const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean);
return <React.Fragment>
{ flows.map(flow => {
const stepRenderer = this.stepRendererMap[flow.type];
return <React.Fragment key={flow.type}>{ stepRenderer() }</React.Fragment>;
}) }
</React.Fragment>;
const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type)));
return (
<React.Fragment>
{flows.map((flow) => {
const stepRenderer = this.stepRendererMap[flow.type];
return <React.Fragment key={flow.type}>{stepRenderer()}</React.Fragment>;
})}
</React.Fragment>
);
}
private renderPasswordStep = () => {
private renderPasswordStep = (): JSX.Element => {
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
@ -530,8 +454,28 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
);
};
private renderSsoStep = loginType => {
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
private renderOidcNativeStep = (): React.ReactNode => {
const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow;
return (
<AccessibleButton
className="mx_Login_fullWidthButton"
kind="primary"
onClick={async () => {
await startOidcLogin(
this.props.serverConfig.delegatedAuthentication!,
flow.clientId,
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
);
}}
>
{_t("action|continue")}
</AccessibleButton>
);
};
private renderSsoStep = (loginType: "cas" | "sso"): JSX.Element => {
const flow = this.state.flows?.find((flow) => flow.type === "m.login." + loginType) as SSOFlow;
return (
<SSOButtons
@ -539,60 +483,64 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
primary={!this.state.flows?.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
/>
);
};
render() {
const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Spinner /></div> : null;
public render(): React.ReactNode {
const loader =
this.isBusy() && !this.state.busyLoggingIn ? (
<div className="mx_Login_loader">
<Spinner />
</div>
) : null;
const errorText = this.state.errorText;
let errorTextSection;
if (errorText) {
errorTextSection = (
<div className="mx_Login_error">
{ errorText }
</div>
);
errorTextSection = <div className="mx_Login_error">{errorText}</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
mx_Login_error: true,
mx_Login_serverError: true,
mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{ this.state.serverDeadError }
</div>
);
serverDeadSection = <div className={classes}>{this.state.serverDeadError}</div>;
}
let footer;
if (this.props.isSyncing || this.state.busyLoggingIn) {
footer = <div className="mx_AuthBody_paddedFooter">
<div className="mx_AuthBody_paddedFooter_title">
<InlineSpinner w={20} h={20} />
{ this.props.isSyncing ? _t("Syncing...") : _t("Signing In...") }
footer = (
<div className="mx_AuthBody_paddedFooter">
<div className="mx_AuthBody_paddedFooter_title">
<InlineSpinner w={20} h={20} />
{this.props.isSyncing ? _t("auth|syncing") : _t("auth|signing_in")}
</div>
{this.props.isSyncing && (
<div className="mx_AuthBody_paddedFooter_subtitle">{_t("auth|sync_footer_subtitle")}</div>
)}
</div>
{ this.props.isSyncing && <div className="mx_AuthBody_paddedFooter_subtitle">
{ _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>", {}, {
a: sub =>
<AccessibleButton kind='link_inline' onClick={this.onTryRegisterClick}>
{ sub }
</AccessibleButton>,
}) }
{_t(
"auth|create_account_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onTryRegisterClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
}
@ -602,17 +550,17 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
<AuthBody>
<h1>
{ _t('Sign in') }
{ loader }
{_t("action|sign_in")}
{loader}
</h1>
{ errorTextSection }
{ serverDeadSection }
{errorTextSection}
{serverDeadSection}
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
/>
{ this.renderLoginComponentForFlows() }
{ footer }
{this.renderLoginComponentForFlows()}
{footer}
</AuthBody>
</AuthPage>
);

View file

@ -0,0 +1,88 @@
/*
Copyright 2015-2024 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 { CryptoEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import { messageForSyncError } from "../../../utils/ErrorUtils";
import Spinner from "../../views/elements/Spinner";
import ProgressBar from "../../views/elements/ProgressBar";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import { _t } from "../../../languageHandler";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import SdkConfig from "../../../SdkConfig";
interface Props {
/** The matrix client which is logging in */
matrixClient: MatrixClient;
/**
* A callback function. Will be called if the user clicks the "logout" button on the splash screen.
*
* @param event - The click event
*/
onLogoutClick: (event: ButtonEvent) => void;
/**
* Error that caused `/sync` to fail. If set, an error message will be shown on the splash screen.
*/
syncError: Error | null;
}
type MigrationState = {
progress: number;
totalSteps: number;
};
/**
* The view that is displayed after we have logged in, before the first /sync is completed.
*/
export function LoginSplashView(props: Props): React.JSX.Element {
const migrationState = useTypedEventEmitterState(
props.matrixClient,
CryptoEvent.LegacyCryptoStoreMigrationProgress,
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }),
);
let errorBox: React.JSX.Element | undefined;
if (props.syncError) {
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
}
// If we are migrating the crypto data, show a progress bar. Otherwise, show a normal spinner.
let spinnerOrProgress;
if (migrationState.totalSteps !== -1) {
spinnerOrProgress = (
<div className="mx_LoginSplashView_migrationProgress">
<p>{_t("migrating_crypto", { brand: SdkConfig.get().brand })}</p>
<ProgressBar value={migrationState.progress} max={migrationState.totalSteps} />
</div>
);
} else {
spinnerOrProgress = <Spinner />;
}
return (
<div className="mx_MatrixChat_splash">
{errorBox}
{spinnerOrProgress}
<div className="mx_LoginSplashView_splashButtons">
<AccessibleButton kind="link_inline" onClick={props.onLogoutClick}>
{_t("action|logout")}
</AccessibleButton>
</div>
</div>
);
}

View file

@ -14,35 +14,48 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AuthType, createClient } from 'matrix-js-sdk/src/matrix';
import React, { Fragment, ReactNode } from 'react';
import { MatrixClient } from "matrix-js-sdk/src/client";
import {
AuthType,
createClient,
IAuthData,
IAuthDict,
IInputs,
MatrixError,
IRegisterRequestParams,
IRequestTokenResponse,
MatrixClient,
SSOFlow,
SSOAction,
RegisterResponse,
} from "matrix-js-sdk/src/matrix";
import React, { Fragment, ReactNode } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { ISSOFlow } from "matrix-js-sdk/src/@types/auth";
import { _t, _td } from '../../../languageHandler';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import { _t } from "../../../languageHandler";
import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as Lifecycle from '../../../Lifecycle';
import * as Lifecycle from "../../../Lifecycle";
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
import Login from "../../../Login";
import Login, { OidcNativeFlow } from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from '../../views/elements/ServerPicker';
import RegistrationForm from '../../views/auth/RegistrationForm';
import AccessibleButton from '../../views/elements/AccessibleButton';
import ServerPicker from "../../views/elements/ServerPicker";
import RegistrationForm from "../../views/auth/RegistrationForm";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
import InteractiveAuth, { InteractiveAuthCallback } from "../InteractiveAuth";
import Spinner from "../../views/elements/Spinner";
import { AuthHeaderDisplay } from './header/AuthHeaderDisplay';
import { AuthHeaderProvider } from './header/AuthHeaderProvider';
import SettingsStore from '../../../settings/SettingsStore';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
import { AuthHeaderDisplay } from "./header/AuthHeaderDisplay";
import { AuthHeaderProvider } from "./header/AuthHeaderProvider";
import SettingsStore from "../../../settings/SettingsStore";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { Features } from "../../../settings/Settings";
import { startOidcLogin } from "../../../utils/oidc/authorize";
const debuglog = (...args: any[]) => {
const debuglog = (...args: any[]): void => {
if (SettingsStore.getValue("debug_registration")) {
logger.log.call(console, "Registration debuglog:", ...args);
}
@ -50,7 +63,7 @@ const debuglog = (...args: any[]) => {
interface IProps {
serverConfig: ValidatedServerConfig;
defaultDeviceDisplayName: string;
defaultDeviceDisplayName?: string;
email?: string;
brand?: string;
clientSecret?: string;
@ -63,24 +76,16 @@ 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: IMatrixClientCreds, password: string): void;
makeRegistrationUrl(params: {
/* eslint-disable camelcase */
client_secret: string;
hs_url: string;
is_url?: string;
session_id: string;
/* eslint-enable camelcase */
}): string;
onLoggedIn(params: IMatrixClientCreds, password: string): Promise<void>;
// registration shouldn't know or care how login is done.
onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
interface IState {
// true if we're waiting for the user to complete
busy: boolean;
errorText?: ReactNode;
// true if we're waiting for the user to complete
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
@ -88,7 +93,7 @@ interface IState {
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: Record<string, string>;
formVals: Record<string, string | undefined>;
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
@ -96,9 +101,11 @@ interface IState {
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: boolean;
flows: {
stages: string[];
}[];
flows:
| {
stages: string[];
}[]
| null;
// 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
@ -117,15 +124,20 @@ interface IState {
differentLoggedInUserId?: string;
// the SSO flow definition, this is fetched from /login as that's the only
// place it is exposed.
ssoFlow?: ISSOFlow;
ssoFlow?: SSOFlow;
// the OIDC native login flow, when supported and enabled
// if present, must be used for registration
oidcNativeFlow?: OidcNativeFlow;
}
export default class Registration extends React.Component<IProps, IState> {
private readonly loginLogic: Login;
// `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows
private latestServerConfig: ValidatedServerConfig;
private latestServerConfig?: ValidatedServerConfig;
// cache value from settings store
private oidcNativeFlowEnabled = false;
constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -142,39 +154,45 @@ export default class Registration extends React.Component<IProps, IState> {
serverDeadError: "",
};
const { hsUrl, isUrl } = this.props.serverConfig;
// only set on a config level, so we don't need to watch
this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow);
const { hsUrl, isUrl, delegatedAuthentication } = this.props.serverConfig;
this.loginLogic = new Login(hsUrl, isUrl, null, {
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
// if native OIDC is enabled in the client pass the server's delegated auth settings
delegatedAuthentication: this.oidcNativeFlowEnabled ? delegatedAuthentication : undefined,
});
}
componentDidMount() {
public componentDidMount(): void {
this.replaceClient(this.props.serverConfig);
//triggers a confirmation dialog for data loss before page unloads/refreshes
window.addEventListener("beforeunload", this.unloadCallback);
}
componentWillUnmount() {
public componentWillUnmount(): void {
window.removeEventListener("beforeunload", this.unloadCallback);
}
private unloadCallback = (event: BeforeUnloadEvent) => {
private unloadCallback = (event: BeforeUnloadEvent): string | undefined => {
if (this.state.doingUIAuth) {
event.preventDefault();
event.returnValue = "";
return "";
}
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
UNSAFE_componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
this.replaceClient(newProps.serverConfig);
public componentDidUpdate(prevProps: IProps): void {
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
this.replaceClient(this.props.serverConfig);
}
}
private async replaceClient(serverConfig: ValidatedServerConfig) {
private async replaceClient(serverConfig: ValidatedServerConfig): Promise<void> {
this.latestServerConfig = serverConfig;
const { hsUrl, isUrl } = serverConfig;
@ -213,22 +231,38 @@ export default class Registration extends React.Component<IProps, IState> {
this.loginLogic.setHomeserverUrl(hsUrl);
this.loginLogic.setIdentityServerUrl(isUrl);
// if native OIDC is enabled in the client pass the server's delegated auth settings
const delegatedAuthentication = this.oidcNativeFlowEnabled ? serverConfig.delegatedAuthentication : undefined;
let ssoFlow: ISSOFlow;
this.loginLogic.setDelegatedAuthentication(delegatedAuthentication);
let ssoFlow: SSOFlow | undefined;
let oidcNativeFlow: OidcNativeFlow | undefined;
try {
const loginFlows = await this.loginLogic.getFlows();
const loginFlows = await this.loginLogic.getFlows(true);
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow;
ssoFlow = loginFlows.find((f) => f.type === "m.login.sso" || f.type === "m.login.cas") as SSOFlow;
oidcNativeFlow = loginFlows.find((f) => f.type === "oidcNativeFlow") as OidcNativeFlow;
} catch (e) {
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
logger.error("Failed to get login flows to check for SSO support", e);
}
this.setState({
this.setState(({ flows }) => ({
matrixClient: cli,
ssoFlow,
oidcNativeFlow,
// if we are using oidc native we won't continue with flow discovery on HS
// so set an empty array to indicate flows are no longer loading
flows: oidcNativeFlow ? [] : flows,
busy: false,
});
}));
// don't need to check with homeserver for login flows
// since we are going to use OIDC native flow
if (oidcNativeFlow) {
return;
}
try {
// We do the first registration request ourselves to discover whether we need to
@ -242,22 +276,22 @@ export default class Registration extends React.Component<IProps, IState> {
}
} catch (e) {
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
if (e.httpStatus === 401) {
if (e instanceof MatrixError && e.httpStatus === 401) {
this.setState({
flows: e.data.flows,
});
} else if (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN") {
} else if (e instanceof MatrixError && (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN")) {
// Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN.
// At this point registration is pretty much disabled, but before we do that let's
// quickly check to see if the server supports SSO instead. If it does, we'll send
// the user off to the login page to figure their account out.
if (ssoFlow) {
// Redirect to login page - server probably expects SSO only
dis.dispatch({ action: 'start_login' });
dis.dispatch({ action: "start_login" });
} else {
this.setState({
serverErrorIsFatal: true, // fatal because user cannot continue on this server
errorText: _t("Registration has been disabled on this homeserver."),
errorText: _t("auth|registration_disabled"),
// add empty flows array to get rid of spinner
flows: [],
});
@ -265,7 +299,7 @@ export default class Registration extends React.Component<IProps, IState> {
} else {
logger.log("Unable to query for supported registration methods.", e);
this.setState({
errorText: _t("Unable to query for supported registration methods."),
errorText: _t("auth|failed_query_registration_methods"),
// add empty flows array to get rid of spinner
flows: [],
});
@ -282,58 +316,50 @@ export default class Registration extends React.Component<IProps, IState> {
});
};
private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => {
return this.state.matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
sendAttempt,
this.props.makeRegistrationUrl({
client_secret: clientSecret,
hs_url: this.state.matrixClient.getHomeserverUrl(),
is_url: this.state.matrixClient.getIdentityServerUrl(),
session_id: sessionId,
}),
);
private requestEmailToken = (
emailAddress: string,
clientSecret: string,
sendAttempt: number,
sessionId: string,
): Promise<IRequestTokenResponse> => {
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
return this.state.matrixClient.requestRegisterEmailToken(emailAddress, clientSecret, sendAttempt);
};
private onUIAuthFinished: InteractiveAuthCallback = async (success, response) => {
private onUIAuthFinished: InteractiveAuthCallback<RegisterResponse> = async (success, response): Promise<void> => {
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
debuglog("Registration: ui authentication finished: ", { success, response });
if (!success) {
let errorText: ReactNode = response.message || response.toString();
let errorText: ReactNode = (response as Error).message || (response as Error).toString();
// can we give a better error message?
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
if (response instanceof MatrixError && response.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact,
{
'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
'hs_blocked': _td("This homeserver has been blocked by its administrator."),
'': _td("This homeserver has exceeded one of its resource limits."),
},
resourceLimitStrings,
);
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
},
adminContactStrings,
);
errorText = <div>
<p>{ errorTop }</p>
<p>{ errorDetail }</p>
</div>;
} else if (response.required_stages && response.required_stages.includes(AuthType.Msisdn)) {
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdnAvailable = msisdnAvailable || flow.stages.includes(AuthType.Msisdn);
}
errorText = (
<div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
</div>
);
} else if ((response as IAuthData).flows?.some((flow) => flow.stages.includes(AuthType.Msisdn))) {
const flows = (response as IAuthData).flows ?? [];
const msisdnAvailable = flows.some((flow) => flow.stages.includes(AuthType.Msisdn));
if (!msisdnAvailable) {
errorText = _t('This server does not support authentication with a phone number.');
errorText = _t("auth|unsupported_auth_msisdn");
}
} else if (response.errcode === "M_USER_IN_USE") {
errorText = _t("Someone already has that username, please try another.");
} else if (response.errcode === "M_THREEPID_IN_USE") {
errorText = _t("That e-mail address is already in use.");
} else if (response instanceof MatrixError && response.errcode === "M_USER_IN_USE") {
errorText = _t("auth|username_in_use");
} else if (response instanceof MatrixError && response.errcode === "M_THREEPID_IN_USE") {
errorText = _t("auth|3pid_in_use");
}
this.setState({
@ -344,12 +370,16 @@ export default class Registration extends React.Component<IProps, IState> {
return;
}
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
const userId = (response as RegisterResponse).user_id;
const accessToken = (response as RegisterResponse).access_token;
if (!userId || !accessToken) throw new Error("Registration failed");
const newState = {
MatrixClientPeg.setJustRegisteredUserId(userId);
const newState: Partial<IState> = {
doingUIAuth: false,
registeredUsername: response.user_id,
differentLoggedInUserId: null,
registeredUsername: userId,
differentLoggedInUserId: undefined,
completedNoSignin: false,
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
@ -361,10 +391,8 @@ export default class Registration extends React.Component<IProps, IState> {
// starting the registration process. This isn't perfect since it's possible
// 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.user_id) {
logger.log(
`Found a session for ${sessionOwner} but ${response.user_id} has just registered.`,
);
if (sessionOwner && !sessionIsGuest && sessionOwner !== userId) {
logger.log(`Found a session for ${sessionOwner} but ${userId} has just registered.`);
newState.differentLoggedInUserId = sessionOwner;
}
@ -381,19 +409,20 @@ export default class Registration extends React.Component<IProps, IState> {
// as the client that started registration may be gone by the time we've verified the email, and only the client
// that verified the email is guaranteed to exist, we'll always do the login in that client.
const hasEmail = Boolean(this.state.formVals.email);
const hasAccessToken = Boolean(response.access_token);
const hasAccessToken = Boolean(accessToken);
debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken });
// dont log in if we found a session for a different user
if (!hasEmail && hasAccessToken && !newState.differentLoggedInUserId) {
// we'll only try logging in if we either have no email to verify at all or we're the client that verified
// the email, not the client that started the registration flow
await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
}, this.state.formVals.password);
if (hasAccessToken && !newState.differentLoggedInUserId) {
await this.props.onLoggedIn(
{
userId,
deviceId: (response as RegisterResponse).device_id!,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken,
},
this.state.formVals.password!,
);
this.setupPushers();
} else {
@ -401,39 +430,45 @@ export default class Registration extends React.Component<IProps, IState> {
newState.completedNoSignin = true;
}
this.setState(newState);
this.setState(newState as IState);
};
private setupPushers() {
private setupPushers(): Promise<void> {
if (!this.props.brand) {
return Promise.resolve();
}
const matrixClient = MatrixClientPeg.get();
return matrixClient.getPushers().then((resp) => {
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).then(() => {
logger.log("Set email branding to " + this.props.brand);
}, (error) => {
logger.error("Couldn't set email branding: " + error);
});
const matrixClient = MatrixClientPeg.safeGet();
return matrixClient.getPushers().then(
(resp) => {
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === "email") {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).then(
() => {
logger.log("Set email branding to " + this.props.brand);
},
(error) => {
logger.error("Couldn't set email branding: " + error);
},
);
}
}
}
}, (error) => {
logger.error("Couldn't get pushers: " + error);
});
},
(error) => {
logger.error("Couldn't get pushers: " + error);
},
);
}
private onLoginClick = ev => {
private onLoginClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
private onGoToFormClicked = ev => {
private onGoToFormClicked = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.replaceClient(this.props.serverConfig);
@ -443,8 +478,10 @@ export default class Registration extends React.Component<IProps, IState> {
});
};
private makeRegisterRequest = auth => {
const registerParams = {
private makeRegisterRequest = (auth: IAuthDict | null): Promise<RegisterResponse> => {
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
const registerParams: IRegisterRequestParams = {
username: this.state.formVals.username,
password: this.state.formVals.password,
initial_device_display_name: this.props.defaultDeviceDisplayName,
@ -458,7 +495,7 @@ export default class Registration extends React.Component<IProps, IState> {
return this.state.matrixClient.registerRequest(registerParams);
};
private getUIAuthInputs() {
private getUIAuthInputs(): IInputs {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
@ -469,7 +506,7 @@ export default class Registration extends React.Component<IProps, IState> {
// Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?)
private onLoginClickWithCheck = async ev => {
private onLoginClickWithCheck = async (ev: ButtonEvent): Promise<boolean> => {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true });
@ -481,193 +518,243 @@ export default class Registration extends React.Component<IProps, IState> {
return sessionLoaded;
};
private renderRegisterComponent() {
private renderRegisterComponent(): ReactNode {
if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this.state.matrixClient}
makeRequest={this.makeRegisterRequest}
onAuthFinished={this.onUIAuthFinished}
inputs={this.getUIAuthInputs()}
requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>;
return (
<InteractiveAuth
matrixClient={this.state.matrixClient}
makeRequest={this.makeRegisterRequest}
onAuthFinished={this.onUIAuthFinished}
inputs={this.getUIAuthInputs()}
requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>
);
} else if (!this.state.matrixClient && !this.state.busy) {
return null;
} else if (this.state.busy || !this.state.flows) {
return <div className="mx_AuthBody_spinner">
<Spinner />
</div>;
} else if (this.state.flows.length) {
let ssoSection;
return (
<div className="mx_AuthBody_spinner">
<Spinner />
</div>
);
} else if (this.state.matrixClient && this.state.oidcNativeFlow) {
return (
<AccessibleButton
className="mx_Login_fullWidthButton"
kind="primary"
onClick={async () => {
await startOidcLogin(
this.props.serverConfig.delegatedAuthentication!,
this.state.oidcNativeFlow!.clientId,
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
true /* isRegistration */,
);
}}
>
{_t("action|continue")}
</AccessibleButton>
);
} else if (this.state.matrixClient && this.state.flows.length) {
let ssoSection: JSX.Element | undefined;
if (this.state.ssoFlow) {
let continueWithSection;
const providers = this.state.ssoFlow.identity_providers || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context
continueWithSection = <h2 className="mx_AuthBody_centered">
{ _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() }
</h2>;
continueWithSection = (
<h2 className="mx_AuthBody_centered">
{_t("auth|continue_with_sso", { ssoButtons: "" }).trim()}
</h2>
);
}
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
ssoSection = <React.Fragment>
{ continueWithSection }
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={this.state.ssoFlow}
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
/>
<h2 className="mx_AuthBody_centered">
{ _t(
"%(ssoButtons)s Or %(usernamePassword)s",
{
ssoSection = (
<React.Fragment>
{continueWithSection}
<SSOButtons
matrixClient={this.loginLogic.createTemporaryClient()}
flow={this.state.ssoFlow}
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
fragmentAfterLogin={this.props.fragmentAfterLogin}
action={SSOAction.REGISTER}
/>
<h2 className="mx_AuthBody_centered">
{_t("auth|sso_or_username_password", {
ssoButtons: "",
usernamePassword: "",
},
).trim() }
</h2>
</React.Fragment>;
}).trim()}
</h2>
</React.Fragment>
);
}
return <React.Fragment>
{ ssoSection }
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
matrixClient={this.state.matrixClient}
/>
</React.Fragment>;
return (
<React.Fragment>
{ssoSection}
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
onRegisterClick={this.onFormSubmit}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
matrixClient={this.state.matrixClient}
/>
</React.Fragment>
);
}
return null;
}
render() {
public render(): React.ReactNode {
let errorText;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
errorText = <div className="mx_Login_error">{err}</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
mx_Login_error: true,
mx_Login_serverError: true,
mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{ this.state.serverDeadError }
</div>
);
serverDeadSection = <div className={classes}>{this.state.serverDeadError}</div>;
}
const signIn = <span className="mx_AuthBody_changeFlow">
{ _t("Already have an account? <a>Sign in here</a>", {}, {
a: sub => <AccessibleButton kind='link_inline' onClick={this.onLoginClick}>{ sub }</AccessibleButton>,
}) }
</span>;
const signIn = (
<span className="mx_AuthBody_changeFlow">
{_t(
"auth|sign_in_instead_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onLoginClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
// Only show the 'go back' button if you're not looking at the form
let goBack;
if (this.state.doingUIAuth) {
goBack = <AccessibleButton
kind='link'
className="mx_AuthBody_changeFlow"
onClick={this.onGoToFormClicked}
>
{ _t('Go back') }
</AccessibleButton>;
goBack = (
<AccessibleButton kind="link" className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked}>
{_t("action|go_back")}
</AccessibleButton>
);
}
let body;
if (this.state.completedNoSignin) {
let regDoneText;
if (this.state.differentLoggedInUserId) {
regDoneText = <div>
<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
kind="link_inline"
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>;
regDoneText = (
<div>
<p>
{_t("auth|account_clash", {
newAccountId: this.state.registeredUsername,
loggedInUserId: this.state.differentLoggedInUserId,
})}
</p>
<p>
<AccessibleButton
kind="link_inline"
onClick={async (event: ButtonEvent): Promise<void> => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_welcome_page" });
}
}}
>
{_t("auth|account_clash_previous_account")}
</AccessibleButton>
</p>
</div>
);
} else {
// regardless of whether we're the client that started the registration or not, we should
// try our credentials anyway
regDoneText = <h2>{ _t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <AccessibleButton
kind="link_inline"
onClick={async event => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_home_page" });
}
}}
>{ sub }</AccessibleButton>,
},
) }</h2>;
regDoneText = (
<h2>
{_t(
"auth|log_in_new_account",
{},
{
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={async (event: ButtonEvent): Promise<void> => {
const sessionLoaded = await this.onLoginClickWithCheck(event);
if (sessionLoaded) {
dis.dispatch({ action: "view_home_page" });
}
}}
>
{sub}
</AccessibleButton>
),
},
)}
</h2>
);
}
body = <div>
<h1>{ _t("Registration Successful") }</h1>
{ regDoneText }
</div>;
body = (
<div>
<h1>{_t("auth|registration_successful")}</h1>
{regDoneText}
</div>
);
} else {
body = <Fragment>
<div className="mx_Register_mainContent">
<AuthHeaderDisplay
title={_t('Create account')}
serverPicker={<ServerPicker
title={_t("Host account on")}
dialogTitle={_t("Decide where your account is hosted")}
serverConfig={this.props.serverConfig}
onServerConfigChange={this.state.doingUIAuth ? undefined : this.props.onServerConfigChange}
/>}
>
{ errorText }
{ serverDeadSection }
</AuthHeaderDisplay>
{ this.renderRegisterComponent() }
</div>
<div className="mx_Register_footerActions">
{ goBack }
{ signIn }
</div>
</Fragment>;
body = (
<Fragment>
<div className="mx_Register_mainContent">
<AuthHeaderDisplay
title={_t("auth|create_account_title")}
serverPicker={
<ServerPicker
title={_t("auth|server_picker_title_registration")}
dialogTitle={_t("auth|server_picker_dialog_title")}
serverConfig={this.props.serverConfig}
onServerConfigChange={
this.state.doingUIAuth ? undefined : this.props.onServerConfigChange
}
/>
}
>
{errorText}
{serverDeadSection}
</AuthHeaderDisplay>
{this.renderRegisterComponent()}
</div>
<div className="mx_Register_footerActions">
{goBack}
{signIn}
</div>
</Fragment>
);
}
return (
<AuthPage>
<AuthHeader />
<AuthHeaderProvider>
<AuthBody flex>
{ body }
</AuthBody>
<AuthBody flex>{body}</AuthBody>
</AuthHeaderProvider>
</AuthPage>
);

View file

@ -0,0 +1,35 @@
/*
Copyright 2023 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 SplashPage from "../SplashPage";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
/**
* Component shown by {@link MatrixChat} when another session is started in the same browser.
*/
export function SessionLockStolenView(): JSX.Element {
const brand = SdkConfig.get().brand;
return (
<SplashPage className="mx_SessionLockStolenView">
<h1>{_t("error_app_open_in_another_tab_title", { brand })}</h1>
<h2>{_t("error_app_open_in_another_tab", { brand })}</h2>
</SplashPage>
);
}

View file

@ -14,27 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
import React from "react";
import { ISecretStorageKeyInfo } from "matrix-js-sdk/src/crypto/api";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import VerificationRequestDialog from "../../views/dialogs/VerificationRequestDialog";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton from '../../views/elements/AccessibleButton';
import Spinner from '../../views/elements/Spinner';
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import Spinner from "../../views/elements/Spinner";
function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
return Boolean(
keyInfo.passphrase &&
keyInfo.passphrase.salt &&
keyInfo.passphrase.iterations,
);
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
}
interface IProps {
@ -42,14 +38,14 @@ interface IProps {
}
interface IState {
phase: Phase;
verificationRequest: VerificationRequest;
backupInfo: IKeyBackupInfo;
phase?: Phase;
verificationRequest: VerificationRequest | null;
backupInfo: IKeyBackupInfo | null;
lostKeys: boolean;
}
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
constructor(props) {
public constructor(props: IProps) {
super(props);
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this.onStoreUpdate);
@ -65,7 +61,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
};
}
private onStoreUpdate = () => {
private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === Phase.Finished) {
this.props.onFinished();
@ -79,29 +75,29 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
});
};
public componentWillUnmount() {
public componentWillUnmount(): void {
const store = SetupEncryptionStore.sharedInstance();
store.off("update", this.onStoreUpdate);
store.stop();
}
private onUsePassphraseClick = async () => {
private onUsePassphraseClick = async (): Promise<void> => {
const store = SetupEncryptionStore.sharedInstance();
store.usePassPhrase();
};
private onVerifyClick = () => {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const requestPromise = cli.requestVerification(userId);
private onVerifyClick = (): void => {
const cli = MatrixClientPeg.safeGet();
const userId = cli.getSafeUserId();
const requestPromise = cli.getCrypto()!.requestOwnUserVerification();
// We need to call onFinished now to close this dialog, and
// again later to signal that the verification is complete.
this.props.onFinished();
Modal.createDialog(VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
onFinished: async () => {
member: cli.getUser(userId) ?? undefined,
onFinished: async (): Promise<void> => {
const request = await requestPromise;
request.cancel();
this.props.onFinished();
@ -109,70 +105,65 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
});
};
private onSkipConfirmClick = () => {
private onSkipConfirmClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm();
};
private onSkipBackClick = () => {
private onSkipBackClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterSkip();
};
private onResetClick = (ev: React.MouseEvent<HTMLButtonElement>) => {
private onResetClick = (ev: ButtonEvent): void => {
ev.preventDefault();
const store = SetupEncryptionStore.sharedInstance();
store.reset();
};
private onResetConfirmClick = () => {
private onResetConfirmClick = (): void => {
this.props.onFinished();
const store = SetupEncryptionStore.sharedInstance();
store.resetConfirm();
};
private onResetBackClick = () => {
private onResetBackClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterReset();
};
private onDoneClick = () => {
private onDoneClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
};
private onEncryptionPanelClose = () => {
private onEncryptionPanelClose = (): void => {
this.props.onFinished();
};
public render() {
const {
phase,
lostKeys,
} = this.state;
public render(): React.ReactNode {
const cli = MatrixClientPeg.safeGet();
const { phase, lostKeys } = this.state;
if (this.state.verificationRequest) {
return <EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.onEncryptionPanelClose}
member={MatrixClientPeg.get().getUser(this.state.verificationRequest.otherUserId)}
isRoomEncrypted={false}
/>;
if (this.state.verificationRequest && cli.getUser(this.state.verificationRequest.otherUserId)) {
return (
<EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.onEncryptionPanelClose}
member={cli.getUser(this.state.verificationRequest.otherUserId)!}
isRoomEncrypted={false}
/>
);
} else if (phase === Phase.Intro) {
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>
<p>{_t("encryption|verification|no_key_or_device")}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
{_t("encryption|verification|reset_proceed_prompt")}
</AccessibleButton>
</div>
</div>
@ -181,71 +172,67 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Verify with Security Key or Phrase");
recoveryKeyPrompt = _t("encryption|verification|verify_using_key_or_phrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("Verify with Security Key");
recoveryKeyPrompt = _t("encryption|verification|verify_using_key");
}
let useRecoveryKeyButton;
if (recoveryKeyPrompt) {
useRecoveryKeyButton = <AccessibleButton kind="primary" onClick={this.onUsePassphraseClick}>
{ recoveryKeyPrompt }
</AccessibleButton>;
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 device") }
</AccessibleButton>;
verifyButton = (
<AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{_t("encryption|verification|verify_using_device")}
</AccessibleButton>
);
}
return (
<div>
<p>{ _t(
"Verify your identity to access encrypted messages and prove your identity to others.",
) }</p>
<p>{_t("encryption|verification|verification_description")}</p>
<div className="mx_CompleteSecurity_actionRow">
{ verifyButton }
{ useRecoveryKeyButton }
{verifyButton}
{useRecoveryKeyButton}
</div>
<div className="mx_SetupEncryptionBody_reset">
{ _t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
a: (sub) => <AccessibleButton
kind="link_inline"
className="mx_SetupEncryptionBody_reset_link"
onClick={this.onResetClick}
>
{ sub }
</AccessibleButton>,
}) }
{_t("encryption|reset_all_button", undefined, {
a: (sub) => (
<AccessibleButton
kind="link_inline"
className="mx_SetupEncryptionBody_reset_link"
onClick={this.onResetClick}
>
{sub}
</AccessibleButton>
),
})}
</div>
</div>
);
}
} else if (phase === Phase.Done) {
let message;
let message: JSX.Element;
if (this.state.backupInfo) {
message = <p>{ _t(
"Your new device is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
) }</p>;
message = <p>{_t("encryption|verification|verification_success_with_backup")}</p>;
} else {
message = <p>{ _t(
"Your new device is now verified. Other users will see it as trusted.",
) }</p>;
message = <p>{_t("encryption|verification|verification_success_without_backup")}</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") }
<AccessibleButton kind="primary" onClick={this.onDoneClick}>
{_t("action|done")}
</AccessibleButton>
</div>
</div>
@ -253,22 +240,13 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
} else if (phase === Phase.ConfirmSkip) {
return (
<div>
<p>{ _t(
"Without verifying, you won't have access to all your messages " +
"and may appear as untrusted to others.",
) }</p>
<p>{_t("encryption|verification|verification_skip_warning")}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
kind="danger_outline"
onClick={this.onSkipConfirmClick}
>
{ _t("I'll verify later") }
<AccessibleButton kind="danger_outline" onClick={this.onSkipConfirmClick}>
{_t("encryption|verification|verify_later")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.onSkipBackClick}
>
{ _t("Go Back") }
<AccessibleButton kind="primary" onClick={this.onSkipBackClick}>
{_t("action|go_back")}
</AccessibleButton>
</div>
</div>
@ -276,23 +254,15 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
} 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>
<p>{_t("encryption|verification|verify_reset_warning_1")}</p>
<p>{_t("encryption|verification|verify_reset_warning_2")}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
{_t("encryption|verification|reset_proceed_prompt")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
{ _t("Go Back") }
{_t("action|go_back")}
</AccessibleButton>
</div>
</div>

View file

@ -14,26 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { ChangeEvent, SyntheticEvent } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Optional } from "matrix-events-sdk";
import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { LoginFlow, MatrixError, SSOAction, SSOFlow } from "matrix-js-sdk/src/matrix";
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import * as Lifecycle from "../../../Lifecycle";
import Modal from "../../../Modal";
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
import { sendLoginRequest } from "../../../Login";
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 ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog';
import Field from '../../views/elements/Field';
import AccessibleButton from '../../views/elements/AccessibleButton';
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 { SDKContext } from "../../../contexts/SDKContext";
enum LoginView {
Loading,
@ -44,7 +45,7 @@ enum LoginView {
Unsupported,
}
const STATIC_FLOWS_TO_VIEWS = {
const STATIC_FLOWS_TO_VIEWS: Record<string, LoginView> = {
"m.login.password": LoginView.Password,
"m.login.cas": LoginView.CAS,
"m.login.sso": LoginView.SSO,
@ -63,7 +64,6 @@ interface IProps {
interface IState {
loginView: LoginView;
keyBackupNeeded: boolean;
busy: boolean;
password: string;
errorText: string;
@ -71,12 +71,16 @@ interface IState {
}
export default class SoftLogout extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
public static contextType = SDKContext;
public context!: React.ContextType<typeof SDKContext>;
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
this.context = context;
this.state = {
loginView: LoginView.Loading,
keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount)
busy: false,
password: "",
errorText: "",
@ -92,80 +96,79 @@ export default class SoftLogout extends React.Component<IProps, IState> {
}
this.initLogin();
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled()) {
cli.countSessionsNeedingBackup().then(remaining => {
this.setState({ keyBackupNeeded: remaining > 0 });
});
}
}
private onClearAll = () => {
private onClearAll = (): void => {
Modal.createDialog(ConfirmWipeDeviceDialog, {
onFinished: (wipeData) => {
if (!wipeData) return;
logger.log("Clearing data from soft-logged-out session");
Lifecycle.logout();
Lifecycle.logout(this.context.oidcClientStore);
},
});
};
private async initLogin() {
private async initLogin(): Promise<void> {
const queryParams = this.props.realQueryParams;
const hasAllParams = queryParams && queryParams['loginToken'];
const hasAllParams = queryParams?.["loginToken"];
if (hasAllParams) {
this.setState({ loginView: LoginView.Loading });
this.trySsoLogin();
return;
const loggedIn = await this.trySsoLogin();
if (loggedIn) return;
}
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
// care about login flows here, unless it is the single flow we support.
const client = MatrixClientPeg.get();
const client = MatrixClientPeg.safeGet();
const flows = (await client.loginFlows()).flows;
const loginViews = flows.map(f => STATIC_FLOWS_TO_VIEWS[f.type]);
const loginViews = flows.map((f) => STATIC_FLOWS_TO_VIEWS[f.type]);
const isSocialSignOn = loginViews.includes(LoginView.Password) && loginViews.includes(LoginView.SSO);
const firstView = loginViews.filter(f => !!f)[0] || LoginView.Unsupported;
const firstView = loginViews.filter((f) => !!f)[0] || LoginView.Unsupported;
const chosenView = isSocialSignOn ? LoginView.PasswordWithSocialSignOn : firstView;
this.setState({ flows, loginView: chosenView });
}
private onPasswordChange = (ev) => {
private onPasswordChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({ password: ev.target.value });
};
private onForgotPassword = () => {
dis.dispatch({ action: 'start_password_recovery' });
private onForgotPassword = (): void => {
dis.dispatch({ action: "start_password_recovery" });
};
private onPasswordLogin = async (ev) => {
private onPasswordLogin = async (ev: SyntheticEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
this.setState({ busy: true });
const hsUrl = MatrixClientPeg.get().getHomeserverUrl();
const isUrl = MatrixClientPeg.get().getIdentityServerUrl();
const cli = MatrixClientPeg.safeGet();
const hsUrl = cli.getHomeserverUrl();
const isUrl = cli.getIdentityServerUrl();
const loginType = "m.login.password";
const loginParams = {
identifier: {
type: "m.id.user",
user: MatrixClientPeg.get().getUserId(),
user: cli.getUserId(),
},
password: this.state.password,
device_id: MatrixClientPeg.get().getDeviceId(),
device_id: cli.getDeviceId() ?? undefined,
};
let credentials = null;
let credentials: IMatrixClientCreds;
try {
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
} catch (e) {
let errorText = _t("Failed to re-authenticate due to a homeserver problem");
if (e.errcode === "M_FORBIDDEN" && (e.httpStatus === 401 || e.httpStatus === 403)) {
errorText = _t("Incorrect password");
let errorText = _t("auth|failed_soft_logout_homeserver");
if (
e instanceof MatrixError &&
e.errcode === "M_FORBIDDEN" &&
(e.httpStatus === 401 || e.httpStatus === 403)
) {
errorText = _t("auth|incorrect_password");
}
this.setState({
@ -177,51 +180,67 @@ export default class SoftLogout extends React.Component<IProps, IState> {
Lifecycle.hydrateSession(credentials).catch((e) => {
logger.error(e);
this.setState({ busy: false, errorText: _t("Failed to re-authenticate") });
this.setState({ busy: false, errorText: _t("auth|failed_soft_logout_auth") });
});
};
private async trySsoLogin() {
/**
* Attempt to login via SSO
* @returns A promise that resolves to a boolean - true when sso login was successful
*/
private async trySsoLogin(): Promise<boolean> {
this.setState({ busy: true });
const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
if (!hsUrl) {
logger.error("Homeserver URL unknown for SSO login callback");
this.setState({ busy: false, loginView: LoginView.Unsupported });
return false;
}
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.safeGet().getIdentityServerUrl();
const loginType = "m.login.token";
const loginParams = {
token: this.props.realQueryParams['loginToken'],
device_id: MatrixClientPeg.get().getDeviceId(),
token: this.props.realQueryParams["loginToken"],
device_id: MatrixClientPeg.safeGet().getDeviceId() ?? undefined,
};
let credentials = null;
let credentials: IMatrixClientCreds;
try {
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
} catch (e) {
logger.error(e);
this.setState({ busy: false, loginView: LoginView.Unsupported });
return;
return false;
}
Lifecycle.hydrateSession(credentials).then(() => {
if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted();
}).catch((e) => {
logger.error(e);
this.setState({ busy: false, loginView: LoginView.Unsupported });
});
return Lifecycle.hydrateSession(credentials)
.then(() => {
if (this.props.onTokenLoginCompleted) {
this.props.onTokenLoginCompleted();
}
return true;
})
.catch((e) => {
logger.error(e);
this.setState({ busy: false, loginView: LoginView.Unsupported });
return false;
});
}
private renderPasswordForm(introText: Optional<string>): JSX.Element {
let error: JSX.Element = null;
let error: JSX.Element | undefined;
if (this.state.errorText) {
error = <span className='mx_Login_error'>{ this.state.errorText }</span>;
error = <span className="mx_Login_error">{this.state.errorText}</span>;
}
return (
<form onSubmit={this.onPasswordLogin}>
{ introText ? <p>{ introText }</p> : null }
{ error }
{introText ? <p>{introText}</p> : null}
{error}
<Field
type="password"
label={_t("Password")}
label={_t("common|password")}
onChange={this.onPasswordChange}
value={this.state.password}
disabled={this.state.busy}
@ -232,10 +251,10 @@ export default class SoftLogout extends React.Component<IProps, IState> {
type="submit"
disabled={this.state.busy}
>
{ _t("Sign In") }
{_t("action|sign_in")}
</AccessibleButton>
<AccessibleButton onClick={this.onForgotPassword} kind="link">
{ _t("Forgotten your password?") }
{_t("auth|forgot_password_prompt")}
</AccessibleButton>
</form>
);
@ -243,111 +262,75 @@ export default class SoftLogout extends React.Component<IProps, IState> {
private renderSsoForm(introText: Optional<string>): JSX.Element {
const loginType = this.state.loginView === LoginView.CAS ? "cas" : "sso";
const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow;
const flow = this.state.flows.find((flow) => flow.type === "m.login." + loginType) as SSOFlow;
return (
<div>
{ introText ? <p>{ introText }</p> : null }
{introText ? <p>{introText}</p> : null}
<SSOButtons
matrixClient={MatrixClientPeg.get()}
matrixClient={MatrixClientPeg.safeGet()}
flow={flow}
loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows.find(flow => flow.type === "m.login.password")}
primary={!this.state.flows.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
/>
</div>
);
}
private renderSignInSection() {
private renderSignInSection(): JSX.Element {
if (this.state.loginView === LoginView.Loading) {
return <Spinner />;
}
let introText = null; // null is translated to something area specific in this function
if (this.state.keyBackupNeeded) {
introText = _t(
"Regain access to your account and recover encryption keys stored in this session. " +
"Without them, you won't be able to read all of your secure messages in any session.");
}
if (this.state.loginView === LoginView.Password) {
if (!introText) {
introText = _t("Enter your password to sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
return this.renderPasswordForm(introText);
return this.renderPasswordForm(_t("auth|soft_logout_intro_password"));
}
if (this.state.loginView === LoginView.SSO || this.state.loginView === LoginView.CAS) {
if (!introText) {
introText = _t("Sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
return this.renderSsoForm(introText);
return this.renderSsoForm(_t("auth|soft_logout_intro_sso"));
}
if (this.state.loginView === LoginView.PasswordWithSocialSignOn) {
if (!introText) {
introText = _t("Sign in and regain access to your account.");
}
// We render both forms with no intro/error to ensure the layout looks reasonably
// okay enough.
//
// Note: "mx_AuthBody_centered" text taken from registration page.
return <>
<p>{ introText }</p>
{ this.renderSsoForm(null) }
<h2 className="mx_AuthBody_centered">
{ _t(
"%(ssoButtons)s Or %(usernamePassword)s",
{
return (
<>
<p>{_t("auth|soft_logout_intro_sso")}</p>
{this.renderSsoForm(null)}
<h2 className="mx_AuthBody_centered">
{_t("auth|sso_or_username_password", {
ssoButtons: "",
usernamePassword: "",
},
).trim() }
</h2>
{ this.renderPasswordForm(null) }
</>;
}).trim()}
</h2>
{this.renderPasswordForm(null)}
</>
);
}
// Default: assume unsupported/error
return (
<p>
{ _t(
"You cannot sign in to your account. Please contact your " +
"homeserver admin for more information.",
) }
</p>
);
return <p>{_t("auth|soft_logout_intro_unsupported_auth")}</p>;
}
public render() {
public render(): React.ReactNode {
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h1>
{ _t("You're signed out") }
</h1>
<h1>{_t("auth|soft_logout_heading")}</h1>
<h2>{ _t("Sign in") }</h2>
<div>
{ this.renderSignInSection() }
</div>
<h2>{_t("action|sign_in")}</h2>
<div>{this.renderSignInSection()}</div>
<h2>{ _t("Clear personal data") }</h2>
<p>
{ _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>
<h2>{_t("auth|soft_logout_subheading")}</h2>
<p>{_t("auth|soft_logout_warning")}</p>
<div>
<AccessibleButton onClick={this.onClearAll} kind="danger">
{ _t("Clear all data") }
{_t("auth|soft_logout|clear_data_button")}
</AccessibleButton>
</div>
</AuthBody>

View file

@ -0,0 +1,78 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import { Tooltip } from "@vector-im/compound-web";
import AccessibleButton from "../../../views/elements/AccessibleButton";
import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
import { Icon as RetryIcon } from "../../../../../res/img/compound/retry-16px.svg";
import { _t } from "../../../../languageHandler";
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
import { ErrorMessage } from "../../ErrorMessage";
interface CheckEmailProps {
email: string;
errorText: string | ReactNode | null;
onReEnterEmailClick: () => void;
onResendClick: () => Promise<boolean>;
onSubmitForm: (ev: React.FormEvent) => void;
}
/**
* This component renders the email verification view of the forgot password flow.
*/
export const CheckEmail: React.FC<CheckEmailProps> = ({
email,
errorText,
onReEnterEmailClick,
onSubmitForm,
onResendClick,
}) => {
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
const onResendClickFn = async (): Promise<void> => {
await onResendClick();
toggleTooltipVisible();
};
return (
<>
<EMailPromptIcon className="mx_AuthBody_emailPromptIcon--shifted" />
<h1>{_t("auth|uia|email_auth_header")}</h1>
<div className="mx_AuthBody_text">
<p>{_t("auth|check_email_explainer", { email: email }, { b: (t) => <b>{t}</b> })}</p>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_wrong_email_prompt")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
{_t("auth|check_email_wrong_email_button")}
</AccessibleButton>
</div>
</div>
{errorText && <ErrorMessage message={errorText} />}
<input onClick={onSubmitForm} type="button" className="mx_Login_submit" value={_t("action|next")} />
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_resend_prompt")}</span>
<Tooltip label={_t("auth|check_email_resend_tooltip")} side="top" open={tooltipVisible}>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
<RetryIcon className="mx_Icon mx_Icon_16" />
{_t("action|resend")}
</AccessibleButton>
</Tooltip>
</div>
</>
);
};

View file

@ -0,0 +1,105 @@
/*
Copyright 2022 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, useRef } from "react";
import { Icon as EmailIcon } from "../../../../../res/img/element-icons/Email-icon.svg";
import { _t, _td } from "../../../../languageHandler";
import EmailField from "../../../views/auth/EmailField";
import { ErrorMessage } from "../../ErrorMessage";
import Spinner from "../../../views/elements/Spinner";
import Field from "../../../views/elements/Field";
import AccessibleButton, { ButtonEvent } from "../../../views/elements/AccessibleButton";
interface EnterEmailProps {
email: string;
errorText: string | ReactNode | null;
homeserver: string;
loading: boolean;
onInputChanged: (stateKey: "email", ev: React.FormEvent<HTMLInputElement>) => void;
onLoginClick: () => void;
onSubmitForm: (ev: React.FormEvent) => void;
}
/**
* This component renders the email input view of the forgot password flow.
*/
export const EnterEmail: React.FC<EnterEmailProps> = ({
email,
errorText,
homeserver,
loading,
onInputChanged,
onLoginClick,
onSubmitForm,
}) => {
const submitButtonChild = loading ? <Spinner w={16} h={16} /> : _t("auth|forgot_password_send_email");
const emailFieldRef = useRef<Field>(null);
const onSubmit = async (event: React.FormEvent): Promise<void> => {
if (await emailFieldRef.current?.validate({ allowEmpty: false })) {
onSubmitForm(event);
return;
}
emailFieldRef.current?.focus();
emailFieldRef.current?.validate({ allowEmpty: false, focused: true });
};
return (
<>
<EmailIcon className="mx_AuthBody_icon" />
<h1>{_t("auth|enter_email_heading")}</h1>
<p className="mx_AuthBody_text">
{_t("auth|enter_email_explainer", { homeserver }, { b: (t) => <b>{t}</b> })}
</p>
<form onSubmit={onSubmit}>
<fieldset disabled={loading}>
<div className="mx_AuthBody_fieldRow">
<EmailField
name="reset_email" // define a name so browser's password autofill gets less confused
label={_td("common|email_address")}
labelRequired={_td("auth|forgot_password_email_required")}
labelInvalid={_td("auth|forgot_password_email_invalid")}
value={email}
autoFocus={true}
onChange={(event: React.FormEvent<HTMLInputElement>) => onInputChanged("email", event)}
fieldRef={emailFieldRef}
/>
</div>
{errorText && <ErrorMessage message={errorText} />}
<button type="submit" className="mx_Login_submit">
{submitButtonChild}
</button>
<div className="mx_AuthBody_button-container">
<AccessibleButton
className="mx_AuthBody_sign-in-instead-button"
element="button"
kind="link"
onClick={(e: ButtonEvent) => {
e.preventDefault();
onLoginClick();
}}
>
{_t("auth|sign_in_instead")}
</AccessibleButton>
</div>
</fieldset>
</form>
</>
);
};

View file

@ -0,0 +1,91 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import { Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../../views/elements/AccessibleButton";
import { Icon as RetryIcon } from "../../../../../res/img/compound/retry-16px.svg";
import { Icon as EmailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
import { ErrorMessage } from "../../ErrorMessage";
interface Props {
email: string;
errorText: ReactNode | null;
onFinished(): void; // This modal is weird in that the way you close it signals intent
onCloseClick: () => void;
onReEnterEmailClick: () => void;
onResendClick: () => Promise<boolean>;
}
export const VerifyEmailModal: React.FC<Props> = ({
email,
errorText,
onCloseClick,
onReEnterEmailClick,
onResendClick,
}) => {
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
const onResendClickFn = async (): Promise<void> => {
await onResendClick();
toggleTooltipVisible();
};
return (
<>
<EmailPromptIcon className="mx_AuthBody_emailPromptIcon" />
<h1>{_t("auth|verify_email_heading")}</h1>
<p>
{_t(
"auth|verify_email_explainer",
{
email,
},
{
b: (sub) => <b>{sub}</b>,
},
)}
</p>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_resend_prompt")}</span>
<Tooltip label={_t("auth|check_email_resend_tooltip")} side="top" open={tooltipVisible}>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
<RetryIcon className="mx_Icon mx_Icon_16" />
{_t("action|resend")}
</AccessibleButton>
</Tooltip>
{errorText && <ErrorMessage message={errorText} />}
</div>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_wrong_email_prompt")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
{_t("auth|check_email_wrong_email_button")}
</AccessibleButton>
</div>
<AccessibleButton
onClick={onCloseClick}
className="mx_Dialog_cancelButton"
aria-label={_t("dialog_close_label")}
/>
</>
);
};

View file

@ -23,4 +23,4 @@ interface AuthHeaderContextType {
dispatch: Dispatch<ReducerAction<AuthHeaderReducer>>;
}
export const AuthHeaderContext = createContext<AuthHeaderContextType>(undefined);
export const AuthHeaderContext = createContext<AuthHeaderContextType | undefined>(undefined);

View file

@ -24,18 +24,18 @@ interface Props {
serverPicker: ReactNode;
}
export function AuthHeaderDisplay({ title, icon, serverPicker, children }: PropsWithChildren<Props>) {
export function AuthHeaderDisplay({ title, icon, serverPicker, children }: PropsWithChildren<Props>): JSX.Element {
const context = useContext(AuthHeaderContext);
if (!context) {
return null;
return <></>;
}
const current = context.state.length ? context.state[0] : null;
const current = context.state[0] ?? null;
return (
<Fragment>
{ current?.icon ?? icon }
<h1>{ current?.title ?? title }</h1>
{ children }
{ current?.hideServerPicker !== true && serverPicker }
{current?.icon ?? icon}
<h1>{current?.title ?? title}</h1>
{children}
{current?.hideServerPicker !== true && serverPicker}
</Fragment>
);
}

View file

@ -25,9 +25,9 @@ interface Props {
hideServerPicker?: boolean;
}
export function AuthHeaderModifier(props: Props) {
export function AuthHeaderModifier(props: Props): null {
const context = useContext(AuthHeaderContext);
const dispatch = context ? context.dispatch : null;
const dispatch = context?.dispatch ?? null;
useEffect(() => {
if (!dispatch) {
return;

View file

@ -22,7 +22,7 @@ import { AuthHeaderModifier } from "./AuthHeaderModifier";
export enum AuthHeaderActionType {
Add,
Remove
Remove,
}
interface AuthHeaderAction {
@ -32,21 +32,17 @@ interface AuthHeaderAction {
export type AuthHeaderReducer = Reducer<ComponentProps<typeof AuthHeaderModifier>[], AuthHeaderAction>;
export function AuthHeaderProvider({ children }: PropsWithChildren<{}>) {
export function AuthHeaderProvider({ children }: PropsWithChildren<{}>): JSX.Element {
const [state, dispatch] = useReducer<AuthHeaderReducer>(
(state: ComponentProps<typeof AuthHeaderModifier>[], action: AuthHeaderAction) => {
switch (action.type) {
case AuthHeaderActionType.Add:
return [action.value, ...state];
case AuthHeaderActionType.Remove:
return (state.length && isEqual(state[0], action.value)) ? state.slice(1) : state;
return state.length && isEqual(state[0], action.value) ? state.slice(1) : state;
}
},
[] as ComponentProps<typeof AuthHeaderModifier>[],
);
return (
<AuthHeaderContext.Provider value={{ state, dispatch }}>
{ children }
</AuthHeaderContext.Provider>
);
return <AuthHeaderContext.Provider value={{ state, dispatch }}>{children}</AuthHeaderContext.Provider>;
}

View file

@ -0,0 +1,59 @@
/*
Copyright 2023 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 { ReactNode } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import MessagePanel, { WrappedEvent } from "../MessagePanel";
/* Grouper classes determine when events can be grouped together in a summary.
* Groupers should have the following methods:
* - canStartGroup (static): determines if a new group should be started with the
* given event
* - shouldGroup: determines if the given event should be added to an existing group
* - add: adds an event to an existing group (should only be called if shouldGroup
* return true)
* - getTiles: returns the tiles that represent the group
* - getNewPrevEvent: returns the event that should be used as the new prevEvent
* when determining things such as whether a date separator is necessary
*/
export abstract class BaseGrouper {
public static canStartGroup = (_panel: MessagePanel, _ev: WrappedEvent): boolean => true;
public events: WrappedEvent[] = [];
// events that we include in the group but then eject out and place above the group.
public ejectedEvents: WrappedEvent[] = [];
public readMarker: ReactNode;
public constructor(
public readonly panel: MessagePanel,
public readonly firstEventAndShouldShow: WrappedEvent,
public readonly prevEvent: MatrixEvent | null,
public readonly lastShownEvent: MatrixEvent | undefined,
public readonly nextEvent: WrappedEvent | null,
public readonly nextEventTile?: MatrixEvent | null,
) {
this.readMarker = panel.readMarkerForEvent(
firstEventAndShouldShow.event.getId()!,
firstEventAndShouldShow.event === lastShownEvent,
);
}
public abstract shouldGroup(ev: WrappedEvent): boolean;
public abstract add(ev: WrappedEvent): void;
public abstract getTiles(): ReactNode[];
public abstract getNewPrevEvent(): MatrixEvent;
}

View file

@ -0,0 +1,167 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import { EventType, M_BEACON_INFO, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { BaseGrouper } from "./BaseGrouper";
import MessagePanel, { WrappedEvent } from "../MessagePanel";
import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast";
import DMRoomMap from "../../../utils/DMRoomMap";
import { _t } from "../../../languageHandler";
import DateSeparator from "../../views/messages/DateSeparator";
import NewRoomIntro from "../../views/rooms/NewRoomIntro";
import GenericEventListSummary from "../../views/elements/GenericEventListSummary";
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
// Wrap initial room creation events into a GenericEventListSummary
// Grouping only events sent by the same user that sent the `m.room.create` and only until
// the first non-state event, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event
export class CreationGrouper extends BaseGrouper {
public static canStartGroup = function (_panel: MessagePanel, { event }: WrappedEvent): boolean {
return event.getType() === EventType.RoomCreate;
};
public shouldGroup({ event, shouldShow }: WrappedEvent): boolean {
const panel = this.panel;
const createEvent = this.firstEventAndShouldShow.event;
if (!shouldShow) {
return true;
}
if (panel.wantsSeparator(this.firstEventAndShouldShow.event, event) === SeparatorKind.Date) {
return false;
}
const eventType = event.getType();
if (
eventType === EventType.RoomMember &&
(event.getStateKey() !== createEvent.getSender() || event.getContent()["membership"] !== "join")
) {
return false;
}
// beacons are not part of room creation configuration
// should be shown in timeline
if (M_BEACON_INFO.matches(eventType)) {
return false;
}
if (VoiceBroadcastInfoEventType === eventType) {
// always show voice broadcast info events in timeline
return false;
}
if (event.isState() && event.getSender() === createEvent.getSender()) {
return true;
}
return false;
}
public add(wrappedEvent: WrappedEvent): void {
const { event: ev, shouldShow } = wrappedEvent;
const panel = this.panel;
this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent);
if (!shouldShow) {
return;
}
if (ev.getType() === EventType.RoomEncryption) {
this.ejectedEvents.push(wrappedEvent);
} else {
this.events.push(wrappedEvent);
}
}
public getTiles(): ReactNode[] {
// If we don't have any events to group, don't even try to group them. The logic
// below assumes that we have a group of events to deal with, but we might not if
// the events we were supposed to group were redacted.
if (!this.events || !this.events.length) return [];
const panel = this.panel;
const ret: ReactNode[] = [];
const isGrouped = true;
const createEvent = this.firstEventAndShouldShow;
const lastShownEvent = this.lastShownEvent;
if (panel.wantsSeparator(this.prevEvent, createEvent.event) === SeparatorKind.Date) {
const ts = createEvent.event.getTs();
ret.push(
<li key={ts + "~"}>
<DateSeparator roomId={createEvent.event.getRoomId()!} ts={ts} />
</li>,
);
}
// If this m.room.create event should be shown (room upgrade) then show it before the summary
if (createEvent.shouldShow) {
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
ret.push(...panel.getTilesForEvent(createEvent.event, createEvent));
}
for (const ejected of this.ejectedEvents) {
ret.push(
...panel.getTilesForEvent(createEvent.event, ejected, createEvent.event === lastShownEvent, isGrouped),
);
}
const eventTiles = this.events
.map((e) => {
// In order to prevent DateSeparators from appearing in the expanded form
// of GenericEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted.
return panel.getTilesForEvent(e.event, e, e.event === lastShownEvent, isGrouped);
})
.reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1].event;
let summaryText: string;
const roomId = ev.getRoomId();
const creator = ev.sender?.name ?? ev.getSender();
if (roomId && DMRoomMap.shared().getUserIdForRoomId(roomId)) {
summaryText = _t("timeline|creation_summary_dm", { creator });
} else {
summaryText = _t("timeline|creation_summary_room", { creator });
}
ret.push(<NewRoomIntro key="newroomintro" />);
ret.push(
<GenericEventListSummary
key="roomcreationsummary"
events={this.events.map((e) => e.event)}
onToggle={panel.onHeightChanged} // Update scroll state
summaryMembers={ev.sender ? [ev.sender] : undefined}
summaryText={summaryText}
layout={this.panel.props.layout}
>
{eventTiles}
</GenericEventListSummary>,
);
if (this.readMarker) {
ret.push(this.readMarker);
}
return ret;
}
public getNewPrevEvent(): MatrixEvent {
return this.firstEventAndShouldShow.event;
}
}

View file

@ -0,0 +1,43 @@
/*
Copyright 2023 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 { MatrixEvent } from "matrix-js-sdk/src/matrix";
const UNSIGNED_KEY = "io.element.late_event";
/**
* This metadata describes when events arrive late after a net-split to offer improved UX.
*/
interface UnsignedLateEventInfo {
/**
* Milliseconds since epoch representing the time the event was received by the server
*/
received_ts: number;
/**
* An opaque identifier representing the group the server has put the late arriving event into
*/
group_id: string;
}
/**
* Get io.element.late_event metadata from unsigned as sent by the server.
*
* @experimental this is not in the Matrix spec and needs special server support
* @param mxEvent the Matrix Event to get UnsignedLateEventInfo on
*/
export function getLateEventInfo(mxEvent: MatrixEvent): UnsignedLateEventInfo | undefined {
return mxEvent.getUnsigned()[UNSIGNED_KEY];
}

View file

@ -0,0 +1,193 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import type MessagePanel from "../MessagePanel";
import type { WrappedEvent } from "../MessagePanel";
import { BaseGrouper } from "./BaseGrouper";
import { hasText } from "../../../TextForEvent";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import DateSeparator from "../../views/messages/DateSeparator";
import HistoryTile from "../../views/rooms/HistoryTile";
import EventListSummary from "../../views/elements/EventListSummary";
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
const groupedStateEvents = [
EventType.RoomMember,
EventType.RoomThirdPartyInvite,
EventType.RoomServerAcl,
EventType.RoomPinnedEvents,
];
// Wrap consecutive grouped events in a ListSummary
export class MainGrouper extends BaseGrouper {
public static canStartGroup = function (panel: MessagePanel, { event: ev, shouldShow }: WrappedEvent): boolean {
if (!shouldShow) return false;
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
return true;
}
if (ev.isRedacted()) {
return true;
}
if (panel.showHiddenEvents && !panel.shouldShowEvent(ev, true)) {
return true;
}
return false;
};
public constructor(
public readonly panel: MessagePanel,
public readonly firstEventAndShouldShow: WrappedEvent,
public readonly prevEvent: MatrixEvent | null,
public readonly lastShownEvent: MatrixEvent | undefined,
nextEvent: WrappedEvent | null,
nextEventTile: MatrixEvent | null,
) {
super(panel, firstEventAndShouldShow, prevEvent, lastShownEvent, nextEvent, nextEventTile);
this.events = [firstEventAndShouldShow];
}
public shouldGroup({ event: ev, shouldShow }: WrappedEvent): boolean {
if (!shouldShow) {
// absorb hidden events so that they do not break up streams of messages & redaction events being grouped
return true;
}
if (this.panel.wantsSeparator(this.events[0].event, ev) === SeparatorKind.Date) {
return false;
}
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
return true;
}
if (ev.isRedacted()) {
return true;
}
if (this.panel.showHiddenEvents && !this.panel.shouldShowEvent(ev, true)) {
return true;
}
return false;
}
public add(wrappedEvent: WrappedEvent): void {
const { event: ev, shouldShow } = wrappedEvent;
if (ev.getType() === EventType.RoomMember) {
// We can ignore any events that don't actually have a message to display
if (!hasText(ev, MatrixClientPeg.safeGet(), this.panel.showHiddenEvents)) return;
}
this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent);
if (!this.panel.showHiddenEvents && !shouldShow) {
// absorb hidden events to not split the summary
return;
}
this.events.push(wrappedEvent);
}
private generateKey(): string {
return "eventlistsummary-" + this.events[0].event.getId();
}
public getTiles(): ReactNode[] {
// If we don't have any events to group, don't even try to group them. The logic
// below assumes that we have a group of events to deal with, but we might not if
// the events we were supposed to group were redacted.
if (!this.events?.length) return [];
const isGrouped = true;
const panel = this.panel;
const lastShownEvent = this.lastShownEvent;
const ret: ReactNode[] = [];
if (panel.wantsSeparator(this.prevEvent, this.events[0].event) === SeparatorKind.Date) {
const ts = this.events[0].event.getTs();
ret.push(
<li key={ts + "~"}>
<DateSeparator roomId={this.events[0].event.getRoomId()!} ts={ts} />
</li>,
);
}
// Ensure that the key of the EventListSummary does not change with new events in either direction.
// This will prevent it from being re-created unnecessarily, and instead will allow new props to be provided.
// In turn, the shouldComponentUpdate method on ELS can be used to prevent unnecessary renderings.
const keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e.event));
const key =
keyEvent && this.panel.grouperKeyMap.has(keyEvent.event)
? this.panel.grouperKeyMap.get(keyEvent.event)!
: this.generateKey();
if (!keyEvent) {
// Populate the weak map with the key.
// Note that we only set the key on the specific event it refers to, since this group might get
// split up in the future by other intervening events. If we were to set the key on all events
// currently in the group, we would risk later giving the same key to multiple groups.
this.panel.grouperKeyMap.set(this.events[0].event, key);
}
let highlightInSummary = false;
let eventTiles: ReactNode[] | null = this.events
.map((e, i) => {
if (e.event.getId() === panel.props.highlightedEventId) {
highlightInSummary = true;
}
return panel.getTilesForEvent(
i === 0 ? this.prevEvent : this.events[i - 1].event,
e,
e.event === lastShownEvent,
isGrouped,
this.nextEvent,
this.nextEventTile,
);
})
.reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
eventTiles = null;
}
// If a membership event is the start of visible history, tell the user
// why they can't see earlier messages
if (!this.panel.props.canBackPaginate && !this.prevEvent) {
ret.push(<HistoryTile key="historytile" />);
}
ret.push(
<EventListSummary
key={key}
data-testid={key}
events={this.events.map((e) => e.event)}
onToggle={panel.onHeightChanged} // Update scroll state
startExpanded={highlightInSummary}
layout={this.panel.props.layout}
>
{eventTiles}
</EventListSummary>,
);
if (this.readMarker) {
ret.push(this.readMarker);
}
return ret;
}
public getNewPrevEvent(): MatrixEvent {
return this.events[this.events.length - 1].event;
}
}

View file

@ -16,7 +16,7 @@ limitations under the License.
// We're importing via require specifically so the svg becomes a URI rather than a DOM element.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const matrixSvg = require('../../../res/img/matrix.svg').default;
const matrixSvg = require("../../../res/img/matrix.svg").default;
/**
* Intended to replace $matrixLogo in the welcome page.

View file

@ -0,0 +1,103 @@
/*
Copyright 2023 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 classNames from "classnames";
import React, { useEffect, useRef } from "react";
type FlexProps = {
/**
* The type of the HTML element
* @default div
*/
as?: string;
/**
* The CSS class name.
*/
className?: string;
/**
* the on click event callback
*/
onClick?: (e: React.MouseEvent) => void;
/**
* The flex space to use
* @default null
*/
flex?: string | null;
/**
* The flex shrink factor
* @default null
*/
shrink?: string | null;
/**
* The flex grow factor
* @default null
*/
grow?: string | null;
};
/**
* Set or remove a CSS property
* @param ref the reference
* @param name the CSS property name
* @param value the CSS property value
*/
function addOrRemoveProperty(
ref: React.MutableRefObject<HTMLElement | undefined>,
name: string,
value?: string | null,
): void {
const style = ref.current!.style;
if (value) {
style.setProperty(name, value);
} else {
style.removeProperty(name);
}
}
/**
* A flex child helper
*/
export function Box({
as = "div",
flex = null,
shrink = null,
grow = null,
className,
children,
...props
}: React.PropsWithChildren<FlexProps>): JSX.Element {
const ref = useRef<HTMLElement>();
useEffect(() => {
addOrRemoveProperty(ref, `--mx-box-flex`, flex);
addOrRemoveProperty(ref, `--mx-box-shrink`, shrink);
addOrRemoveProperty(ref, `--mx-box-grow`, grow);
}, [flex, grow, shrink]);
return React.createElement(
as,
{
...props,
className: classNames("mx_Box", className, {
"mx_Box--flex": !!flex,
"mx_Box--shrink": !!shrink,
"mx_Box--grow": !!grow,
}),
ref,
},
children,
);
}

View file

@ -0,0 +1,86 @@
/*
Copyright 2023 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 classNames from "classnames";
import React, { useEffect, useRef } from "react";
type FlexProps = {
/**
* The type of the HTML element
* @default div
*/
as?: string;
/**
* The CSS class name.
*/
className?: string;
/**
* The type of flex container
* @default flex
*/
display?: "flex" | "inline-flex";
/**
* The flow direction of the flex children
* @default row
*/
direction?: "row" | "column" | "row-reverse" | "column-reverse";
/**
* The alingment of the flex children
* @default start
*/
align?: "start" | "center" | "end" | "baseline" | "stretch";
/**
* The justification of the flex children
* @default start
*/
justify?: "start" | "center" | "end" | "space-between";
/**
* The spacing between the flex children, expressed with the CSS unit
* @default 0
*/
gap?: string;
/**
* the on click event callback
*/
onClick?: (e: React.MouseEvent) => void;
};
/**
* A flexbox container helper
*/
export function Flex({
as = "div",
display = "flex",
direction = "row",
align = "start",
justify = "start",
gap = "0",
className,
children,
...props
}: React.PropsWithChildren<FlexProps>): JSX.Element {
const ref = useRef<HTMLElement>();
useEffect(() => {
ref.current!.style.setProperty(`--mx-flex-display`, display);
ref.current!.style.setProperty(`--mx-flex-direction`, direction);
ref.current!.style.setProperty(`--mx-flex-align`, align);
ref.current!.style.setProperty(`--mx-flex-justify`, justify);
ref.current!.style.setProperty(`--mx-flex-gap`, gap);
}, [align, direction, display, gap, justify]);
return React.createElement(as, { ...props, className: classNames("mx_Flex", className), ref }, children);
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 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.
@ -23,9 +23,10 @@ import { _t } from "../../../languageHandler";
import SeekBar from "./SeekBar";
import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase from "./AudioPlayerBase";
import { PlaybackState } from "../../../audio/Playback";
export default class AudioPlayer extends AudioPlayerBase {
protected renderFileSize(): string {
protected renderFileSize(): string | null {
const bytes = this.props.playback.sizeBytes;
if (!bytes) return null;
@ -38,30 +39,30 @@ export default class AudioPlayer extends AudioPlayerBase {
// 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'>
<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") }
<div className="mx_AudioPlayer_mediaInfo">
<span className="mx_AudioPlayer_mediaName">
{this.props.mediaName || _t("timeline|m.audio|unnamed_audio")}
</span>
<div className='mx_AudioPlayer_byline'>
<div className="mx_AudioPlayer_byline">
<DurationClock playback={this.props.playback} />
&nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
&nbsp; {/* easiest way to introduce a gap between the components */}
{this.renderFileSize()}
</div>
</div>
</div>
<div className='mx_AudioPlayer_seek'>
<div className="mx_AudioPlayer_seek">
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
disabled={this.state.playbackPhase === PlaybackState.Decoding}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />

View file

@ -42,7 +42,7 @@ export default abstract class AudioPlayerBase<T extends IProps = IProps> extends
protected seekRef: RefObject<SeekBar> = createRef();
protected playPauseRef: RefObject<PlayPauseButton> = createRef();
constructor(props: T) {
public constructor(props: T) {
super(props);
// Playback instances can be reused in the composer
@ -55,13 +55,13 @@ export default abstract class AudioPlayerBase<T extends IProps = IProps> extends
// 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 => {
this.props.playback.prepare().catch((e) => {
logger.error("Error processing audio file:", e);
this.setState({ error: true });
});
}
protected onKeyDown = (ev: React.KeyboardEvent) => {
protected onKeyDown = (ev: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
@ -88,16 +88,20 @@ export default abstract class AudioPlayerBase<T extends IProps = IProps> extends
}
};
private onPlaybackUpdate = (ev: PlaybackState) => {
private onPlaybackUpdate = (ev: PlaybackState): void => {
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> }
</>;
return (
<>
{this.renderComponent()}
{this.state.error && (
<div className="text-warning">{_t("timeline|m.audio|error_downloading_audio")}</div>
)}
</>
);
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,31 +15,53 @@ limitations under the License.
*/
import React, { HTMLProps } from "react";
import { Temporal } from "proposal-temporal";
import { formatSeconds } from "../../../DateUtils";
interface IProps extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "role"> {
interface Props extends Pick<HTMLProps<HTMLSpanElement>, "aria-live" | "role"> {
seconds: number;
formatFn: (seconds: number) => string;
}
/**
* Simply converts seconds into minutes and seconds. Note that hours will not be
* displayed, making it possible to see "82:29".
* Clock which represents time periods rather than absolute time.
* Simply converts seconds using formatFn.
* Defaulting to formatSeconds().
* Note that in this case hours will not be displayed, making it possible to see "82:29".
*/
export default class Clock extends React.Component<IProps> {
public constructor(props) {
export default class Clock extends React.Component<Props> {
public static defaultProps = {
formatFn: formatSeconds,
};
public constructor(props: Props) {
super(props);
}
public shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
public shouldComponentUpdate(nextProps: Readonly<Props>): boolean {
const currentFloor = Math.floor(this.props.seconds);
const nextFloor = Math.floor(nextProps.seconds);
return currentFloor !== nextFloor;
}
public render() {
return <span aria-live={this.props["aria-live"]} role={this.props.role} className='mx_Clock'>
{ formatSeconds(this.props.seconds) }
</span>;
private calculateDuration(seconds: number): string {
return new Temporal.Duration(0, 0, 0, 0, 0, 0, seconds)
.round({ smallestUnit: "seconds", largestUnit: "hours" })
.toString();
}
public render(): React.ReactNode {
const { seconds, role } = this.props;
return (
<time
dateTime={this.calculateDuration(seconds)}
aria-live={this.props["aria-live"]}
role={role}
className="mx_Clock"
>
{this.props.formatFn(seconds)}
</time>
);
}
}

View file

@ -0,0 +1,53 @@
/*
Copyright 2022 - 2023 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, { MutableRefObject } from "react";
import { toLeftOrRightOf } from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
interface Props {
containerRef: MutableRefObject<HTMLElement | null>;
currentDevice: MediaDeviceInfo | null;
devices: MediaDeviceInfo[];
onDeviceSelect: (device: MediaDeviceInfo) => void;
}
export const DevicesContextMenu: React.FC<Props> = ({ containerRef, currentDevice, devices, onDeviceSelect }) => {
const deviceOptions = devices.map((d: MediaDeviceInfo) => {
return (
<IconizedContextMenuRadio
key={d.deviceId}
active={d.deviceId === currentDevice?.deviceId}
onClick={() => onDeviceSelect(d)}
label={d.label}
/>
);
});
return (
<IconizedContextMenu
mountAsChild={false}
onFinished={() => {}}
{...(containerRef.current ? toLeftOrRightOf(containerRef.current.getBoundingClientRect(), 0) : {})}
>
<IconizedContextMenuOptionList>{deviceOptions}</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
};

View file

@ -31,7 +31,7 @@ interface IState {
* A clock which shows a clip's maximum duration.
*/
export default class DurationClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -44,11 +44,11 @@ export default class DurationClock extends React.PureComponent<IProps, IState> {
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onTimeUpdate = (time: number[]) => {
private onTimeUpdate = (time: number[]): void => {
this.setState({ durationSeconds: time[1] });
};
public render() {
public render(): React.ReactNode {
return <Clock seconds={this.state.durationSeconds} />;
}
}

View file

@ -34,32 +34,32 @@ interface IState {
*/
export default class LiveRecordingClock extends React.PureComponent<IProps, IState> {
private seconds = 0;
private scheduledUpdate = new MarkedExecution(
private scheduledUpdate: MarkedExecution = new MarkedExecution(
() => this.updateClock(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
seconds: 0,
};
}
componentDidMount() {
public componentDidMount(): void {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
this.seconds = update.timeSeconds;
this.scheduledUpdate.mark();
});
}
private updateClock() {
private updateClock(): void {
this.setState({
seconds: this.seconds,
});
}
public render() {
public render(): React.ReactNode {
return <Clock seconds={this.state.seconds} aria-live="off" />;
}
}

View file

@ -39,19 +39,19 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
};
private waveform: number[] = [];
private scheduledUpdate = new MarkedExecution(
private scheduledUpdate: MarkedExecution = new MarkedExecution(
() => this.updateWaveform(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
};
}
componentDidMount() {
public componentDidMount(): void {
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
@ -59,11 +59,11 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
});
}
private updateWaveform() {
private updateWaveform(): void {
this.setState({ waveform: this.waveform });
}
public render() {
public render(): React.ReactNode {
return <Waveform relHeights={this.state.waveform} />;
}
}

View file

@ -14,37 +14,39 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from "react";
import React, { ComponentProps, ReactNode } from "react";
import classNames from "classnames";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler";
import { Playback, PlaybackState } from "../../../audio/Playback";
// omitted props are handled by render function
interface IProps extends Omit<React.ComponentProps<typeof AccessibleTooltipButton>, "title" | "onClick" | "disabled"> {
type Props = Omit<
ComponentProps<typeof AccessibleTooltipButton>,
"title" | "onClick" | "disabled" | "element" | "ref"
> & {
// Playback instance to manipulate. Cannot change during the component lifecycle.
playback: Playback;
// The playback phase to render. Able to change during the component lifecycle.
playbackPhase: PlaybackState;
}
};
/**
* Displays a play/pause button (activating the play/pause function of the recorder)
* to be displayed in reference to a recording.
*/
export default class PlayPauseButton extends React.PureComponent<IProps> {
public constructor(props) {
export default class PlayPauseButton extends React.PureComponent<Props> {
public constructor(props: Props) {
super(props);
}
private onClick = () => {
private onClick = (): void => {
// noinspection JSIgnoredPromiseFromCall
this.toggleState();
};
public async toggleState() {
public async toggleState(): Promise<void> {
await this.props.playback.toggle();
}
@ -52,19 +54,21 @@ export default class PlayPauseButton extends React.PureComponent<IProps> {
const { playback, playbackPhase, ...restProps } = this.props;
const isPlaying = playback.isPlaying;
const isDisabled = playbackPhase === PlaybackState.Decoding;
const classes = classNames('mx_PlayPauseButton', {
'mx_PlayPauseButton_play': !isPlaying,
'mx_PlayPauseButton_pause': isPlaying,
'mx_PlayPauseButton_disabled': isDisabled,
const classes = classNames("mx_PlayPauseButton", {
mx_PlayPauseButton_play: !isPlaying,
mx_PlayPauseButton_pause: isPlaying,
mx_PlayPauseButton_disabled: isDisabled,
});
return <AccessibleTooltipButton
data-test-id='play-pause-button'
className={classes}
title={isPlaying ? _t("Pause") : _t("Play")}
onClick={this.onClick}
disabled={isDisabled}
{...restProps}
/>;
return (
<AccessibleTooltipButton
data-testid="play-pause-button"
className={classes}
title={isPlaying ? _t("action|pause") : _t("action|play")}
onClick={this.onClick}
disabled={isDisabled}
{...restProps}
/>
);
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 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.
@ -39,7 +39,7 @@ interface IState {
* A clock for a playback of a recording.
*/
export default class PlaybackClock extends React.PureComponent<IProps, IState> {
public constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -55,28 +55,25 @@ export default class PlaybackClock extends React.PureComponent<IProps, IState> {
this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate);
}
private onPlaybackUpdate = (ev: PlaybackState) => {
private onPlaybackUpdate = (ev: PlaybackState): void => {
// Convert Decoding -> Stopped because we don't care about the distinction here
if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped;
this.setState({ playbackPhase: ev });
};
private onTimeUpdate = (time: number[]) => {
private onTimeUpdate = (time: number[]): void => {
this.setState({ seconds: time[0], durationSeconds: time[1] });
};
public render() {
public render(): React.ReactNode {
let seconds = this.state.seconds;
if (this.state.playbackPhase === PlaybackState.Stopped) {
if (Number.isFinite(this.props.defaultDisplaySeconds)) {
seconds = this.props.defaultDisplaySeconds;
seconds = this.props.defaultDisplaySeconds ?? this.props.playback.durationSeconds;
} else {
seconds = this.state.durationSeconds;
}
}
return <Clock
seconds={seconds}
role="timer"
/>;
return <Clock seconds={seconds} role="timer" />;
}
}

View file

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

View file

@ -21,6 +21,7 @@ import PlaybackClock from "./PlaybackClock";
import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
import SeekBar from "./SeekBar";
import PlaybackWaveform from "./PlaybackWaveform";
import { PlaybackState } from "../../../audio/Playback";
export enum PlaybackLayout {
/**
@ -43,25 +44,29 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
// rendering properties (specifically the difference of a waveform or not).
private renderComposerLook(): ReactNode {
return <>
<PlaybackClock playback={this.props.playback} />
<PlaybackWaveform playback={this.props.playback} />
</>;
return (
<>
<PlaybackClock playback={this.props.playback} />
<PlaybackWaveform playback={this.props.playback} />
</>
);
}
private renderTimelineLook(): ReactNode {
return <>
<div className="mx_RecordingPlayback_timelineLayoutMiddle">
<PlaybackWaveform playback={this.props.playback} />
<SeekBar
playback={this.props.playback}
tabIndex={0} // allow keyboard users to fall into the seek bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
</div>
<PlaybackClock playback={this.props.playback} />
</>;
return (
<>
<div className="mx_RecordingPlayback_timelineLayoutMiddle">
<PlaybackWaveform playback={this.props.playback} />
<SeekBar
playback={this.props.playback}
tabIndex={0} // allow keyboard users to fall into the seek bar
disabled={this.state.playbackPhase === PlaybackState.Decoding}
ref={this.seekRef}
/>
</div>
<PlaybackClock playback={this.props.playback} />
</>
);
}
protected renderComponent(): ReactNode {
@ -83,7 +88,7 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
playbackPhase={this.state.playbackPhase}
ref={this.playPauseRef}
/>
{ body }
{body}
</div>
);
}

View file

@ -16,20 +16,21 @@ limitations under the License.
import React, { ChangeEvent, CSSProperties, ReactNode } from "react";
import { Playback, PlaybackState } from "../../../audio/Playback";
import { PlaybackInterface } from "../../../audio/Playback";
import { MarkedExecution } from "../../../utils/MarkedExecution";
import { percentageOf } from "../../../utils/numbers";
import { _t } from "../../../languageHandler";
interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create
// an all-new component instead.
playback: Playback;
playback: PlaybackInterface;
// Tab index for the underlying component. Useful if the seek bar is in a managed state.
// Defaults to zero.
tabIndex?: number;
playbackPhase: PlaybackState;
disabled?: boolean;
}
interface IState {
@ -37,7 +38,7 @@ interface IState {
}
interface ISeekCSS extends CSSProperties {
'--fillTo': number;
"--fillTo": number;
}
const ARROW_SKIP_SECONDS = 5; // arbitrary
@ -46,66 +47,74 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
// We use an animation frame request to avoid overly spamming prop updates, even if we aren't
// really using anything demanding on the CSS front.
private animationFrameFn = new MarkedExecution(
private animationFrameFn: MarkedExecution = new MarkedExecution(
() => this.doUpdate(),
() => requestAnimationFrame(() => this.animationFrameFn.trigger()));
() => requestAnimationFrame(() => this.animationFrameFn.trigger()),
);
public static defaultProps = {
tabIndex: 0,
disabled: false,
};
constructor(props: IProps) {
public constructor(props: IProps) {
super(props);
this.state = {
percentage: 0,
percentage: percentageOf(this.props.playback.timeSeconds, 0, this.props.playback.durationSeconds),
};
// We don't need to de-register: the class handles this for us internally
this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark());
this.props.playback.liveData.onUpdate(() => this.animationFrameFn.mark());
}
private doUpdate() {
private doUpdate(): void {
this.setState({
percentage: percentageOf(
this.props.playback.clockInfo.timeSeconds,
0,
this.props.playback.clockInfo.durationSeconds),
percentage: percentageOf(this.props.playback.timeSeconds, 0, this.props.playback.durationSeconds),
});
}
public left() {
public left(): void {
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS);
this.props.playback.skipTo(this.props.playback.timeSeconds - ARROW_SKIP_SECONDS);
}
public right() {
public right(): void {
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS);
this.props.playback.skipTo(this.props.playback.timeSeconds + ARROW_SKIP_SECONDS);
}
private onChange = (ev: ChangeEvent<HTMLInputElement>) => {
private onChange = (ev: ChangeEvent<HTMLInputElement>): void => {
// Thankfully, onChange is only called when the user changes the value, not when we
// change the value on the component. We can use this as a reliable "skip to X" function.
//
// noinspection JSIgnoredPromiseFromCall
this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds);
this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.durationSeconds);
};
private onMouseDown = (event: React.MouseEvent<Element, MouseEvent>): void => {
// do not propagate mouse down events, because these should be handled by the seekbar
event.stopPropagation();
};
public render(): ReactNode {
// We use a range input to avoid having to re-invent accessibility handling on
// a custom set of divs.
return <input
type="range"
className='mx_SeekBar'
tabIndex={this.props.tabIndex}
onChange={this.onChange}
min={0}
max={1}
value={this.state.percentage}
step={0.001}
style={{ '--fillTo': this.state.percentage } as ISeekCSS}
disabled={this.props.playbackPhase === PlaybackState.Decoding}
/>;
return (
<input
type="range"
className="mx_SeekBar"
tabIndex={this.props.tabIndex}
onChange={this.onChange}
onMouseDown={this.onMouseDown}
min={0}
max={1}
value={this.state.percentage}
step={0.001}
style={{ "--fillTo": this.state.percentage } as ISeekCSS}
disabled={this.props.disabled}
aria-label={_t("a11y|seek_bar_label")}
/>
);
}
}

View file

@ -18,7 +18,7 @@ import React, { CSSProperties } from "react";
import classNames from "classnames";
interface WaveformCSSProperties extends CSSProperties {
'--barHeight': number;
"--barHeight": number;
}
interface IProps {
@ -26,8 +26,7 @@ interface IProps {
progress: number; // percent complete, 0-1, default 100%
}
interface IState {
}
interface IState {}
/**
* A simple waveform component. This renders bars (centered vertically) for each
@ -42,23 +41,29 @@ export default class Waveform extends React.PureComponent<IProps, IState> {
progress: 1,
};
public render() {
return <div className='mx_Waveform'>
{ this.props.relHeights.map((h, i) => {
const progress = this.props.progress;
const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0;
const classes = classNames({
'mx_Waveform_bar': true,
'mx_Waveform_bar_100pct': isCompleteBar,
});
return <span
key={i}
style={{
"--barHeight": h,
} as WaveformCSSProperties}
className={classes}
/>;
}) }
</div>;
public render(): React.ReactNode {
return (
<div className="mx_Waveform">
{this.props.relHeights.map((h, i) => {
const progress = this.props.progress;
const isCompleteBar = i / this.props.relHeights.length <= progress && progress > 0;
const classes = classNames({
mx_Waveform_bar: true,
mx_Waveform_bar_100pct: isCompleteBar,
});
return (
<span
key={i}
style={
{
"--barHeight": h,
} as WaveformCSSProperties
}
className={classes}
/>
);
})}
</div>
);
}
}

View file

@ -15,14 +15,13 @@ limitations under the License.
*/
import classNames from "classnames";
import React, { PropsWithChildren } from 'react';
import React, { PropsWithChildren } from "react";
interface Props {
className?: string;
flex?: boolean;
}
export default function AuthBody({ flex, children }: PropsWithChildren<Props>) {
return <main className={classNames("mx_AuthBody", { "mx_AuthBody_flex": flex })}>
{ children }
</main>;
export default function AuthBody({ flex, className, children }: PropsWithChildren<Props>): JSX.Element {
return <main className={classNames("mx_AuthBody", className, { mx_AuthBody_flex: flex })}>{children}</main>;
}

View file

@ -16,15 +16,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { _t } from '../../../languageHandler';
import { _t } from "../../../languageHandler";
export default class AuthFooter extends React.Component {
public render(): React.ReactNode {
return (
<footer className="mx_AuthFooter" role="contentinfo">
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">{ _t("powered by Matrix") }</a>
<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">
{_t("auth|footer_powered_by_matrix")}
</a>
</footer>
);
}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import AuthHeaderLogo from "./AuthHeaderLogo";
import LanguageSelector from "./LanguageSelector";

View file

@ -14,12 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
export default class AuthHeaderLogo extends React.PureComponent {
public render(): React.ReactNode {
return <aside className="mx_AuthHeaderLogo">
Matrix
</aside>;
return <aside className="mx_AuthHeaderLogo">Matrix</aside>;
}
}

View file

@ -16,17 +16,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { ReactNode } from "react";
import AuthFooter from "./AuthFooter";
export default class AuthPage extends React.PureComponent {
export default class AuthPage extends React.PureComponent<{ children: ReactNode }> {
public render(): React.ReactNode {
return (
<div className="mx_AuthPage">
<div className="mx_AuthPage_modal">
{ this.props.children }
</div>
<div className="mx_AuthPage_modal">{this.props.children}</div>
<AuthFooter />
</div>
);

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import React, { createRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../languageHandler';
import { _t } from "../../../languageHandler";
const DIV_ID = 'mx_recaptcha';
const DIV_ID = "mx_recaptcha";
interface ICaptchaFormProps {
sitePublicKey: string;
@ -28,21 +28,20 @@ interface ICaptchaFormProps {
interface ICaptchaFormState {
errorText?: string;
}
/**
* A pure UI component which displays a captcha form.
*/
export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICaptchaFormState> {
static defaultProps = {
public static defaultProps = {
onCaptchaResponse: () => {},
};
private captchaWidgetId?: string;
private recaptchaContainer = createRef<HTMLDivElement>();
constructor(props: ICaptchaFormProps) {
public constructor(props: ICaptchaFormProps) {
super(props);
this.state = {
@ -50,7 +49,7 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
};
}
componentDidMount() {
public componentDidMount(): void {
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
// so we do this instead.
if (this.isRecaptchaReady()) {
@ -58,27 +57,32 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
this.onCaptchaLoaded();
} else {
logger.log("Loading recaptcha script...");
window.mxOnRecaptchaLoaded = () => { this.onCaptchaLoaded(); };
const scriptTag = document.createElement('script');
window.mxOnRecaptchaLoaded = () => {
this.onCaptchaLoaded();
};
const scriptTag = document.createElement("script");
scriptTag.setAttribute(
'src', `https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&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() {
public componentWillUnmount(): void {
this.resetRecaptcha();
}
// Borrowed directly from: https://github.com/codeep/react-recaptcha-google/commit/e118fa5670fa268426969323b2e7fe77698376ba
private isRecaptchaReady(): boolean {
return typeof window !== "undefined" &&
return (
typeof window !== "undefined" &&
typeof global.grecaptcha !== "undefined" &&
typeof global.grecaptcha.render === 'function';
typeof global.grecaptcha.render === "function"
);
}
private renderRecaptcha(divId: string) {
private renderRecaptcha(divId: string): void {
if (!this.isRecaptchaReady()) {
logger.error("grecaptcha not loaded!");
throw new Error("Recaptcha did not load successfully");
@ -87,56 +91,48 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
const publicKey = this.props.sitePublicKey;
if (!publicKey) {
logger.error("No public key for recaptcha!");
throw new Error(
"This server has not supplied enough information for Recaptcha "
+ "authentication");
throw new Error("This server has not supplied enough information for Recaptcha authentication");
}
logger.info("Rendering to %s", divId);
this.captchaWidgetId = global.grecaptcha.render(divId, {
logger.info(`Rendering to ${divId}`);
this.captchaWidgetId = global.grecaptcha?.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,
});
}
private resetRecaptcha() {
private resetRecaptcha(): void {
if (this.captchaWidgetId) {
global?.grecaptcha?.reset(this.captchaWidgetId);
}
}
private onCaptchaLoaded() {
private onCaptchaLoaded(): void {
logger.log("Loaded recaptcha script.");
try {
this.renderRecaptcha(DIV_ID);
// clear error if re-rendered
this.setState({
errorText: null,
errorText: undefined,
});
} catch (e) {
this.setState({
errorText: e.toString(),
errorText: e instanceof Error ? e.message : String(e),
});
}
}
render() {
let error = null;
public render(): React.ReactNode {
let error: JSX.Element | undefined;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
error = <div className="error">{this.state.errorText}</div>;
}
return (
<div ref={this.recaptchaContainer}>
<p>{ _t(
"This homeserver would like to make sure you are not a robot.",
) }</p>
<p>{_t("auth|captcha_description")}</p>
<div id={DIV_ID} />
{ error }
{error}
</div>
);
}

View file

@ -14,12 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { ReactNode } from "react";
export default class CompleteSecurityBody extends React.PureComponent {
export default class CompleteSecurityBody extends React.PureComponent<{ children: ReactNode }> {
public render(): React.ReactNode {
return <div className="mx_CompleteSecurityBody">
{ this.props.children }
</div>;
return <div className="mx_CompleteSecurityBody">{this.props.children}</div>;
}
}

View file

@ -14,21 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { ReactElement } from "react";
import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from '../../../phonenumber';
import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from "../../../phonenumber";
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
import { _t, getUserLanguage } from "../../../languageHandler";
import Dropdown from "../elements/Dropdown";
import { NonEmptyArray } from "../../../@types/common";
const COUNTRIES_BY_ISO2 = {};
for (const c of COUNTRIES) {
COUNTRIES_BY_ISO2[c.iso2] = c;
interface InternationalisedCountry extends PhoneNumberCountryDefinition {
name: string; // already translated to the user's locale
}
function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDefinition): boolean {
function countryMatchesSearchQuery(query: string, country: InternationalisedCountry): boolean {
// Remove '+' if present (when searching for a prefix)
if (query[0] === '+') {
if (query[0] === "+") {
query = query.slice(1);
}
@ -40,7 +40,7 @@ function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDef
interface IProps {
value?: string;
onOptionChange: (country: PhoneNumberCountryDefinition) => void;
onOptionChange: (country: InternationalisedCountry) => void;
isSmall: boolean; // if isSmall, show +44 in the selected value
showPrefix: boolean;
className?: string;
@ -49,23 +49,47 @@ interface IProps {
interface IState {
searchQuery: string;
defaultCountry: PhoneNumberCountryDefinition;
}
export default class CountryDropdown extends React.Component<IProps, IState> {
constructor(props: IProps) {
private readonly defaultCountry: InternationalisedCountry;
private readonly countries: InternationalisedCountry[];
private readonly countryMap: Map<string, InternationalisedCountry>;
public constructor(props: IProps) {
super(props);
let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0];
const displayNames = new Intl.DisplayNames([getUserLanguage()], { type: "region" });
this.countries = COUNTRIES.map((c) => ({
name: displayNames.of(c.iso2) ?? c.iso2,
...c,
}));
this.countryMap = new Map(this.countries.map((c) => [c.iso2, c]));
let defaultCountry: InternationalisedCountry | undefined;
const defaultCountryCode = SdkConfig.get("default_country_code");
if (defaultCountryCode) {
const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase());
const country = this.countries.find((c) => c.iso2 === defaultCountryCode.toUpperCase());
if (country) defaultCountry = country;
}
if (!defaultCountry) {
try {
const locale = new Intl.Locale(navigator.language ?? navigator.languages[0]);
const code = locale.region ?? locale.language ?? locale.baseName;
const displayName = displayNames.of(code)!.toUpperCase();
defaultCountry = this.countries.find(
(c) => c.iso2 === code.toUpperCase() || c.name.toUpperCase() === displayName,
);
} catch (e) {
console.warn("Failed to detect default locale", e);
}
}
this.defaultCountry = defaultCountry ?? this.countries[0];
this.state = {
searchQuery: '',
defaultCountry,
searchQuery: "",
};
}
@ -74,7 +98,7 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
// If no value is given, we start with the default
// country selected, but our parent component
// doesn't know this, therefore we do this.
this.props.onOptionChange(this.state.defaultCountry);
this.props.onOptionChange(this.defaultCountry);
}
}
@ -85,72 +109,76 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
};
private onOptionChange = (iso2: string): void => {
this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]);
this.props.onOptionChange(this.countryMap.get(iso2)!);
};
private flagImgForIso2(iso2: string): React.ReactNode {
return <div className="mx_Dropdown_option_emoji">{ getEmojiFlag(iso2) }</div>;
return <div className="mx_Dropdown_option_emoji">{getEmojiFlag(iso2)}</div>;
}
private getShortOption = (iso2: string): React.ReactNode => {
if (!this.props.isSmall) {
return undefined;
}
let countryPrefix;
let countryPrefix: string | undefined;
if (this.props.showPrefix) {
countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix;
countryPrefix = "+" + this.countryMap.get(iso2)!.prefix;
}
return <span className="mx_CountryDropdown_shortOption">
{ this.flagImgForIso2(iso2) }
{ countryPrefix }
</span>;
return (
<span className="mx_CountryDropdown_shortOption">
{this.flagImgForIso2(iso2)}
{countryPrefix}
</span>
);
};
public render(): React.ReactNode {
let displayedCountries;
let displayedCountries: InternationalisedCountry[];
if (this.state.searchQuery) {
displayedCountries = COUNTRIES.filter(
countryMatchesSearchQuery.bind(this, this.state.searchQuery),
displayedCountries = this.countries.filter((country) =>
countryMatchesSearchQuery(this.state.searchQuery, country),
);
if (
this.state.searchQuery.length == 2 &&
COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()]
) {
if (this.state.searchQuery.length == 2 && this.countryMap.has(this.state.searchQuery.toUpperCase())) {
// exact ISO2 country name match: make the first result the matches ISO2
const matched = COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()];
const matched = this.countryMap.get(this.state.searchQuery.toUpperCase())!;
displayedCountries = displayedCountries.filter((c) => {
return c.iso2 != matched.iso2;
});
displayedCountries.unshift(matched);
}
} else {
displayedCountries = COUNTRIES;
displayedCountries = this.countries;
}
const options = displayedCountries.map((country) => {
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this.flagImgForIso2(country.iso2) }
{ _t(country.name) } (+{ country.prefix })
</div>;
});
return (
<div className="mx_CountryDropdown_option" key={country.iso2}>
{this.flagImgForIso2(country.iso2)}
{country.name} (+{country.prefix})
</div>
);
}) as NonEmptyArray<ReactElement & { key: string }>;
// default value here too, otherwise we need to handle null / undefined
// values between mounting and the initial value propagating
const value = this.props.value || this.state.defaultCountry.iso2;
const value = this.props.value || this.defaultCountry.iso2;
return <Dropdown
id="mx_CountryDropdown"
className={this.props.className + " mx_CountryDropdown"}
onOptionChange={this.onOptionChange}
onSearchChange={this.onSearchChange}
menuWidth={298}
getShortOption={this.getShortOption}
value={value}
searchEnabled={true}
disabled={this.props.disabled}
label={_t("Country Dropdown")}
>
{ options }
</Dropdown>;
return (
<Dropdown
id="mx_CountryDropdown"
className={this.props.className + " mx_CountryDropdown"}
onOptionChange={this.onOptionChange}
onSearchChange={this.onSearchChange}
menuWidth={298}
getShortOption={this.getShortOption}
value={value}
searchEnabled={true}
disabled={this.props.disabled}
label={_t("auth|country_dropdown")}
autoComplete="tel-country-code"
>
{options}
</Dropdown>
);
}
}

View file

@ -17,19 +17,19 @@ limitations under the License.
import React, { PureComponent, RefCallback, RefObject } from "react";
import Field, { IInputProps } from "../elements/Field";
import { _t, _td } from "../../../languageHandler";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import * as Email from "../../../email";
interface IProps extends Omit<IInputProps, "onValidate"> {
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
id?: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
value: string;
autoFocus?: boolean;
label?: string;
labelRequired?: string;
labelInvalid?: string;
label: TranslationKey;
labelRequired: TranslationKey;
labelInvalid: TranslationKey;
// When present, completely overrides the default validation rules.
validationRules?: (fieldState: IFieldState) => Promise<IValidationResult>;
@ -39,10 +39,10 @@ interface IProps extends Omit<IInputProps, "onValidate"> {
}
class EmailField extends PureComponent<IProps> {
static defaultProps = {
label: _td("Email"),
labelRequired: _td("Enter email address"),
labelInvalid: _td("Doesn't look like a valid email address"),
public static defaultProps = {
label: _td("auth|email_field_label"),
labelRequired: _td("auth|email_field_label_required"),
labelInvalid: _td("auth|email_field_label_invalid"),
};
public readonly validate = withValidation({
@ -60,7 +60,7 @@ class EmailField extends PureComponent<IProps> {
],
});
onValidate = async (fieldState: IFieldState) => {
public onValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
let validate = this.validate;
if (this.props.validationRules) {
validate = this.props.validationRules;
@ -74,17 +74,19 @@ class EmailField extends PureComponent<IProps> {
return result;
};
render() {
return <Field
id={this.props.id}
ref={this.props.fieldRef}
type="text"
label={_t(this.props.label)}
value={this.props.value}
autoFocus={this.props.autoFocus}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>;
public render(): React.ReactNode {
return (
<Field
id={this.props.id}
ref={this.props.fieldRef}
type="text"
label={_t(this.props.label)}
value={this.props.value}
autoFocus={this.props.autoFocus}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import SdkConfig from "../../../SdkConfig";
import { getCurrentLanguage } from "../../../languageHandler";
@ -26,7 +26,7 @@ import LanguageDropdown from "../elements/LanguageDropdown";
function onChange(newLang: string): void {
if (getCurrentLanguage() !== newLang) {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
PlatformPeg.get()?.reload();
}
}
@ -36,10 +36,12 @@ interface IProps {
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}
value={getCurrentLanguage()}
disabled={disabled}
/>;
return (
<LanguageDropdown
className="mx_AuthBody_language"
onOptionChange={onChange}
value={getCurrentLanguage()}
disabled={disabled}
/>
);
}

View file

@ -14,22 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous';
import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports';
import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from 'matrix-js-sdk/src/rendezvous/channels';
import { logger } from 'matrix-js-sdk/src/logger';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import React from "react";
import { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
import { MSC3886SimpleHttpRendezvousTransport } from "matrix-js-sdk/src/rendezvous/transports";
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "matrix-js-sdk/src/rendezvous/channels";
import { logger } from "matrix-js-sdk/src/logger";
import { HTTPError, MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import AccessibleButton from '../elements/AccessibleButton';
import QRCode from '../elements/QRCode';
import Spinner from '../elements/Spinner';
import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg";
import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg";
import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg";
import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg";
import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth';
import { wrapRequestWithDialog } from "../../../utils/UserInteractiveAuth";
import LoginWithQRFlow from "./LoginWithQRFlow";
/**
* The intention of this enum is to have a mode that scans a QR code instead of generating one.
@ -41,7 +35,7 @@ export enum Mode {
Show = "show",
}
enum Phase {
export enum Phase {
Loading,
ShowingQR,
Connecting,
@ -51,6 +45,14 @@ enum Phase {
Error,
}
export enum Click {
Cancel,
Decline,
Approve,
TryAgain,
Back,
}
interface IProps {
client: MatrixClient;
mode: Mode;
@ -61,19 +63,25 @@ interface IState {
phase: Phase;
rendezvous?: MSC3906Rendezvous;
confirmationDigits?: string;
failureReason?: RendezvousFailureReason;
failureReason?: FailureReason;
mediaPermissionError?: boolean;
}
export enum LoginWithQRFailureReason {
RateLimited = "rate_limited",
}
export type FailureReason = RendezvousFailureReason | LoginWithQRFailureReason;
/**
* A component that allows sign in and E2EE set up with a QR code.
*
* It implements both `login.start` and `login-reciprocate` capabilities as well as both scanning and showing QR codes.
* It implements `login.reciprocate` capabilities and showing QR codes.
*
* This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906
*/
export default class LoginWithQR extends React.Component<IProps, IState> {
public constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
@ -91,11 +99,12 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
}
private async updateMode(mode: Mode) {
private async updateMode(mode: Mode): Promise<void> {
this.setState({ phase: Phase.Loading });
if (this.state.rendezvous) {
this.state.rendezvous.onFailure = undefined;
await this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled);
const rendezvous = this.state.rendezvous;
rendezvous.onFailure = undefined;
await rendezvous.cancel(RendezvousFailureReason.UserCancelled);
this.setState({ rendezvous: undefined });
}
if (mode === Mode.Show) {
@ -114,7 +123,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
private approveLogin = async (): Promise<void> => {
if (!this.state.rendezvous) {
throw new Error('Rendezvous not found');
throw new Error("Rendezvous not found");
}
this.setState({ phase: Phase.Loading });
@ -123,7 +132,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
const { login_token: loginToken } = await wrapRequestWithDialog(this.props.client.requestLoginToken, {
matrixClient: this.props.client,
title: _t("Sign in new device"),
title: _t("auth|qr_code_login|sign_in_new_device"),
})();
this.setState({ phase: Phase.WaitingForDevice });
@ -133,29 +142,45 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
// user denied
return;
}
if (!this.props.client.crypto) {
if (!this.props.client.getCrypto()) {
// no E2EE to set up
this.props.onFinished(true);
return;
}
this.setState({ phase: Phase.Verifying });
await this.state.rendezvous.verifyNewDeviceOnExistingDevice();
// clean up our state:
try {
await this.state.rendezvous.close();
} finally {
this.setState({ rendezvous: undefined });
}
this.props.onFinished(true);
} catch (e) {
logger.error('Error whilst approving sign in', e);
logger.error("Error whilst approving sign in", e);
if (e instanceof HTTPError && e.httpStatus === 429) {
// 429: rate limit
this.setState({ phase: Phase.Error, failureReason: LoginWithQRFailureReason.RateLimited });
return;
}
this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown });
}
};
private generateCode = async () => {
private generateCode = async (): Promise<void> => {
let rendezvous: MSC3906Rendezvous;
try {
const fallbackRzServer = this.props.client.getClientWellKnown()?.["io.element.rendezvous"]?.server;
const transport = new MSC3886SimpleHttpRendezvousTransport<MSC3903ECDHPayload>({
onFailure: this.onFailure,
client: this.props.client,
fallbackRzServer,
});
const channel = new MSC3903ECDHv1RendezvousChannel<MSC3906RendezvousPayload>(
transport, undefined, this.onFailure,
const channel = new MSC3903ECDHv2RendezvousChannel<MSC3906RendezvousPayload>(
transport,
undefined,
this.onFailure,
);
rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure);
@ -167,7 +192,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
failureReason: undefined,
});
} catch (e) {
logger.error('Error whilst generating QR code', e);
logger.error("Error whilst generating QR code", e);
this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport });
return;
}
@ -176,7 +201,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
const confirmationDigits = await rendezvous.startAfterShowingCode();
this.setState({ phase: Phase.Connected, confirmationDigits });
} catch (e) {
logger.error('Error whilst doing QR login', e);
logger.error("Error whilst doing QR login", e);
// only set to error phase if it hasn't already been set by onFailure or similar
if (this.state.phase !== Phase.Error) {
this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown });
@ -184,12 +209,12 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
}
};
private onFailure = (reason: RendezvousFailureReason) => {
private onFailure = (reason: RendezvousFailureReason): void => {
logger.info(`Rendezvous failed: ${reason}`);
this.setState({ phase: Phase.Error, failureReason: reason });
};
public reset() {
public reset(): void {
this.setState({
rendezvous: undefined,
confirmationDigits: undefined,
@ -197,200 +222,41 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
});
}
private cancelClicked = async (e: React.FormEvent) => {
e.preventDefault();
await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled);
this.reset();
this.props.onFinished(false);
};
private declineClicked = async (e: React.FormEvent) => {
e.preventDefault();
await this.state.rendezvous?.declineLoginOnExistingDevice();
this.reset();
this.props.onFinished(false);
};
private tryAgainClicked = async (e: React.FormEvent) => {
e.preventDefault();
this.reset();
await this.updateMode(this.props.mode);
};
private onBackClick = async () => {
await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled);
this.props.onFinished(false);
};
private cancelButton = () => <AccessibleButton
kind="primary_outline"
onClick={this.cancelClicked}
>
{ _t("Cancel") }
</AccessibleButton>;
private simpleSpinner = (description?: string): JSX.Element => {
return <div className="mx_LoginWithQR_spinner">
<div>
<Spinner />
{ description && <p>{ description }</p> }
</div>
</div>;
};
public render() {
let title: string;
let titleIcon: JSX.Element | undefined;
let main: JSX.Element | undefined;
let buttons: JSX.Element | undefined;
let backButton = true;
let cancellationMessage: string | undefined;
let centreTitle = false;
switch (this.state.phase) {
case Phase.Error:
switch (this.state.failureReason) {
case RendezvousFailureReason.Expired:
cancellationMessage = _t("The linking wasn't completed in the required time.");
break;
case RendezvousFailureReason.InvalidCode:
cancellationMessage = _t("The scanned code is invalid.");
break;
case RendezvousFailureReason.UnsupportedAlgorithm:
cancellationMessage = _t("Linking with this device is not supported.");
break;
case RendezvousFailureReason.UserDeclined:
cancellationMessage = _t("The request was declined on the other device.");
break;
case RendezvousFailureReason.OtherDeviceAlreadySignedIn:
cancellationMessage = _t("The other device is already signed in.");
break;
case RendezvousFailureReason.OtherDeviceNotSignedIn:
cancellationMessage = _t("The other device isn't signed in.");
break;
case RendezvousFailureReason.UserCancelled:
cancellationMessage = _t("The request was cancelled.");
break;
case RendezvousFailureReason.Unknown:
cancellationMessage = _t("An unexpected error occurred.");
break;
case RendezvousFailureReason.HomeserverLacksSupport:
cancellationMessage = _t("The homeserver doesn't support signing in another device.");
break;
default:
cancellationMessage = _t("The request was cancelled.");
break;
}
title = _t("Connection failed");
centreTitle = true;
titleIcon = <WarningBadge className="error" />;
backButton = false;
main = <p data-testid="cancellation-message">{ cancellationMessage }</p>;
buttons = <>
<AccessibleButton
kind="primary"
onClick={this.tryAgainClicked}
>
{ _t("Try again") }
</AccessibleButton>
{ this.cancelButton() }
</>;
private onClick = async (type: Click): Promise<void> => {
switch (type) {
case Click.Cancel:
await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled);
this.reset();
this.props.onFinished(false);
break;
case Phase.Connected:
title = _t("Devices connected");
titleIcon = <DevicesIcon className="normal" />;
backButton = false;
main = <>
<p>{ _t("Check that the code below matches with your other device:") }</p>
<div className="mx_LoginWithQR_confirmationDigits">
{ this.state.confirmationDigits }
</div>
<div className="mx_LoginWithQR_confirmationAlert">
<div>
<InfoIcon />
</div>
<div>{ _t("By approving access for this device, it will have full access to your account.") }</div>
</div>
</>;
buttons = <>
<AccessibleButton
data-testid="decline-login-button"
kind="primary_outline"
onClick={this.declineClicked}
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
data-testid="approve-login-button"
kind="primary"
onClick={this.approveLogin}
>
{ _t("Approve") }
</AccessibleButton>
</>;
case Click.Approve:
await this.approveLogin();
break;
case Phase.ShowingQR:
title =_t("Sign in with QR code");
if (this.state.rendezvous) {
const code = <div className="mx_LoginWithQR_qrWrapper">
<QRCode data={[{ data: Buffer.from(this.state.rendezvous.code), mode: 'byte' }]} className="mx_QRCode" />
</div>;
main = <>
<p>{ _t("Scan the QR code below with your device that's signed out.") }</p>
<ol>
<li>{ _t("Start at the sign in screen") }</li>
<li>{ _t("Select 'Scan QR code'") }</li>
<li>{ _t("Review and approve the sign in") }</li>
</ol>
{ code }
</>;
} else {
main = this.simpleSpinner();
buttons = this.cancelButton();
}
case Click.Decline:
await this.state.rendezvous?.declineLoginOnExistingDevice();
this.reset();
this.props.onFinished(false);
break;
case Phase.Loading:
main = this.simpleSpinner();
case Click.TryAgain:
this.reset();
await this.updateMode(this.props.mode);
break;
case Phase.Connecting:
main = this.simpleSpinner(_t("Connecting..."));
buttons = this.cancelButton();
break;
case Phase.WaitingForDevice:
main = this.simpleSpinner(_t("Waiting for device to sign in"));
buttons = this.cancelButton();
break;
case Phase.Verifying:
title = _t("Success");
centreTitle = true;
main = this.simpleSpinner(_t("Completing set up of your new device"));
case Click.Back:
await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled);
this.props.onFinished(false);
break;
}
};
public render(): React.ReactNode {
return (
<div data-testid="login-with-qr" className="mx_LoginWithQR">
<div className={centreTitle ? "mx_LoginWithQR_centreTitle" : ""}>
{ backButton ?
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
onClick={this.onBackClick}
title="Back"
>
<BackButtonIcon />
</AccessibleButton>
: null }
<h1>{ titleIcon }{ title }</h1>
</div>
<div className="mx_LoginWithQR_main">
{ main }
</div>
<div className="mx_LoginWithQR_buttons">
{ buttons }
</div>
</div>
<LoginWithQRFlow
onClick={this.onClick}
phase={this.state.phase}
code={this.state.phase === Phase.ShowingQR ? this.state.rendezvous?.code : undefined}
confirmationDigits={this.state.phase === Phase.Connected ? this.state.confirmationDigits : undefined}
failureReason={this.state.phase === Phase.Error ? this.state.failureReason : undefined}
/>
);
}
}

View file

@ -0,0 +1,244 @@
/*
Copyright 2022 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 { RendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import QRCode from "../elements/QRCode";
import Spinner from "../elements/Spinner";
import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg";
import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg";
import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg";
import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg";
import { Click, FailureReason, LoginWithQRFailureReason, Phase } from "./LoginWithQR";
interface IProps {
phase: Phase;
code?: string;
onClick(type: Click): Promise<void>;
failureReason?: FailureReason;
confirmationDigits?: string;
}
/**
* A component that implements the UI for sign in and E2EE set up with a QR code.
*
* This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906
*/
export default class LoginWithQRFlow extends React.Component<IProps> {
public constructor(props: IProps) {
super(props);
}
private handleClick = (type: Click): ((e: React.FormEvent) => Promise<void>) => {
return async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
await this.props.onClick(type);
};
};
private cancelButton = (): JSX.Element => (
<AccessibleButton data-testid="cancel-button" kind="primary_outline" onClick={this.handleClick(Click.Cancel)}>
{_t("action|cancel")}
</AccessibleButton>
);
private simpleSpinner = (description?: string): JSX.Element => {
return (
<div className="mx_LoginWithQR_spinner">
<div>
<Spinner />
{description && <p>{description}</p>}
</div>
</div>
);
};
public render(): React.ReactNode {
let title = "";
let titleIcon: JSX.Element | undefined;
let main: JSX.Element | undefined;
let buttons: JSX.Element | undefined;
let backButton = true;
let cancellationMessage: string | undefined;
let centreTitle = false;
switch (this.props.phase) {
case Phase.Error:
switch (this.props.failureReason) {
case RendezvousFailureReason.Expired:
cancellationMessage = _t("auth|qr_code_login|error_linking_incomplete");
break;
case RendezvousFailureReason.InvalidCode:
cancellationMessage = _t("auth|qr_code_login|error_invalid_scanned_code");
break;
case RendezvousFailureReason.UnsupportedAlgorithm:
cancellationMessage = _t("auth|qr_code_login|error_device_unsupported");
break;
case RendezvousFailureReason.UserDeclined:
cancellationMessage = _t("auth|qr_code_login|error_request_declined");
break;
case RendezvousFailureReason.OtherDeviceAlreadySignedIn:
cancellationMessage = _t("auth|qr_code_login|error_device_already_signed_in");
break;
case RendezvousFailureReason.OtherDeviceNotSignedIn:
cancellationMessage = _t("auth|qr_code_login|error_device_not_signed_in");
break;
case RendezvousFailureReason.UserCancelled:
cancellationMessage = _t("auth|qr_code_login|error_request_cancelled");
break;
case LoginWithQRFailureReason.RateLimited:
cancellationMessage = _t("auth|qr_code_login|error_rate_limited");
break;
case RendezvousFailureReason.Unknown:
cancellationMessage = _t("auth|qr_code_login|error_unexpected");
break;
case RendezvousFailureReason.HomeserverLacksSupport:
cancellationMessage = _t("auth|qr_code_login|error_homeserver_lacks_support");
break;
default:
cancellationMessage = _t("auth|qr_code_login|error_request_cancelled");
break;
}
title = _t("timeline|m.call.invite|failed_connection");
centreTitle = true;
titleIcon = <WarningBadge className="error" />;
backButton = false;
main = <p data-testid="cancellation-message">{cancellationMessage}</p>;
buttons = (
<>
<AccessibleButton
data-testid="try-again-button"
kind="primary"
onClick={this.handleClick(Click.TryAgain)}
>
{_t("action|try_again")}
</AccessibleButton>
{this.cancelButton()}
</>
);
break;
case Phase.Connected:
title = _t("auth|qr_code_login|devices_connected");
titleIcon = <DevicesIcon className="normal" />;
backButton = false;
main = (
<>
<p>{_t("auth|qr_code_login|confirm_code_match")}</p>
<div className="mx_LoginWithQR_confirmationDigits">{this.props.confirmationDigits}</div>
<div className="mx_LoginWithQR_confirmationAlert">
<div>
<InfoIcon />
</div>
<div>{_t("auth|qr_code_login|approve_access_warning")}</div>
</div>
</>
);
buttons = (
<>
<AccessibleButton
data-testid="decline-login-button"
kind="primary_outline"
onClick={this.handleClick(Click.Decline)}
>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton
data-testid="approve-login-button"
kind="primary"
onClick={this.handleClick(Click.Approve)}
>
{_t("action|approve")}
</AccessibleButton>
</>
);
break;
case Phase.ShowingQR:
title = _t("settings|sessions|sign_in_with_qr");
if (this.props.code) {
const code = (
<div className="mx_LoginWithQR_qrWrapper">
<QRCode
data={[{ data: Buffer.from(this.props.code ?? ""), mode: "byte" }]}
className="mx_QRCode"
/>
</div>
);
main = (
<>
<p>{_t("auth|qr_code_login|scan_code_instruction")}</p>
<ol>
<li>{_t("auth|qr_code_login|start_at_sign_in_screen")}</li>
<li>
{_t("auth|qr_code_login|select_qr_code", {
scanQRCode: _t("auth|qr_code_login|scan_qr_code"),
})}
</li>
<li>{_t("auth|qr_code_login|review_and_approve")}</li>
</ol>
{code}
</>
);
} else {
main = this.simpleSpinner();
buttons = this.cancelButton();
}
break;
case Phase.Loading:
main = this.simpleSpinner();
break;
case Phase.Connecting:
main = this.simpleSpinner(_t("auth|qr_code_login|connecting"));
buttons = this.cancelButton();
break;
case Phase.WaitingForDevice:
main = this.simpleSpinner(_t("auth|qr_code_login|waiting_for_device"));
buttons = this.cancelButton();
break;
case Phase.Verifying:
title = _t("common|success");
centreTitle = true;
main = this.simpleSpinner(_t("auth|qr_code_login|completing_setup"));
break;
}
return (
<div data-testid="login-with-qr" className="mx_LoginWithQR">
<div className={centreTitle ? "mx_LoginWithQR_centreTitle" : ""}>
{backButton ? (
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
onClick={this.handleClick(Click.Back)}
title="Back"
>
<BackButtonIcon />
</AccessibleButton>
) : null}
<h1>
{titleIcon}
{title}
</h1>
</div>
<div className="mx_LoginWithQR_main">{main}</div>
<div className="mx_LoginWithQR_buttons">{buttons}</div>
</div>
);
}
}

View file

@ -18,27 +18,28 @@ import React, { PureComponent, RefCallback, RefObject } from "react";
import Field, { IInputProps } from "../elements/Field";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { _t, _td } from "../../../languageHandler";
import { _t, _td, TranslationKey } from "../../../languageHandler";
interface IProps extends Omit<IInputProps, "onValidate"> {
interface IProps extends Omit<IInputProps, "onValidate" | "label" | "element"> {
id?: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
autoComplete?: string;
value: string;
password: string; // The password we're confirming
labelRequired?: string;
labelInvalid?: string;
label: TranslationKey;
labelRequired: TranslationKey;
labelInvalid: TranslationKey;
onChange(ev: React.FormEvent<HTMLElement>);
onValidate?(result: IValidationResult);
onChange(ev: React.FormEvent<HTMLElement>): void;
onValidate?(result: IValidationResult): void;
}
class PassphraseConfirmField extends PureComponent<IProps> {
static defaultProps = {
label: _td("Confirm password"),
labelRequired: _td("Confirm password"),
labelInvalid: _td("Passwords don't match"),
public static defaultProps = {
label: _td("auth|change_password_confirm_label"),
labelRequired: _td("auth|change_password_confirm_label"),
labelInvalid: _td("auth|change_password_confirm_invalid"),
};
private validate = withValidation({
@ -56,7 +57,7 @@ class PassphraseConfirmField extends PureComponent<IProps> {
],
});
private onValidate = async (fieldState: IFieldState) => {
private onValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validate(fieldState);
if (this.props.onValidate) {
this.props.onValidate(result);
@ -65,17 +66,20 @@ class PassphraseConfirmField extends PureComponent<IProps> {
return result;
};
render() {
return <Field
id={this.props.id}
ref={this.props.fieldRef}
type="password"
label={_t(this.props.label)}
autoComplete={this.props.autoComplete}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>;
public render(): React.ReactNode {
return (
<Field
id={this.props.id}
ref={this.props.fieldRef}
type="password"
label={_t(this.props.label)}
autoComplete={this.props.autoComplete}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
autoFocus={this.props.autoFocus}
/>
);
}
}

View file

@ -16,47 +16,50 @@ limitations under the License.
import React, { PureComponent, RefCallback, RefObject } from "react";
import classNames from "classnames";
import zxcvbn from "zxcvbn";
import type { ZxcvbnResult } from "@zxcvbn-ts/core";
import SdkConfig from "../../../SdkConfig";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { _t, _td } from "../../../languageHandler";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import Field, { IInputProps } from "../elements/Field";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps extends Omit<IInputProps, "onValidate"> {
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
autoFocus?: boolean;
id?: string;
className?: string;
minScore: 0 | 1 | 2 | 3 | 4;
value: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
// Additional strings such as a username used to catch bad passwords
userInputs?: string[];
label?: string;
labelEnterPassword?: string;
labelStrongPassword?: string;
labelAllowedButUnsafe?: string;
label: TranslationKey;
labelEnterPassword: TranslationKey;
labelStrongPassword: TranslationKey;
labelAllowedButUnsafe: TranslationKey;
onChange(ev: React.FormEvent<HTMLElement>);
onValidate?(result: IValidationResult);
onChange(ev: React.FormEvent<HTMLElement>): void;
onValidate?(result: IValidationResult): void;
}
class PassphraseField extends PureComponent<IProps> {
static defaultProps = {
label: _td("Password"),
labelEnterPassword: _td("Enter password"),
labelStrongPassword: _td("Nice, strong password!"),
labelAllowedButUnsafe: _td("Password is allowed, but unsafe"),
public static defaultProps = {
label: _td("common|password"),
labelEnterPassword: _td("auth|password_field_label"),
labelStrongPassword: _td("auth|password_field_strong_label"),
labelAllowedButUnsafe: _td("auth|password_field_weak_label"),
};
public readonly validate = withValidation<this, zxcvbn.ZXCVBNResult>({
description: function(complexity) {
public readonly validate = withValidation<this, ZxcvbnResult | null>({
description: function (complexity) {
const score = complexity ? complexity.score : 0;
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
},
deriveData: async ({ value }) => {
deriveData: async ({ value }): Promise<ZxcvbnResult | null> => {
if (!value) return null;
const { scorePassword } = await import('../../../utils/PasswordScorer');
return scorePassword(value);
const { scorePassword } = await import("../../../utils/PasswordScorer");
return scorePassword(MatrixClientPeg.get(), value, this.props.userInputs);
},
rules: [
{
@ -66,35 +69,36 @@ class PassphraseField extends PureComponent<IProps> {
},
{
key: "complexity",
test: async function({ value }, complexity) {
if (!value) {
test: async function ({ value }, complexity): Promise<boolean> {
if (!value || !complexity) {
return false;
}
const safe = complexity.score >= this.props.minScore;
const allowUnsafe = SdkConfig.get("dangerously_allow_unsafe_and_insecure_passwords");
return allowUnsafe || safe;
},
valid: function(complexity) {
valid: function (complexity) {
// Unsafe passwords that are valid are only possible through a
// configuration flag. We'll print some helper text to signal
// to the user that their password is allowed, but unsafe.
if (complexity.score >= this.props.minScore) {
if (complexity && complexity.score >= this.props.minScore) {
return _t(this.props.labelStrongPassword);
}
return _t(this.props.labelAllowedButUnsafe);
},
invalid: function(complexity) {
invalid: function (complexity) {
if (!complexity) {
return null;
}
const { feedback } = complexity;
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
return feedback.warning || feedback.suggestions[0] || _t("auth|password_field_keep_going_prompt");
},
},
],
memoize: true,
});
onValidate = async (fieldState: IFieldState) => {
public onValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validate(fieldState);
if (this.props.onValidate) {
this.props.onValidate(result);
@ -102,19 +106,21 @@ class PassphraseField extends PureComponent<IProps> {
return result;
};
render() {
return <Field
id={this.props.id}
autoFocus={this.props.autoFocus}
className={classNames("mx_PassphraseField", this.props.className)}
ref={this.props.fieldRef}
type="password"
autoComplete="new-password"
label={_t(this.props.label)}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>;
public render(): React.ReactNode {
return (
<Field
id={this.props.id}
autoFocus={this.props.autoFocus}
className={classNames("mx_PassphraseField", this.props.className)}
ref={this.props.fieldRef}
type="password"
autoComplete="new-password"
label={_t(this.props.label)}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>
);
}
}

View file

@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import React, { SyntheticEvent } from "react";
import classNames from "classnames";
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
import AccessibleButton from "../elements/AccessibleButton";
import withValidation, { IValidationResult } from "../elements/Validation";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown";
import EmailField from "./EmailField";
import { PhoneNumberCountryDefinition } from "../../../phonenumber";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -35,7 +36,7 @@ interface IProps {
phoneNumber: string;
serverConfig: ValidatedServerConfig;
loginIncorrect?: boolean;
loginIncorrect: boolean;
disableSubmit?: boolean;
busy?: boolean;
@ -51,14 +52,14 @@ interface IProps {
interface IState {
fieldValid: Partial<Record<LoginField, boolean>>;
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone;
password: "";
password: string;
}
enum LoginField {
const enum LoginField {
Email = "login_field_email",
MatrixId = "login_field_mxid",
Phone = "login_field_phone",
Password = "login_field_phone",
Password = "login_field_password",
}
/*
@ -66,16 +67,21 @@ enum LoginField {
* The email/username/phone fields are fully-controlled, the password field is not.
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
private [LoginField.Email]: Field | null = null;
private [LoginField.Phone]: Field | null = null;
private [LoginField.MatrixId]: Field | null = null;
private [LoginField.Password]: Field | null = null;
public static defaultProps = {
onUsernameChanged: function () {},
onUsernameBlur: function () {},
onPhoneCountryChanged: function () {},
onPhoneNumberChanged: function () {},
loginIncorrect: false,
disableSubmit: false,
};
constructor(props) {
public constructor(props: IProps) {
super(props);
this.state = {
// Field error codes by field ID
@ -85,13 +91,13 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
};
}
private onForgotPasswordClick = ev => {
private onForgotPasswordClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
this.props.onForgotPasswordClick?.();
};
private onSubmitForm = async ev => {
private onSubmitForm = async (ev: SyntheticEvent): Promise<void> => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
@ -99,51 +105,44 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return;
}
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
username = this.props.username;
this.props.onSubmit(this.props.username, undefined, undefined, this.state.password);
break;
case LoginField.Phone:
phoneCountry = this.props.phoneCountry;
phoneNumber = this.props.phoneNumber;
this.props.onSubmit(undefined, this.props.phoneCountry, this.props.phoneNumber, this.state.password);
break;
}
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
};
private onUsernameChanged = ev => {
this.props.onUsernameChanged(ev.target.value);
private onUsernameChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.props.onUsernameChanged?.(ev.target.value);
};
private onUsernameBlur = ev => {
this.props.onUsernameBlur(ev.target.value);
private onUsernameBlur = (ev: React.FocusEvent<HTMLInputElement>): void => {
this.props.onUsernameBlur?.(ev.target.value);
};
private onLoginTypeChange = ev => {
const loginType = ev.target.value;
private onLoginTypeChange = (ev: React.ChangeEvent<HTMLSelectElement>): void => {
const loginType = ev.target.value as IState["loginType"];
this.setState({ loginType });
this.props.onUsernameChanged(""); // Reset because email and username use the same state
this.props.onUsernameChanged?.(""); // Reset because email and username use the same state
};
private onPhoneCountryChanged = country => {
this.props.onPhoneCountryChanged(country.iso2);
private onPhoneCountryChanged = (country: PhoneNumberCountryDefinition): void => {
this.props.onPhoneCountryChanged?.(country.iso2);
};
private onPhoneNumberChanged = ev => {
this.props.onPhoneNumberChanged(ev.target.value);
private onPhoneNumberChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.props.onPhoneNumberChanged?.(ev.target.value);
};
private onPasswordChanged = ev => {
private onPasswordChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ password: ev.target.value });
};
private async verifyFieldsBeforeSubmit() {
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement as HTMLElement;
@ -151,10 +150,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
this.state.loginType,
LoginField.Password,
];
const fieldIDsInDisplayOrder = [this.state.loginType, LoginField.Password];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
@ -172,7 +168,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise<void>(resolve => this.setState({}, resolve));
await new Promise<void>((resolve) => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
@ -191,17 +187,11 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return false;
}
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
private allFieldsValid(): boolean {
return Object.values(this.state.fieldValid).every(Boolean);
}
private findFirstInvalidField(fieldIDs: LoginField[]) {
private findFirstInvalidField(fieldIDs: LoginField[]): Field | null {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
@ -210,7 +200,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return null;
}
private markFieldValid(fieldID: LoginField, valid: boolean) {
private markFieldValid(fieldID: LoginField, valid?: boolean): void {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
@ -225,18 +215,18 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter username"),
invalid: () => _t("auth|username_field_required_invalid"),
},
],
});
private onUsernameValidate = async (fieldState) => {
private onUsernameValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(LoginField.MatrixId, result.valid);
return result;
};
private onEmailValidate = (result: IValidationResult) => {
private onEmailValidate = (result: IValidationResult): void => {
this.markFieldValid(LoginField.Email, result.valid);
};
@ -244,19 +234,20 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
rules: [
{
key: "required",
test({ value, allowEmpty }) {
test({ value, allowEmpty }): boolean {
return allowEmpty || !!value;
},
invalid: () => _t("Enter phone number"),
}, {
invalid: (): string => _t("auth|msisdn_field_required_invalid"),
},
{
key: "number",
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
invalid: () => _t("That phone number doesn't look quite right, please check and try again"),
test: ({ value }): boolean => !value || PHONE_NUMBER_REGEX.test(value),
invalid: (): string => _t("auth|msisdn_field_number_invalid"),
},
],
});
private onPhoneNumberValidate = async (fieldState) => {
private onPhoneNumberValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
@ -266,21 +257,21 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
rules: [
{
key: "required",
test({ value, allowEmpty }) {
test({ value, allowEmpty }): boolean {
return allowEmpty || !!value;
},
invalid: () => _t("Enter password"),
invalid: (): string => _t("auth|password_field_label"),
},
],
});
private onPasswordValidate = async (fieldState) => {
private onPasswordValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(LoginField.Password, result.valid);
return result;
};
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean): JSX.Element {
const classes = {
error: false,
};
@ -288,72 +279,86 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
switch (loginType) {
case LoginField.Email:
classes.error = this.props.loginIncorrect && !this.props.username;
return <EmailField
id="mx_LoginForm_email"
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="email"
type="email"
key="email_input"
placeholder="joe@example.com"
value={this.props.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.busy}
autoFocus={autoFocus}
onValidate={this.onEmailValidate}
fieldRef={field => this[LoginField.Email] = field}
/>;
return (
<EmailField
id="mx_LoginForm_email"
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="email"
type="email"
key="email_input"
placeholder="joe@example.com"
value={this.props.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.busy}
autoFocus={autoFocus}
onValidate={this.onEmailValidate}
fieldRef={(field): void => {
this[LoginField.Email] = field;
}}
/>
);
case LoginField.MatrixId:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
id="mx_LoginForm_username"
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="username"
key="username_input"
type="text"
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.props.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.busy}
autoFocus={autoFocus}
onValidate={this.onUsernameValidate}
ref={field => this[LoginField.MatrixId] = field}
/>;
return (
<Field
id="mx_LoginForm_username"
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="username"
key="username_input"
type="text"
label={_t("common|username")}
placeholder={_t("common|username")}
value={this.props.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
disabled={this.props.busy}
autoFocus={autoFocus}
onValidate={this.onUsernameValidate}
ref={(field): void => {
this[LoginField.MatrixId] = field;
}}
/>
);
case LoginField.Phone: {
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.props.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
const phoneCountry = (
<CountryDropdown
value={this.props.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>
);
return <Field
id="mx_LoginForm_phone"
className={classNames(classes)}
name="phoneNumber"
autoComplete="tel-national"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.props.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
disabled={this.props.busy}
autoFocus={autoFocus}
onValidate={this.onPhoneNumberValidate}
ref={field => this[LoginField.Password] = field}
/>;
return (
<Field
id="mx_LoginForm_phone"
className={classNames(classes)}
name="phoneNumber"
autoComplete="tel-national"
key="phone_input"
type="text"
label={_t("auth|msisdn_field_label")}
value={this.props.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
disabled={this.props.busy}
autoFocus={autoFocus}
onValidate={this.onPhoneNumberValidate}
ref={(field): void => {
this[LoginField.Password] = field;
}}
/>
);
}
}
}
private isLoginEmpty() {
private isLoginEmpty(): boolean {
switch (this.state.loginType) {
case LoginField.Email:
case LoginField.MatrixId:
@ -363,18 +368,20 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
}
}
render() {
let forgotPasswordJsx;
public render(): React.ReactNode {
let forgotPasswordJsx: JSX.Element | undefined;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{ _t("Forgot password?") }
</AccessibleButton>;
forgotPasswordJsx = (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{_t("auth|reset_password_button")}
</AccessibleButton>
);
}
const pwFieldClass = classNames({
@ -390,7 +397,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<label className="mx_Login_type_label">{_t("auth|identifier_label")}</label>
<Field
element="select"
value={this.state.loginType}
@ -398,16 +405,13 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
disabled={this.props.busy}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{ _t('Username') }
{_t("common|username")}
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{ _t('Email address') }
<option key={LoginField.Email} value={LoginField.Email}>
{_t("common|email_address")}
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{ _t('Phone') }
{_t("auth|msisdn_field_label")}
</option>
</Field>
</div>
@ -417,28 +421,31 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return (
<div>
<form onSubmit={this.onSubmitForm}>
{ loginType }
{ loginField }
{loginType}
{loginField}
<Field
id="mx_LoginForm_password"
className={pwFieldClass}
autoComplete="current-password"
type="password"
name="password"
label={_t('Password')}
label={_t("common|password")}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.busy}
autoFocus={autoFocusPassword}
onValidate={this.onPasswordValidate}
ref={field => this[LoginField.Password] = field}
ref={(field) => (this[LoginField.Password] = field)}
/>
{ forgotPasswordJsx }
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
{forgotPasswordJsx}
{!this.props.busy && (
<input
className="mx_Login_submit"
type="submit"
value={_t("action|sign_in")}
disabled={this.props.disableSubmit}
/>
)}
</form>
</div>
);

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