Merge remote-tracking branch 'origin/develop' into last-admin-leave-room-warning
This commit is contained in:
commit
0fdb300858
2886 changed files with 393845 additions and 234022 deletions
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
238
src/components/structures/AutocompleteInput.tsx
Normal file
238
src/components/structures/AutocompleteInput.tsx
Normal 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>
|
||||
</>,
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
38
src/components/structures/ErrorMessage.tsx
Normal file
38
src/components/structures/ErrorMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
336
src/components/structures/RoomSearchView.tsx
Normal file
336
src/components/structures/RoomSearchView.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -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>
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
: <> </>
|
||||
}
|
||||
</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>
|
||||
) : (
|
||||
<> </>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
87
src/components/structures/WaitingForThirdPartyRoomView.tsx
Normal file
87
src/components/structures/WaitingForThirdPartyRoomView.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
88
src/components/structures/auth/LoginSplashView.tsx
Normal file
88
src/components/structures/auth/LoginSplashView.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 });
|
||||
// don’t 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>
|
||||
);
|
||||
|
|
35
src/components/structures/auth/SessionLockStolenView.tsx
Normal file
35
src/components/structures/auth/SessionLockStolenView.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
105
src/components/structures/auth/forgot-password/EnterEmail.tsx
Normal file
105
src/components/structures/auth/forgot-password/EnterEmail.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -23,4 +23,4 @@ interface AuthHeaderContextType {
|
|||
dispatch: Dispatch<ReducerAction<AuthHeaderReducer>>;
|
||||
}
|
||||
|
||||
export const AuthHeaderContext = createContext<AuthHeaderContextType>(undefined);
|
||||
export const AuthHeaderContext = createContext<AuthHeaderContextType | undefined>(undefined);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
59
src/components/structures/grouper/BaseGrouper.ts
Normal file
59
src/components/structures/grouper/BaseGrouper.ts
Normal 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;
|
||||
}
|
167
src/components/structures/grouper/CreationGrouper.tsx
Normal file
167
src/components/structures/grouper/CreationGrouper.tsx
Normal 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;
|
||||
}
|
||||
}
|
43
src/components/structures/grouper/LateEventGrouper.ts
Normal file
43
src/components/structures/grouper/LateEventGrouper.ts
Normal 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];
|
||||
}
|
193
src/components/structures/grouper/MainGrouper.tsx
Normal file
193
src/components/structures/grouper/MainGrouper.tsx
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
103
src/components/utils/Box.tsx
Normal file
103
src/components/utils/Box.tsx
Normal 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,
|
||||
);
|
||||
}
|
86
src/components/utils/Flex.tsx
Normal file
86
src/components/utils/Flex.tsx
Normal 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);
|
||||
}
|
|
@ -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} />
|
||||
{ /* easiest way to introduce a gap between the components */ }
|
||||
{ this.renderFileSize() }
|
||||
{/* 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} />
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
53
src/components/views/audio_messages/DevicesContextMenu.tsx
Normal file
53
src/components/views/audio_messages/DevicesContextMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
244
src/components/views/auth/LoginWithQRFlow.tsx
Normal file
244
src/components/views/auth/LoginWithQRFlow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue