Apply prettier formatting

This commit is contained in:
Michael Weimann 2022-12-12 12:24:14 +01:00
parent 1cac306093
commit 526645c791
No known key found for this signature in database
GPG key ID: 53F535A266BB9584
1576 changed files with 65385 additions and 62478 deletions

View file

@ -20,7 +20,7 @@ import React, { HTMLAttributes, ReactHTML, 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;
@ -34,7 +34,7 @@ export type IProps<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementPr
export default class AutoHideScrollbar<T extends keyof JSX.IntrinsicElements> extends React.Component<IProps<T>> {
static defaultProps = {
element: 'div' as keyof ReactHTML,
element: "div" as keyof ReactHTML,
};
public readonly containerRef: React.RefObject<HTMLDivElement> = React.createRef();
@ -59,13 +59,17 @@ export default class AutoHideScrollbar<T extends keyof JSX.IntrinsicElements> ex
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { element, className, onScroll, tabIndex, wrappedRef, children, ...otherProps } = this.props;
return React.createElement(element, {
...otherProps,
ref: this.containerRef,
className: classNames("mx_AutoHideScrollbar", className),
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex: tabIndex ?? -1,
}, children);
return React.createElement(
element,
{
...otherProps,
ref: this.containerRef,
className: classNames("mx_AutoHideScrollbar", className),
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex: tabIndex ?? -1,
},
children,
);
}
}

View file

@ -14,15 +14,15 @@ 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 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 { 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 {
@ -46,7 +46,7 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
selection,
additionalFilter,
}) => {
const [query, setQuery] = useState<string>('');
const [query, setQuery] = useState<string>("");
const [suggestions, setSuggestions] = useState<ICompletion[]>([]);
const [isFocused, onFocusChangeHandlerFunctions] = useFocus();
const editorContainerRef = useRef<HTMLDivElement>(null);
@ -89,7 +89,7 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
const toggleSelection = (completion: ICompletion) => {
const newSelection = [...selection];
const index = selection.findIndex(selection => selection.completionId === completion.completionId);
const index = selection.findIndex((selection) => selection.completionId === completion.completionId);
if (index >= 0) {
newSelection.splice(index, 1);
@ -103,7 +103,7 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
const removeSelection = (completion: ICompletion) => {
const newSelection = [...selection];
const index = selection.findIndex(selection => selection.completionId === completion.completionId);
const index = selection.findIndex((selection) => selection.completionId === completion.completionId);
if (index >= 0) {
newSelection.splice(index, 1);
@ -118,24 +118,22 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
<div
ref={editorContainerRef}
className={classNames({
'mx_AutocompleteInput_editor': true,
'mx_AutocompleteInput_editor--focused': isFocused,
'mx_AutocompleteInput_editor--has-suggestions': suggestions.length > 0,
"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}
/>
))
}
{selection.map((item) => (
<SelectionItem
key={item.completionId}
item={item}
onClick={removeSelection}
render={renderSelection}
/>
))}
<input
ref={editorRef}
type="text"
@ -148,27 +146,23 @@ export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
{...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
}
{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>
);
};
@ -182,14 +176,12 @@ type SelectionItemProps = {
const SelectionItem: React.FC<SelectionItemProps> = ({ item, onClick, render }) => {
const withContainer = (children: ReactNode): ReactElement => (
<span
className='mx_AutocompleteInput_editor_selection'
className="mx_AutocompleteInput_editor_selection"
data-testid={`autocomplete-selection-item-${item.completionId}`}
>
<span className='mx_AutocompleteInput_editor_selection_pill'>
{ children }
</span>
<span className="mx_AutocompleteInput_editor_selection_pill">{children}</span>
<AccessibleButton
className='mx_AutocompleteInput_editor_selection_remove_button'
className="mx_AutocompleteInput_editor_selection_remove_button"
onClick={() => onClick(item)}
data-testid={`autocomplete-selection-remove-button-${item.completionId}`}
>
@ -202,9 +194,7 @@ const SelectionItem: React.FC<SelectionItemProps> = ({ item, onClick, render })
return withContainer(render(item));
}
return withContainer(
<span className='mx_AutocompleteInput_editor_selection_text'>{ item.completion }</span>,
);
return withContainer(<span className="mx_AutocompleteInput_editor_selection_text">{item.completion}</span>);
};
type SuggestionItemProps = {
@ -215,10 +205,10 @@ type SuggestionItemProps = {
};
const SuggestionItem: React.FC<SuggestionItemProps> = ({ item, selection, onClick, render }) => {
const isSelected = selection.some(selection => selection.completionId === item.completionId);
const isSelected = selection.some((selection) => selection.completionId === item.completionId);
const classes = classNames({
'mx_AutocompleteInput_suggestion': true,
'mx_AutocompleteInput_suggestion--selected': isSelected,
"mx_AutocompleteInput_suggestion": true,
"mx_AutocompleteInput_suggestion--selected": isSelected,
});
const withContainer = (children: ReactNode): ReactElement => (
@ -231,7 +221,7 @@ const SuggestionItem: React.FC<SuggestionItemProps> = ({ item, selection, onClic
}}
data-testid={`autocomplete-suggestion-item-${item.completionId}`}
>
{ children }
{children}
</div>
);
@ -241,8 +231,8 @@ const SuggestionItem: React.FC<SuggestionItemProps> = ({ item, selection, onClic
return withContainer(
<>
<span className='mx_AutocompleteInput_suggestion_title'>{ item.completion }</span>
<span className='mx_AutocompleteInput_suggestion_description'>{ item.completionId }</span>
<span className="mx_AutocompleteInput_suggestion_title">{item.completion}</span>
<span className="mx_AutocompleteInput_suggestion_description">{item.completionId}</span>
</>,
);
};

View file

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

View file

@ -146,8 +146,9 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
// 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>("[tab-index]");
if (first) {
first.focus();
@ -226,16 +227,18 @@ 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();
}
};
@ -310,10 +313,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
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;
}
@ -328,10 +328,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
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;
}
@ -343,25 +340,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) {
@ -403,15 +403,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
@ -423,7 +423,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 }}
@ -431,7 +431,7 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
onKeyDown={onKeyDownHandler}
onContextMenu={this.onContextMenuPreventBubbling}
>
{ background }
{background}
<div
className={menuClasses}
style={menuStyle}
@ -439,10 +439,10 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
role={managed ? "menu" : undefined}
{...divProps}
>
{ body }
{body}
</div>
</div>
) }
)}
</RovingTabIndexProvider>
);
}
@ -467,7 +467,7 @@ 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 };
};
@ -481,7 +481,7 @@ export type ToLeftOf = {
// 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;
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 };
};
@ -523,7 +523,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;
@ -547,7 +547,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;
@ -567,7 +567,7 @@ export const alwaysAboveLeftOf = (
// Align the right edge of the menu to the right edge of the button
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically above the menu
menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
return menuOptions;
};
@ -586,7 +586,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;
};
@ -623,20 +623,22 @@ 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) {
const onFinished = function (...args) {
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 = (
<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>
);
ReactDOM.render(menu, getOrCreateContainer());

View file

@ -16,14 +16,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import classnames from 'classnames';
import React from "react";
import sanitizeHtml from "sanitize-html";
import classnames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../languageHandler';
import dis from '../../dispatcher/dispatcher';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import { _t } 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";
@ -52,7 +52,7 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
super(props, context);
this.state = {
page: '',
page: "",
};
}
@ -80,10 +80,10 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
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 => {
Object.keys(this.props.replaceMap).forEach((key) => {
body = body.split(key).join(this.props.replaceMap[key]);
});
}
@ -113,7 +113,7 @@ 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();
}
};
@ -129,18 +129,12 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
[`${className}_loggedIn`]: !!client,
});
const content = <div className={`${className}_body`}
dangerouslySetInnerHTML={{ __html: this.state.page }}
/>;
const content = <div className={`${className}_body`} dangerouslySetInnerHTML={{ __html: this.state.page }} />;
if (this.props.scrollbar) {
return <AutoHideScrollbar className={classes}>
{ content }
</AutoHideScrollbar>;
return <AutoHideScrollbar className={classes}>{content}</AutoHideScrollbar>;
} else {
return <div className={classes}>
{ content }
</div>;
return <div className={classes}>{content}</div>;
}
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
import { Icon as WarningBadgeIcon } from "../../../res/img/element-icons/warning-badge.svg";
@ -26,15 +26,13 @@ interface ErrorMessageProps {
* 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;
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>;
return (
<div className="mx_ErrorMessage">
{icon}
{message}
</div>
);
};

View file

@ -41,17 +41,17 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
ev.stopPropagation();
ev.preventDefault();
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,
}));
};
@ -59,7 +59,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
ev.stopPropagation();
ev.preventDefault();
setState(state => ({
setState((state) => ({
counter: state.counter - 1,
dragging: state.counter <= 1 ? false : state.dragging,
}));
@ -84,7 +84,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
ev.preventDefault();
onFileDrop(ev.dataTransfer);
setState(state => ({
setState((state) => ({
dragging: false,
counter: state.counter - 1,
}));
@ -108,10 +108,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("Drop file here to upload")}
</div>
);
}
return null;

View file

@ -15,26 +15,26 @@ 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 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 { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
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;
@ -145,18 +145,14 @@ class FilePanel extends React.Component<IProps, IState> {
const client = MatrixClientPeg.get();
const filter = new Filter(client.credentials.userId);
filter.setDefinition(
{
"room": {
"timeline": {
"contains_url": true,
"types": [
"m.room.message",
],
},
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;
@ -229,52 +225,58 @@ 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>;
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>
);
} 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("You must join the room to see its files")}</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("No files visible in this room")}</h2>
<p>{_t("Attach files from chat or just drag and drop them anywhere in a room.")}</p>
</div>
);
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
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}
/>
<Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />
<SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
@ -291,14 +293,13 @@ class FilePanel extends React.Component<IProps, IState> {
);
} else {
return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
>
<RoomContext.Provider
value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}
>
<BaseCard className="mx_FilePanel" onClose={this.props.onClose}>
<Spinner />
</BaseCard>
</RoomContext.Provider>

View file

@ -44,17 +44,19 @@ export function GenericDropdownMenuOption<T extends Key>({
onClick: (ev: ButtonEvent) => void;
isSelected: boolean;
}): JSX.Element {
return <MenuItemRadio
active={isSelected}
className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item"
onClick={onClick}
>
<div className="mx_GenericDropdownMenu_Option--label">
<span>{ label }</span>
<span>{ description }</span>
</div>
{ adornment }
</MenuItemRadio>;
return (
<MenuItemRadio
active={isSelected}
className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item"
onClick={onClick}
>
<div className="mx_GenericDropdownMenu_Option--label">
<span>{label}</span>
<span>{description}</span>
</div>
{adornment}
</MenuItemRadio>
);
}
export function GenericDropdownMenuGroup<T extends Key>({
@ -63,33 +65,35 @@ 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 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 +106,98 @@ 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);
.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>
)) }
</>;
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>
))}
</>
);
} 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}
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);
}}
const contextMenu = menuDisplayed ? (
<ContextMenu
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
wrapperClassName={classNames("mx_GenericDropdownMenu_wrapper", className)}
{...aboveLeftOf(button.current.getBoundingClientRect())}
>
{ selectedLabel(selected) }
</ContextMenuButton>
{ contextMenu }
</>;
{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}
</>
);
}

View file

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

View file

@ -17,7 +17,7 @@ limitations under the License.
import * as React from "react";
import { useContext, useState } from "react";
import AutoHideScrollbar from './AutoHideScrollbar';
import AutoHideScrollbar from "./AutoHideScrollbar";
import { getHomePageUrl } from "../../utils/pages";
import { _tDom } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
@ -35,7 +35,7 @@ import EmbeddedPage from "./EmbeddedPage";
const onClickSendDm = (ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebHomeCreateChatButton", ev);
dis.dispatch({ action: 'view_create_chat' });
dis.dispatch({ action: "view_create_chat" });
};
const onClickExplore = (ev: ButtonEvent) => {
@ -45,7 +45,7 @@ const onClickExplore = (ev: ButtonEvent) => {
const onClickNewRoom = (ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebHomeCreateRoomButton", ev);
dis.dispatch({ action: 'view_create_room' });
dis.dispatch({ action: "view_create_room" });
};
interface IProps {
@ -65,28 +65,30 @@ const UserWelcomeTop = () => {
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("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>
<h1>{ _tDom("Welcome %(name)s", { name: ownProfile.displayName }) }</h1>
<h2>{ _tDom("Now, let's help you get started") }</h2>
</div>;
<h1>{_tDom("Welcome %(name)s", { name: ownProfile.displayName })}</h1>
<h2>{_tDom("Now, let's help you get started")}</h2>
</div>
);
};
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
@ -104,29 +106,33 @@ const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
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("Welcome to %(appName)s", { appName: config.brand })}</h1>
<h2>{_tDom("Own your conversations.")}</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("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>
</div>
</div>
</div>
</AutoHideScrollbar>;
</AutoHideScrollbar>
);
};
export default HomePage;

View file

@ -16,10 +16,7 @@ limitations under the License.
import React from "react";
import {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { IconizedContextMenuOption, IconizedContextMenuOptionList } from "../views/context_menus/IconizedContextMenu";
import { _t } from "../../languageHandler";
import { HostSignupStore } from "../../stores/HostSignupStore";
import SdkConfig from "../../SdkConfig";
@ -46,12 +43,9 @@ export default class HostSignupAction extends React.PureComponent<IProps, IState
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconHosting"
label={_t(
"Upgrade to %(hostSignupBrand)s",
{
hostSignupBrand: hostSignupConfig.get("brand"),
},
)}
label={_t("Upgrade to %(hostSignupBrand)s", {
hostSignupBrand: hostSignupConfig.get("brand"),
})}
onClick={this.openDialog}
/>
</IconizedContextMenuOptionList>

View file

@ -38,9 +38,10 @@ 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;
@ -50,8 +51,8 @@ export default class IndicatorScrollbar<
super(props);
this.state = {
leftIndicatorOffset: '0',
rightIndicatorOffset: '0',
leftIndicatorOffset: "0",
rightIndicatorOffset: "0",
};
}
@ -84,11 +85,11 @@ export default class IndicatorScrollbar<
private checkOverflow = (): void => {
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 +115,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 +144,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 +159,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,7 +171,7 @@ 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;
}
}
@ -181,20 +183,24 @@ export default class IndicatorScrollbar<
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
const rightIndicatorStyle = { right: this.state.rightIndicatorOffset };
const leftOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
const leftOverflowIndicator = trackHorizontalOverflow ? (
<div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} />
) : null;
const rightOverflowIndicator = trackHorizontalOverflow ? (
<div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} />
) : null;
return (<AutoHideScrollbar
{...otherProps}
ref={this.autoHideScrollbar}
wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel}
>
{ leftOverflowIndicator }
{ children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
return (
<AutoHideScrollbar
{...otherProps}
ref={this.autoHideScrollbar}
wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel}
>
{leftOverflowIndicator}
{children}
{rightOverflowIndicator}
</AutoHideScrollbar>
);
}
}

View file

@ -23,10 +23,10 @@ import {
IStageStatus,
} from "matrix-js-sdk/src/interactive-auth";
import { MatrixClient } from "matrix-js-sdk/src/client";
import React, { createRef } from 'react';
import React, { createRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import getEntryComponentForLoginType, { IStageComponent } from '../views/auth/InteractiveAuthEntryComponents';
import getEntryComponentForLoginType, { IStageComponent } from "../views/auth/InteractiveAuthEntryComponents";
import Spinner from "../views/elements/Spinner";
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
@ -34,12 +34,9 @@ export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
type InteractiveAuthCallbackSuccess = (
success: true,
response: IAuthData,
extra?: { emailSid?: string, clientSecret?: string }
) => void;
type InteractiveAuthCallbackFailure = (
success: false,
response: IAuthData | Error,
extra?: { emailSid?: string; clientSecret?: string },
) => void;
type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => void;
export type InteractiveAuthCallback = InteractiveAuthCallbackSuccess & InteractiveAuthCallbackFailure;
interface IProps {
@ -134,25 +131,28 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
}
public componentDidMount() {
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;
}
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;
}
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() {
@ -168,7 +168,7 @@ 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,
});
@ -183,19 +183,22 @@ 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 | null, background: boolean): Promise<IAuthData> => {

View file

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

View file

@ -157,11 +157,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// 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;
@ -171,8 +174,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// 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 });
@ -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");
}
}
}
@ -326,23 +329,26 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// 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")}
/>;
/>
);
}
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")}
/>;
rightButton = (
<AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/>
);
}
return (
@ -354,45 +360,40 @@ export default class LeftPanel extends React.Component<IProps, IState> {
>
<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}
/>
) }
{this.renderSearchDialExplore()}
{this.renderBreadcrumbs()}
{!this.props.isMinimized && <RoomListHeader onVisibilityChange={this.refreshStickyHeaders} />}
<UserOnboardingButton
selected={this.props.pageType === PageType.HomePage}
minimized={this.props.isMinimized}
@ -405,7 +406,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{ roomList }
{roomList}
</div>
</div>
</div>

View file

@ -17,9 +17,9 @@ limitations under the License.
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { 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,10 +35,7 @@ const CONNECTING_STATES = [
CallState.CreateAnswer,
];
const SUPPORTED_STATES = [
CallState.Connected,
CallState.Ringing,
];
const SUPPORTED_STATES = [CallState.Connected, CallState.Ringing];
export enum CustomCallState {
Missed = "missed",
@ -54,7 +51,7 @@ export function buildLegacyCallEventGroupers(
events?: MatrixEvent[],
): Map<string, LegacyCallEventGrouper> {
const newCallEventGroupers = new Map();
events?.forEach(ev => {
events?.forEach((ev) => {
if (!isCallEvent(ev)) {
return;
}
@ -83,7 +80,8 @@ export default class LegacyCallEventGrouper extends EventEmitter {
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallsChanged, this.setCall);
LegacyCallHandler.instance.addListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
LegacyCallHandlerEvent.SilencedCallsChanged,
this.onSilencedCallsChanged,
);
}
@ -108,7 +106,7 @@ export default class LegacyCallEventGrouper extends EventEmitter {
if (!invite) return;
// FIXME: Find a better way to determine this from the event?
if (invite.getContent()?.offer?.sdp?.indexOf('m=video') !== -1) return false;
if (invite.getContent()?.offer?.sdp?.indexOf("m=video") !== -1) return false;
return true;
}
@ -167,9 +165,9 @@ export default class LegacyCallEventGrouper extends EventEmitter {
public toggleSilenced = () => {
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() {

View file

@ -14,26 +14,26 @@ 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 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 { MatrixError } from "matrix-js-sdk/src/matrix";
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 +41,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 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 HostSignupContainer from "../views/host_signup/HostSignupContainer";
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";
// 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.
@ -125,7 +125,7 @@ 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';
static displayName = "LoggedInView";
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<RoomViewType>;
@ -142,7 +142,7 @@ class LoggedInView extends React.Component<IProps, IState> {
this.state = {
syncErrorData: undefined,
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
useCompactLayout: SettingsStore.getValue("useCompactLayout"),
usageLimitDismissed: false,
activeCalls: LegacyCallHandler.instance.getAllActiveCalls(),
};
@ -160,7 +160,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
document.addEventListener("keydown", this.onNativeKeyDown, false);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this.updateServerNoticeEvents();
@ -168,19 +168,19 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.on(ClientEvent.AccountData, this.onAccountData);
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());
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();
@ -192,7 +192,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
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);
@ -238,7 +238,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,10 +251,10 @@ 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,
@ -273,7 +273,7 @@ class LoggedInView extends React.Component<IProps, IState> {
if (isNaN(lhsSize)) {
lhsSize = 350;
}
this.resizer.forHandleWithId('lp-resizer').resize(lhsSize);
this.resizer.forHandleWithId("lp-resizer").resize(lhsSize);
}
private onAccountData = (event: MatrixEvent) => {
@ -306,7 +306,7 @@ 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();
}
};
@ -352,7 +352,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
const event = timeline.getEvents().find(ev => ev.getId() === eventId);
const event = timeline.getEvents().find((ev) => ev.getId() === eventId);
if (event) events.push(event);
}
}
@ -364,8 +364,9 @@ class LoggedInView extends React.Component<IProps, IState> {
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();
@ -389,10 +390,13 @@ class LoggedInView extends React.Component<IProps, IState> {
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,
);
}
};
@ -448,7 +452,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 +467,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;
@ -542,12 +546,12 @@ class LoggedInView extends React.Component<IProps, IState> {
case KeyBindingAction.ToggleHiddenEventVisibility: {
const hiddenEventVisibility = SettingsStore.getValueAt(
SettingLevel.DEVICE,
'showHiddenEventsInTimeline',
"showHiddenEventsInTimeline",
undefined,
false,
);
SettingsStore.setValue(
'showHiddenEventsInTimeline',
"showHiddenEventsInTimeline",
undefined,
SettingLevel.DEVICE,
!hiddenEventVisibility,
@ -582,8 +586,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
@ -595,10 +598,13 @@ class LoggedInView extends React.Component<IProps, IState> {
if (!isClickShortcut && isPrintable && !getInputableElement(ev.target as HTMLElement)) {
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
}
@ -620,16 +626,18 @@ class LoggedInView extends React.Component<IProps, IState> {
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:
@ -642,18 +650,16 @@ class LoggedInView extends React.Component<IProps, IState> {
}
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 +672,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}
/>
<nav 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}
@ -691,15 +692,13 @@ class LoggedInView extends React.Component<IProps, IState> {
</nav>
</div>
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
<div className="mx_RoomView_wrapper">
{ pageElement }
</div>
<div className="mx_RoomView_wrapper">{pageElement}</div>
</div>
</div>
<PipContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
{ audioFeedArraysForCalls }
{audioFeedArraysForCalls}
</MatrixClientContext.Provider>
);
}

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { NumberSize, Resizable } from 're-resizable';
import React from "react";
import { NumberSize, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import ResizeNotifier from "../../utils/ResizeNotifier";
@ -37,13 +37,16 @@ export default class MainSplit extends React.Component<IProps> {
};
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());
};
private loadSidePanelSize(): {height: string | number, width: number} {
private loadSidePanelSize(): { height: string | number; width: number } {
let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10);
if (isNaN(rhsSize)) {
@ -64,33 +67,37 @@ 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
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

View file

@ -14,23 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, KeyboardEvent, ReactNode, TransitionEvent } from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { Room } from 'matrix-js-sdk/src/models/room';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { logger } from 'matrix-js-sdk/src/logger';
import React, { createRef, KeyboardEvent, ReactNode, TransitionEvent } from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon';
import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon";
import { isSupportedReceiptType } from "matrix-js-sdk/src/utils";
import { ReadReceipt } from 'matrix-js-sdk/src/models/read-receipt';
import { ListenerMap } from 'matrix-js-sdk/src/models/typed-event-emitter';
import { ReadReceipt } from "matrix-js-sdk/src/models/read-receipt";
import { ListenerMap } from "matrix-js-sdk/src/models/typed-event-emitter";
import shouldHideEvent from '../../shouldHideEvent';
import { wantsDateSeparator } from '../../DateUtils';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import shouldHideEvent from "../../shouldHideEvent";
import { wantsDateSeparator } from "../../DateUtils";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import SettingsStore from "../../settings/SettingsStore";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import { Layout } from "../../settings/enums/Layout";
import { _t } from "../../languageHandler";
@ -40,25 +40,25 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz
import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import HistoryTile from "../views/rooms/HistoryTile";
import defaultDispatcher from '../../dispatcher/dispatcher';
import defaultDispatcher from "../../dispatcher/dispatcher";
import LegacyCallEventGrouper from "./LegacyCallEventGrouper";
import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile";
import ScrollPanel, { IScrollState } from "./ScrollPanel";
import GenericEventListSummary from '../views/elements/GenericEventListSummary';
import EventListSummary from '../views/elements/EventListSummary';
import DateSeparator from '../views/messages/DateSeparator';
import ErrorBoundary from '../views/elements/ErrorBoundary';
import GenericEventListSummary from "../views/elements/GenericEventListSummary";
import EventListSummary from "../views/elements/EventListSummary";
import DateSeparator from "../views/messages/DateSeparator";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import ResizeNotifier from "../../utils/ResizeNotifier";
import Spinner from "../views/elements/Spinner";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import EditorStateTransfer from "../../utils/EditorStateTransfer";
import { Action } from '../../dispatcher/actions';
import { Action } from "../../dispatcher/actions";
import { getEventDisplayInfo } from "../../utils/EventRenderingUtils";
import { IReadReceiptInfo } from "../views/rooms/ReadReceiptMarker";
import { haveRendererForEvent } from "../../events/EventTileFactory";
import { editorRoomKey } from "../../Editing";
import { hasThreadSummary } from "../../utils/EventUtils";
import { VoiceBroadcastInfoEventType } from '../../voice-broadcast';
import { VoiceBroadcastInfoEventType } from "../../voice-broadcast";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
@ -88,17 +88,24 @@ export function shouldFormContinuation(
if (mxEvent.isRedacted() !== prevEvent.isRedacted()) return false;
// Some events should appear as continuations from previous events of different types.
if (mxEvent.getType() !== prevEvent.getType() &&
if (
mxEvent.getType() !== prevEvent.getType() &&
(!continuedTypes.includes(mxEvent.getType() as EventType) ||
!continuedTypes.includes(prevEvent.getType() as EventType))) return false;
!continuedTypes.includes(prevEvent.getType() as EventType))
)
return false;
// Check if the sender is the same and hasn't changed their displayname/avatar between these events
if (mxEvent.sender.userId !== prevEvent.sender.userId ||
if (
mxEvent.sender.userId !== prevEvent.sender.userId ||
mxEvent.sender.name !== prevEvent.sender.name ||
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()
)
return false;
// Thread summaries in the main timeline should break up a continuation on both sides
if (threadsEnabled &&
if (
threadsEnabled &&
(hasThreadSummary(mxEvent) || hasThreadSummary(prevEvent)) &&
timelineRenderingType !== TimelineRenderingType.Thread
) {
@ -282,8 +289,11 @@ export default class MessagePanel extends React.Component<IProps, IState> {
this._showHiddenEvents = SettingsStore.getValue("showHiddenEventsInTimeline");
this.threadsEnabled = SettingsStore.getValue("feature_thread");
this.showTypingNotificationsWatcherRef =
SettingsStore.watchSetting("showTypingNotifications", null, this.onShowTypingNotificationsChange);
this.showTypingNotificationsWatcherRef = SettingsStore.watchSetting(
"showTypingNotifications",
null,
this.onShowTypingNotificationsChange,
);
}
componentDidMount() {
@ -497,18 +507,17 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// algorithms which depend on its position on the screen aren't
// confused.
if (visible) {
hr = <hr className="mx_RoomView_myReadMarker"
style={{ opacity: 1, width: '99%' }}
/>;
hr = <hr className="mx_RoomView_myReadMarker" style={{ opacity: 1, width: "99%" }} />;
}
return (
<li key={"readMarker_"+eventId}
<li
key={"readMarker_" + eventId}
ref={this.readMarkerNode}
className="mx_RoomView_myReadMarker_container"
data-scroll-tokens={eventId}
>
{ hr }
{hr}
</li>
);
} else if (this.state.ghostReadMarkers.includes(eventId)) {
@ -522,21 +531,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// case is a little more complex because only some of the items
// transition (ie. the read markers do but the event tiles do not)
// and TransitionGroup requires that all its children are Transitions.
const hr = <hr className="mx_RoomView_myReadMarker"
ref={this.collectGhostReadMarker}
onTransitionEnd={this.onGhostTransitionEnd}
data-eventid={eventId}
/>;
const hr = (
<hr
className="mx_RoomView_myReadMarker"
ref={this.collectGhostReadMarker}
onTransitionEnd={this.onGhostTransitionEnd}
data-eventid={eventId}
/>
);
// give it a key which depends on the event id. That will ensure that
// we get a new DOM node (restarting the animation) when the ghost
// moves to a different event.
return (
<li
key={"_readuptoghost_"+eventId}
className="mx_RoomView_myReadMarker_container"
>
{ hr }
<li key={"_readuptoghost_" + eventId} className="mx_RoomView_myReadMarker_container">
{hr}
</li>
);
}
@ -548,8 +557,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
if (node) {
// now the element has appeared, change the style which will trigger the CSS transition
requestAnimationFrame(() => {
node.style.width = '10%';
node.style.opacity = '0';
node.style.width = "10%";
node.style.opacity = "0";
});
}
};
@ -558,20 +567,18 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// we can now clean up the ghost element
const finishedEventId = (ev.target as HTMLElement).dataset.eventid;
this.setState({
ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId),
ghostReadMarkers: this.state.ghostReadMarkers.filter((eid) => eid !== finishedEventId),
});
};
private getNextEventInfo(arr: MatrixEvent[], i: number): { nextEvent: MatrixEvent, nextTile: MatrixEvent } {
const nextEvent = i < arr.length - 1
? arr[i + 1]
: null;
private getNextEventInfo(arr: MatrixEvent[], i: number): { nextEvent: MatrixEvent; nextTile: MatrixEvent } {
const nextEvent = i < arr.length - 1 ? arr[i + 1] : null;
// The next event with tile is used to to determine the 'last successful' flag
// when rendering the tile. The shouldShowEvent function is pretty quick at what
// it does, so this should have no significant cost even when a room is used for
// not-chat purposes.
const nextTile = arr.slice(i + 1).find(e => this.shouldShowEvent(e));
const nextTile = arr.slice(i + 1).find((e) => this.shouldShowEvent(e));
return { nextEvent, nextTile };
}
@ -601,7 +608,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
let lastShownEvent;
let lastShownNonLocalEchoIndex = -1;
for (i = this.props.events.length-1; i >= 0; i--) {
for (i = this.props.events.length - 1; i >= 0; i--) {
const mxEv = this.props.events[i];
if (!this.shouldShowEvent(mxEv)) {
continue;
@ -637,7 +644,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
for (i = 0; i < this.props.events.length; i++) {
const mxEv = this.props.events[i];
const eventId = mxEv.getId();
const last = (mxEv === lastShownEvent);
const last = mxEv === lastShownEvent;
const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i);
if (grouper) {
@ -715,27 +722,37 @@ export default class MessagePanel extends React.Component<IProps, IState> {
if (nextEventWithTile) {
const nextEv = nextEventWithTile;
const willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEv.getDate() || new Date());
lastInSection = willWantDateSeparator ||
lastInSection =
willWantDateSeparator ||
mxEv.getSender() !== nextEv.getSender() ||
getEventDisplayInfo(nextEv, this.showHiddenEvents).isInfoMessage ||
!shouldFormContinuation(
mxEv, nextEv, this.showHiddenEvents, this.threadsEnabled, this.context.timelineRenderingType,
mxEv,
nextEv,
this.showHiddenEvents,
this.threadsEnabled,
this.context.timelineRenderingType,
);
}
// is this a continuation of the previous message?
const continuation = !wantsDateSeparator &&
const continuation =
!wantsDateSeparator &&
shouldFormContinuation(
prevEvent, mxEv, this.showHiddenEvents, this.threadsEnabled, this.context.timelineRenderingType,
prevEvent,
mxEv,
this.showHiddenEvents,
this.threadsEnabled,
this.context.timelineRenderingType,
);
const eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId);
const highlight = eventId === this.props.highlightedEventId;
const readReceipts = this.readReceiptsByEvent[eventId];
let isLastSuccessful = false;
const isSentState = s => !s || s === 'sent';
const isSentState = (s) => !s || s === "sent";
const isSent = isSentState(mxEv.getAssociatedStatus());
const hasNextEvent = nextEvent && this.shouldShowEvent(nextEvent);
if (!hasNextEvent && isSent) {
@ -825,17 +842,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
const receipts: IReadReceiptProps[] = [];
if (!receiptDestination) {
logger.debug("Discarding request, could not find the receiptDestination for event: "
+ this.context.threadId);
logger.debug(
"Discarding request, could not find the receiptDestination for event: " + this.context.threadId,
);
return receipts;
}
receiptDestination.getReceiptsForEvent(event).forEach((r) => {
if (
!r.userId ||
!isSupportedReceiptType(r.type) ||
r.userId === myUserId
) {
if (!r.userId || !isSupportedReceiptType(r.type) || r.userId === myUserId) {
return; // ignore non-read receipts and receipts from self.
}
if (MatrixClientPeg.get().isUserIgnored(r.userId)) {
@ -972,38 +986,51 @@ export default class MessagePanel extends React.Component<IProps, IState> {
let topSpinner;
let bottomSpinner;
if (this.props.backPaginating) {
topSpinner = <li key="_topSpinner"><Spinner /></li>;
topSpinner = (
<li key="_topSpinner">
<Spinner />
</li>
);
}
if (this.props.forwardPaginating) {
bottomSpinner = <li key="_bottomSpinner"><Spinner /></li>;
bottomSpinner = (
<li key="_bottomSpinner">
<Spinner />
</li>
);
}
const style = this.props.hidden ? { display: 'none' } : {};
const style = this.props.hidden ? { display: "none" } : {};
let whoIsTyping;
if (this.props.room &&
if (
this.props.room &&
this.state.showTypingNotifications &&
this.context.timelineRenderingType === TimelineRenderingType.Room
) {
whoIsTyping = (<WhoIsTypingTile
room={this.props.room}
onShown={this.onTypingShown}
onHidden={this.onTypingHidden}
ref={this.whoIsTyping} />
whoIsTyping = (
<WhoIsTypingTile
room={this.props.room}
onShown={this.onTypingShown}
onHidden={this.onTypingHidden}
ref={this.whoIsTyping}
/>
);
}
let ircResizer = null;
if (this.props.layout == Layout.IRC) {
ircResizer = <IRCTimelineProfileResizer
minWidth={20}
maxWidth={600}
roomId={this.props.room ? this.props.room.roomId : null}
/>;
ircResizer = (
<IRCTimelineProfileResizer
minWidth={20}
maxWidth={600}
roomId={this.props.room ? this.props.room.roomId : null}
/>
);
}
const classes = classNames(this.props.className, {
"mx_MessagePanel_narrow": this.context.narrow,
mx_MessagePanel_narrow: this.context.narrow,
});
return (
@ -1019,10 +1046,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
resizeNotifier={this.props.resizeNotifier}
fixedChildren={ircResizer}
>
{ topSpinner }
{ this.getEventTiles() }
{ whoIsTyping }
{ bottomSpinner }
{topSpinner}
{this.getEventTiles()}
{whoIsTyping}
{bottomSpinner}
</ScrollPanel>
</ErrorBoundary>
);
@ -1070,7 +1097,7 @@ abstract class BaseGrouper {
// 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
class CreationGrouper extends BaseGrouper {
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
static canStartGroup = function (panel: MessagePanel, ev: MatrixEvent): boolean {
return ev.getType() === EventType.RoomCreate;
};
@ -1083,8 +1110,10 @@ class CreationGrouper extends BaseGrouper {
if (panel.wantsDateSeparator(this.event, ev.getDate())) {
return false;
}
if (ev.getType() === EventType.RoomMember
&& (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) {
if (
ev.getType() === EventType.RoomMember &&
(ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")
) {
return false;
}
@ -1110,10 +1139,7 @@ class CreationGrouper extends BaseGrouper {
public add(ev: MatrixEvent): void {
const panel = this.panel;
this.readMarker = this.readMarker || panel.readMarkerForEvent(
ev.getId(),
ev === this.lastShownEvent,
);
this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId(), ev === this.lastShownEvent);
if (!panel.shouldShowEvent(ev)) {
return;
}
@ -1139,7 +1165,9 @@ class CreationGrouper extends BaseGrouper {
if (panel.wantsDateSeparator(this.prevEvent, createEvent.getDate())) {
const ts = createEvent.getTs();
ret.push(
<li key={ts+'~'}><DateSeparator roomId={createEvent.getRoomId()} ts={ts} /></li>,
<li key={ts + "~"}>
<DateSeparator roomId={createEvent.getRoomId()} ts={ts} />
</li>,
);
}
@ -1150,18 +1178,18 @@ class CreationGrouper extends BaseGrouper {
}
for (const ejected of this.ejectedEvents) {
ret.push(...panel.getTilesForEvent(
createEvent, ejected, createEvent === lastShownEvent, isGrouped,
));
ret.push(...panel.getTilesForEvent(createEvent, ejected, createEvent === 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, e, e === lastShownEvent, isGrouped);
}).reduce((a, b) => a.concat(b), []);
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, e, e === lastShownEvent, isGrouped);
})
.reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1];
@ -1185,7 +1213,7 @@ class CreationGrouper extends BaseGrouper {
summaryText={summaryText}
layout={this.panel.props.layout}
>
{ eventTiles }
{eventTiles}
</GenericEventListSummary>,
);
@ -1203,7 +1231,7 @@ class CreationGrouper extends BaseGrouper {
// Wrap consecutive grouped events in a ListSummary
class MainGrouper extends BaseGrouper {
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
static canStartGroup = function (panel: MessagePanel, ev: MatrixEvent): boolean {
if (!panel.shouldShowEvent(ev)) return false;
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
@ -1284,14 +1312,16 @@ class MainGrouper extends BaseGrouper {
if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
const ts = this.events[0].getTs();
ret.push(
<li key={ts+'~'}><DateSeparator roomId={this.events[0].getRoomId()} ts={ts} /></li>,
<li key={ts + "~"}>
<DateSeparator roomId={this.events[0].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));
const keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e));
const key = keyEvent ? this.panel.grouperKeyMap.get(keyEvent) : this.generateKey();
if (!keyEvent) {
// Populate the weak map with the key.
@ -1302,19 +1332,21 @@ class MainGrouper extends BaseGrouper {
}
let highlightInSummary = false;
let eventTiles = this.events.map((e, i) => {
if (e.getId() === panel.props.highlightedEventId) {
highlightInSummary = true;
}
return panel.getTilesForEvent(
i === 0 ? this.prevEvent : this.events[i - 1],
e,
e === lastShownEvent,
isGrouped,
this.nextEvent,
this.nextEventTile,
);
}).reduce((a, b) => a.concat(b), []);
let eventTiles = this.events
.map((e, i) => {
if (e.getId() === panel.props.highlightedEventId) {
highlightInSummary = true;
}
return panel.getTilesForEvent(
i === 0 ? this.prevEvent : this.events[i - 1],
e,
e === lastShownEvent,
isGrouped,
this.nextEvent,
this.nextEventTile,
);
})
.reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
eventTiles = null;
@ -1335,7 +1367,7 @@ class MainGrouper extends BaseGrouper {
startExpanded={highlightInSummary}
layout={this.panel.props.layout}
>
{ eventTiles }
{eventTiles}
</EventListSummary>,
);

View file

@ -20,8 +20,7 @@ import { ComponentClass } from "../../@types/common";
import NonUrgentToastStore from "../../stores/NonUrgentToastStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
interface IProps {
}
interface IProps {}
interface IState {
toasts: ComponentClass[];
@ -50,14 +49,14 @@ export default class NonUrgentToastContainer extends React.PureComponent<IProps,
const toasts = this.state.toasts.map((t, i) => {
return (
<div className="mx_NonUrgentToastContainer_toast" key={`toast-${i}`}>
{ React.createElement(t, {}) }
{React.createElement(t, {})}
</div>
);
});
return (
<div className="mx_NonUrgentToastContainer" role="alert">
{ toasts }
{toasts}
</div>
);
}

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../languageHandler';
import { _t } from "../../languageHandler";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseCard from "../views/right_panel/BaseCard";
import TimelinePanel from "./TimelinePanel";
@ -55,10 +55,12 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
};
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>);
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>
);
let content;
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
@ -80,18 +82,19 @@ 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 className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
<Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />
{content}
</BaseCard>
</RoomContext.Provider>
);
}
}

View file

@ -15,15 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
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 { 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,12 +38,12 @@ 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";
interface IProps {
room?: Room; // if showing panels for a given room, this is set
@ -71,9 +71,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);
@ -115,7 +119,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
};
private onRightPanelStoreUpdate = () => {
this.setState({ ...RightPanel.getDerivedStateFromProps(this.props) as IState });
this.setState({ ...(RightPanel.getDerivedStateFromProps(this.props) as IState) });
};
private onClose = () => {
@ -153,40 +157,44 @@ export default class RightPanel extends React.Component<IProps, IState> {
switch (phase) {
case RightPanelPhases.RoomMemberList:
if (roomId) {
card = <MemberList
roomId={roomId}
key={roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>;
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}
/>;
card = (
<MemberList
roomId={cardState.spaceId ? cardState.spaceId : roomId}
key={cardState.spaceId ? 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}
/>;
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:
@ -200,49 +208,57 @@ export default class RightPanel extends React.Component<IProps, IState> {
case RightPanelPhases.PinnedMessages:
if (SettingsStore.getValue("feature_pinning")) {
card = <PinnedMessagesCard
room={this.props.room}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>;
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}
/>;
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} />;
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}
/>;
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}
/>;
card = (
<ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>
);
break;
case RightPanelPhases.RoomSummary:
@ -250,17 +266,13 @@ export default class RightPanel extends React.Component<IProps, IState> {
break;
case RightPanelPhases.Widget:
card = <WidgetCard
room={this.props.room}
widgetId={cardState.widgetId}
onClose={this.onClose}
/>;
card = <WidgetCard room={this.props.room} widgetId={cardState.widgetId} onClose={this.onClose} />;
break;
}
return (
<aside className="mx_RightPanel dark-panel" id="mx_RightPanel">
{ card }
{card}
</aside>
);
}

View file

@ -48,31 +48,34 @@ export default class RoomSearch extends React.PureComponent<IProps> {
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'focus_room_filter') {
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("Search")}</div>}
{shortcutPrompt}
</AccessibleButton>
);
}
}

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, RefObject, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import { IThreadBundledRelationship } from 'matrix-js-sdk/src/models/event';
import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread';
import React, { forwardRef, RefObject, useCallback, useContext, useEffect, useRef, useState } from "react";
import { ISearchResults } from "matrix-js-sdk/src/@types/search";
import { IThreadBundledRelationship } from "matrix-js-sdk/src/models/event";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { logger } from "matrix-js-sdk/src/logger";
import ScrollPanel from "./ScrollPanel";
@ -35,7 +35,7 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import RoomContext from "../../contexts/RoomContext";
const DEBUG = false;
let debuglog = function(msg: string) {};
let debuglog = function (msg: string) {};
/* istanbul ignore next */
if (DEBUG) {
@ -56,203 +56,222 @@ interface Props {
// 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,
permalinkCreator,
className,
onUpdate,
}: Props, ref: RefObject<ScrollPanel>) => {
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);
export const RoomSearchView = forwardRef<ScrollPanel, Props>(
(
{ term, scope, promise, abortController, resizeNotifier, permalinkCreator, className, onUpdate }: Props,
ref: RefObject<ScrollPanel>,
) => {
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);
const handleSearchResult = useCallback((searchPromise: Promise<ISearchResults>): Promise<boolean> => {
setInProgress(true);
const handleSearchResult = useCallback(
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
setInProgress(true);
return searchPromise.then(async (results) => {
debuglog("search complete");
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
return searchPromise
.then(
async (results) => {
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.
// 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);
}
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 overlapping highlights,
// favour longer (more specific) terms first
highlights = highlights.sort(function (a, b) {
return b.length - a.length;
});
if (client.supportsExperimentalThreads()) {
// Process all thread roots returned in this batch of search results
// XXX: This won't work for results coming from Seshat which won't include the bundled relationship
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);
}
}
}
}
if (client.supportsExperimentalThreads()) {
// Process all thread roots returned in this batch of search results
// XXX: This won't work for results coming from Seshat which won't include the bundled relationship
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
}, (error) => {
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
logger.error("Search failed", error);
Modal.createDialog(ErrorDialog, {
title: _t("Search failed"),
description: error?.message
?? _t("Server may be unavailable, overloaded, or search timed out :("),
});
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"
/>
setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh
},
(error) => {
if (aborted.current) {
logger.error("Discarding stale search results");
return false;
}
logger.error("Search failed", error);
Modal.createDialog(ErrorDialog, {
title: _t("Search failed"),
description:
error?.message ??
_t("Server may be unavailable, overloaded, or search timed out :("),
});
return false;
},
)
.finally(() => {
setInProgress(false);
});
},
[client, term],
);
}
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
if (!backwards) {
return false;
// 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(results);
return handleSearchResult(searchPromise);
};
const ret: JSX.Element[] = [];
if (inProgress) {
ret.push(
<li key="search-spinner">
<Spinner />
</li>,
);
}
if (!results.next_batch) {
debuglog("no more search results");
return false;
}
debuglog("requesting more search results");
const searchPromise = searchPagination(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("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 = () => {
const scrollPanel = ref.current;
scrollPanel?.checkScroll();
};
let lastRoomId: string;
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, 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("Room") }: { room.name }</h2>
</li>);
lastRoomId = roomId;
if (!results?.results?.length) {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("No results")}</h2>
</li>,
);
} else {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("No more results")}</h2>
</li>,
);
}
}
const resultLink = "#/room/"+roomId+"/"+mxEv.getId();
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onHeightChanged = () => {
const scrollPanel = ref.current;
scrollPanel?.checkScroll();
};
ret.push(<SearchResultTile
key={mxEv.getId()}
searchResult={result}
searchHighlights={highlights}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
onHeightChanged={onHeightChanged}
/>);
}
let lastRoomId: string;
return (
<ScrollPanel
ref={ref}
className={"mx_RoomView_searchResultsPanel " + className}
onFillRequest={onSearchResultsFillRequest}
resizeNotifier={resizeNotifier}
>
<li className="mx_RoomView_scrollheader" />
{ ret }
</ScrollPanel>
);
});
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, 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("Room")}: {room.name}
</h2>
</li>,
);
lastRoomId = roomId;
}
}
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
ret.push(
<SearchResultTile
key={mxEv.getId()}
searchResult={result}
searchHighlights={highlights}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
onHeightChanged={onHeightChanged}
/>,
);
}
return (
<ScrollPanel
ref={ref}
className={"mx_RoomView_searchResultsPanel " + className}
onFillRequest={onSearchResultsFillRequest}
resizeNotifier={resizeNotifier}
>
<li className="mx_RoomView_scrollheader" />
{ret}
</ScrollPanel>
);
},
);

View file

@ -14,29 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import 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 { _t, _td } from '../../languageHandler';
import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
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";
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);
@ -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;
}
@ -195,10 +197,10 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
let consentError = null;
let resourceLimitError = 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;
}
@ -206,13 +208,14 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
if (consentError) {
title = _t(
"You can't send any messages until you review and agree to " +
"<consentLink>our terms and conditions</consentLink>.",
"<consentLink>our terms and conditions</consentLink>.",
{},
{
'consentLink': (sub) =>
consentLink: (sub) => (
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
{ sub }
</a>,
{sub}
</a>
),
},
);
} else if (resourceLimitError) {
@ -220,46 +223,52 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
resourceLimitError.data.limit_type,
resourceLimitError.data.admin_contact,
{
'monthly_active_user': _td(
"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.",
"Please <a>contact your service administrator</a> to continue using the service.",
),
'hs_disabled': _td(
"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.",
"Please <a>contact your service administrator</a> to continue using the service.",
),
'': _td(
"": _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.",
"Please <a>contact your service administrator</a> to continue using the service.",
),
},
);
} else {
title = _t('Some of your messages have not been sent');
title = _t("Some of your messages have not been 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("Delete all")}
</AccessibleButton>
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentRetry">
{_t("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("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("You can select all or individual messages to retry or delete")}
notificationState={StaticNotificationState.RED_EXCLAMATION}
buttons={buttonRow}
/>
);
}
public render(): JSX.Element {
@ -273,13 +282,14 @@ export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
width="24"
height="24"
title="/!\ "
alt="/!\ " />
alt="/!\ "
/>
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
{_t("Connectivity to the server has been lost.")}
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
{_t("Sent messages will be stored until your connection has returned.")}
</div>
</div>
</div>

View file

@ -31,24 +31,13 @@ export const RoomStatusBarUnsentMessages = (props: RoomStatusBarUnsentMessagesPr
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
<div role="alert">
<div className="mx_RoomStatusBar_unsentBadge">
<NotificationBadge
notification={props.notificationState}
/>
<NotificationBadge notification={props.notificationState} />
</div>
<div>
<div className="mx_RoomStatusBar_unsentTitle">
{ props.title }
</div>
{
props.description &&
<div className="mx_RoomStatusBar_unsentDescription">
{ props.description }
</div>
}
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">
{ props.buttons }
<div className="mx_RoomStatusBar_unsentTitle">{props.title}</div>
{props.description && <div className="mx_RoomStatusBar_unsentDescription">{props.description}</div>}
</div>
<div className="mx_RoomStatusBar_unsentButtonBar">{props.buttons}</div>
</div>
</div>
);

File diff suppressed because it is too large Load diff

View file

@ -17,8 +17,8 @@ limitations under the License.
import React, { createRef, CSSProperties, ReactNode, KeyboardEvent } 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";
@ -165,9 +165,11 @@ export default class ScrollPanel extends React.Component<IProps> {
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> = {
@ -321,7 +323,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;
}
}
@ -379,19 +381,19 @@ export default class ScrollPanel extends React.Component<IProps> {
}
const itemlist = this.itemlist.current;
const firstTile = itemlist && itemlist.firstElementChild as HTMLElement;
const firstTile = itemlist && (itemlist.firstElementChild as HTMLElement);
const contentTop = firstTile && firstTile.offsetTop;
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 - contentTop < 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));
}
@ -440,7 +442,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// If backwards is true, we unpaginate (remove) tiles from the back (top).
let tile;
for (let i = 0; i < tiles.length; i++) {
tile = tiles[backwards ? i : (tiles.length - 1 - i)];
tile = tiles[backwards ? i : tiles.length - 1 - i];
// 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
@ -449,7 +451,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];
}
}
@ -469,7 +471,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// 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 fill in progress - not starting another; direction=", dir);
return Promise.resolve();
@ -485,25 +487,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 => 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);
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("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);
}
});
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
@ -630,7 +635,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// not giving enough content below the trackedNode to scroll downwards
// enough, so it ends up in the top of the viewport.
debuglog("scrollToken: setting scrollTop", { offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop });
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
scrollNode.scrollTop = trackedNode.offsetTop - scrollNode.clientHeight * offsetBase + pixelOffset;
this.saveScrollState();
}
};
@ -653,7 +658,8 @@ export default class ScrollPanel extends React.Component<IProps> {
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length - 1; i >= 0; --i) {
const htmlMessage = messages[i] as HTMLElement;
if (!htmlMessage.dataset?.scrollTokens) { // dataset is only specified on HTMLElements
if (!htmlMessage.dataset?.scrollTokens) {
// dataset is only specified on HTMLElements
continue;
}
node = htmlMessage;
@ -669,7 +675,7 @@ 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];
const scrollToken = node!.dataset.scrollTokens.split(",")[0];
debuglog("saving anchored scroll state to message", scrollToken);
const bottomOffset = this.topFromBottom(node);
this.scrollState = {
@ -791,7 +797,7 @@ 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 (scrollToken && m.dataset.scrollTokens?.split(',').includes(scrollToken!)) {
if (scrollToken && m.dataset.scrollTokens?.split(",").includes(scrollToken!)) {
node = m;
break;
}
@ -820,7 +826,7 @@ export default class ScrollPanel extends React.Component<IProps> {
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
const firstNodeTop = itemlist.firstElementChild ? (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 {
@ -945,10 +951,10 @@ export default class ScrollPanel extends React.Component<IProps> {
className={`mx_ScrollPanel ${this.props.className}`}
style={this.props.style}
>
{ this.props.fixedChildren }
{this.props.fixedChildren}
<div className="mx_RoomView_messageListWrapper">
<ol ref={this.itemlist} className="mx_RoomView_MessageList" aria-live="polite">
{ this.props.children }
{this.props.children}
</ol>
</div>
</AutoHideScrollbar>

View file

@ -15,11 +15,11 @@ 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";
@ -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);
@ -99,8 +103,20 @@ export default class SearchBox extends React.Component<IProps, IState> {
public render(): JSX.Element {
/* 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 +125,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,12 +153,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>
);
}

View file

@ -102,9 +102,13 @@ const Tile: React.FC<ITileProps> = ({
const cliRoom = cli.getRoom(room.room_id);
return cliRoom?.getMyMembership() === "join" ? cliRoom : null;
});
const joinedRoomName = useTypedEventEmitterState(joinedRoom, RoomEvent.Name, room => room?.name);
const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const joinedRoomName = useTypedEventEmitterState(joinedRoom, RoomEvent.Name, (room) => room?.name);
const name =
joinedRoomName ||
room.name ||
room.canonical_alias ||
room.aliases?.[0] ||
(room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
@ -119,41 +123,45 @@ const Tile: React.FC<ITileProps> = ({
setBusy(true);
ev.preventDefault();
ev.stopPropagation();
onJoinRoomClick().then(() => awaitRoomDownSync(cli, room.room_id)).then(setJoinedRoom).finally(() => {
setBusy(false);
});
onJoinRoomClick()
.then(() => awaitRoomDownSync(cli, room.room_id))
.then(setJoinedRoom)
.finally(() => {
setBusy(false);
});
};
let button: ReactElement;
if (busy) {
button = <AccessibleTooltipButton
disabled={true}
onClick={onJoinClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
title={_t("Joining")}
>
<Spinner w={24} h={24} />
</AccessibleTooltipButton>;
button = (
<AccessibleTooltipButton
disabled={true}
onClick={onJoinClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
title={_t("Joining")}
>
<Spinner w={24} h={24} />
</AccessibleTooltipButton>
);
} else if (joinedRoom) {
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
button = (
<AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{_t("View")}
</AccessibleButton>
);
} else {
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
button = (
<AccessibleButton onClick={onJoinClick} kind="primary" onFocus={onFocus} tabIndex={isActive ? 0 : -1}>
{_t("Join")}
</AccessibleButton>
);
}
let checkbox: ReactElement | undefined;
@ -161,12 +169,16 @@ const Tile: React.FC<ITileProps> = ({
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
checkbox = (
<TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={(ev) => {
ev.stopPropagation();
}}
>
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>
);
}
}
@ -174,13 +186,15 @@ const Tile: React.FC<ITileProps> = ({
if (joinedRoom) {
avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
avatar = (
<BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>
);
}
let description = _t("%(count)s members", { count: room.num_joined_members });
@ -198,63 +212,63 @@ const Tile: React.FC<ITileProps> = ({
let joinedSection: ReactElement | undefined;
if (joinedRoom) {
joinedSection = <div className="mx_SpaceHierarchy_roomTile_joined">
{ _t("Joined") }
</div>;
joinedSection = <div className="mx_SpaceHierarchy_roomTile_joined">{_t("Joined")}</div>;
}
let suggestedSection: ReactElement | undefined;
if (suggested && (!joinedRoom || hasPermissions)) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") }
</InfoTooltip>;
suggestedSection = (
<InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>{_t("Suggested")}</InfoTooltip>
);
}
const content = <React.Fragment>
<div className="mx_SpaceHierarchy_roomTile_item">
<div className="mx_SpaceHierarchy_roomTile_avatar">
{ avatar }
const content = (
<React.Fragment>
<div className="mx_SpaceHierarchy_roomTile_item">
<div className="mx_SpaceHierarchy_roomTile_avatar">{avatar}</div>
<div className="mx_SpaceHierarchy_roomTile_name">
{name}
{joinedSection}
{suggestedSection}
</div>
<div
className="mx_SpaceHierarchy_roomTile_info"
ref={(e) => e && linkifyElement(e)}
onClick={(ev) => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{description}
{topic && " · "}
{topic}
</div>
</div>
<div className="mx_SpaceHierarchy_roomTile_name">
{ name }
{ joinedSection }
{ suggestedSection }
<div className="mx_SpaceHierarchy_actions">
{button}
{checkbox}
</div>
<div
className="mx_SpaceHierarchy_roomTile_info"
ref={e => e && linkifyElement(e)}
onClick={ev => {
// prevent clicks on links from bubbling up to the room tile
if ((ev.target as HTMLElement).tagName === "A") {
ev.stopPropagation();
}
}}
>
{ description }
{ topic && " · " }
{ topic }
</div>
</div>
<div className="mx_SpaceHierarchy_actions">
{ button }
{ checkbox }
</div>
</React.Fragment>;
</React.Fragment>
);
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
})}
onClick={ev => {
ev.stopPropagation();
toggleShowChildren();
}}
/>;
childToggle = (
<div
className={classNames("mx_SpaceHierarchy_subspace_toggle", {
mx_SpaceHierarchy_subspace_toggle_shown: showChildren,
})}
onClick={(ev) => {
ev.stopPropagation();
toggleShowChildren();
}}
/>
);
if (showChildren) {
const onChildrenKeyDown = (e) => {
@ -268,13 +282,11 @@ const Tile: React.FC<ITileProps> = ({
}
};
childSection = <div
className="mx_SpaceHierarchy_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
childSection = (
<div className="mx_SpaceHierarchy_subspace_children" onKeyDown={onChildrenKeyDown} role="group">
{children}
</div>
);
}
onKeyDown = (e) => {
@ -307,27 +319,29 @@ const Tile: React.FC<ITileProps> = ({
};
}
return <li
className="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
mx_SpaceHierarchy_joining: busy,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
return (
<li
className="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</li>;
<AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
mx_SpaceHierarchy_joining: busy,
})}
onClick={hasPermissions && onToggleClick ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{content}
{childToggle}
</AccessibleButton>
{childSection}
</li>
);
};
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void => {
@ -372,15 +386,18 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st
viaServers: Array.from(hierarchy.viaMap.get(roomId) || []),
});
prom.then(() => {
defaultDispatcher.dispatch<JoinRoomReadyPayload>({
action: Action.JoinRoomReady,
roomId,
metricsTrigger: "SpaceHierarchy",
});
}, err => {
SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId);
});
prom.then(
() => {
defaultDispatcher.dispatch<JoinRoomReadyPayload>({
action: Action.JoinRoomReady,
roomId,
metricsTrigger: "SpaceHierarchy",
});
},
(err) => {
SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId);
},
);
return prom;
};
@ -409,10 +426,12 @@ const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom =>
avatar_url: cliRoom.getMxcAvatarUrl(),
canonical_alias: cliRoom.getCanonicalAlias(),
aliases: cliRoom.getAltAliases(),
world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent()
.history_visibility === HistoryVisibility.WorldReadable,
guest_can_join: cliRoom.currentState.getStateEvents(EventType.RoomGuestAccess, "")?.getContent()
.guest_access === GuestAccess.CanJoin,
world_readable:
cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent()
.history_visibility === HistoryVisibility.WorldReadable,
guest_can_join:
cliRoom.currentState.getStateEvents(EventType.RoomGuestAccess, "")?.getContent().guest_access ===
GuestAccess.CanJoin,
num_joined_members: cliRoom.getJoinedMemberCount(),
};
}
@ -434,22 +453,25 @@ export const HierarchyLevel = ({
const space = cli.getRoom(root.room_id);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy(root.children_state, ev => {
const sortedChildren = sortBy(root.children_state, (ev) => {
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room));
}
return result;
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
const [subspaces, childRooms] = sortedChildren.reduce(
(result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room));
}
return result;
},
[[] as IHierarchyRoom[], [] as IHierarchyRoom[]],
);
const newParents = new Set(parents).add(root.room_id);
return <React.Fragment>
{
uniqBy(childRooms, "room_id").map(room => (
return (
<React.Fragment>
{uniqBy(childRooms, "room_id").map((room) => (
<Tile
key={room.room_id}
room={room}
@ -460,44 +482,48 @@ export const HierarchyLevel = ({
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
))
}
))}
{
subspaces.filter(room => !newParents.has(room.room_id)).map(space => (
<Tile
key={space.room_id}
room={space}
numChildRooms={space.children_state.filter(ev => {
const room = hierarchy.roomMap.get(ev.state_key);
return room && roomSet.has(room) && !room.room_type;
}).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(space.room_id)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
>
<HierarchyLevel
root={space}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onJoinRoomClick={onJoinRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))
}
</React.Fragment>;
{subspaces
.filter((room) => !newParents.has(room.room_id))
.map((space) => (
<Tile
key={space.room_id}
room={space}
numChildRooms={
space.children_state.filter((ev) => {
const room = hierarchy.roomMap.get(ev.state_key);
return room && roomSet.has(room) && !room.room_type;
}).length
}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(space.room_id)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
>
<HierarchyLevel
root={space}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onJoinRoomClick={onJoinRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
))}
</React.Fragment>
);
};
const INITIAL_PAGE_SIZE = 20;
export const useRoomHierarchy = (space: Room): {
export const useRoomHierarchy = (
space: Room,
): {
loading: boolean;
rooms?: IHierarchyRoom[];
hierarchy?: RoomHierarchy;
@ -519,18 +545,21 @@ export const useRoomHierarchy = (space: Room): {
}, [space]);
useEffect(resetHierarchy, [resetHierarchy]);
useDispatcher(defaultDispatcher, (payload => {
useDispatcher(defaultDispatcher, (payload) => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setRooms([]); // TODO
resetHierarchy();
}
}));
});
const loadMore = useCallback(async (pageSize?: number) => {
if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport || error) return;
await hierarchy.load(pageSize).catch(setError);
setRooms(hierarchy.rooms);
}, [error, hierarchy]);
const loadMore = useCallback(
async (pageSize?: number) => {
if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport || error) return;
await hierarchy.load(pageSize).catch(setError);
setRooms(hierarchy.rooms);
},
[error, hierarchy],
);
// Only return the hierarchy if it is for the space requested
if (hierarchy?.root !== space) {
@ -587,10 +616,8 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu
const [removing, setRemoving] = useState(false);
const [saving, setSaving] = useState(false);
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]);
const selectedRelations = Array.from(selected.keys()).flatMap((parentId) => {
return [...selected.get(parentId).values()].map((childId) => [parentId, childId]);
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
@ -614,78 +641,79 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu
buttonText = selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested");
}
return <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
const userId = cli.getUserId();
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
return (
<>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
const userId = cli.getUserId();
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
// remove the child->parent relation too, if we have permission to.
const childRoom = cli.getRoom(childId);
const parentRelation = childRoom?.currentState.getStateEvents(EventType.SpaceParent, parentId);
if (childRoom?.currentState.maySendStateEvent(EventType.SpaceParent, userId) &&
Array.isArray(parentRelation?.getContent().via)
) {
await cli.sendStateEvent(childId, EventType.SpaceParent, {}, parentId);
// remove the child->parent relation too, if we have permission to.
const childRoom = cli.getRoom(childId);
const parentRelation = childRoom?.currentState.getStateEvents(
EventType.SpaceParent,
parentId,
);
if (
childRoom?.currentState.maySendStateEvent(EventType.SpaceParent, userId) &&
Array.isArray(parentRelation?.getContent().via)
) {
await cli.sendStateEvent(childId, EventType.SpaceParent, {}, parentId);
}
hierarchy.removeRelation(parentId, childId);
}
hierarchy.removeRelation(parentId, childId);
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{removing ? _t("Removing...") : _t("Remove")}
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ buttonText }
</Button>
</>;
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{buttonText}
</Button>
</>
);
};
const SpaceHierarchy = ({
space,
initialText = "",
showRoom,
additionalButtons,
}: IProps) => {
const SpaceHierarchy = ({ space, initialText = "", showRoom, additionalButtons }: IProps) => {
const cli = useContext(MatrixClientContext);
const [query, setQuery] = useState(initialText);
@ -698,24 +726,24 @@ const SpaceHierarchy = ({
const lcQuery = query.toLowerCase().trim();
if (!lcQuery) return new Set(rooms);
const directMatches = rooms.filter(r => {
const directMatches = rooms.filter((r) => {
return r.name?.toLowerCase().includes(lcQuery) || r.topic?.toLowerCase().includes(lcQuery);
});
// Walk back up the tree to find all parents of the direct matches to show their place in the hierarchy
const visited = new Set<string>();
const queue = [...directMatches.map(r => r.room_id)];
const queue = [...directMatches.map((r) => r.room_id)];
while (queue.length) {
const roomId = queue.pop();
visited.add(roomId);
hierarchy.backRefs.get(roomId)?.forEach(parentId => {
hierarchy.backRefs.get(roomId)?.forEach((parentId) => {
if (!visited.has(parentId)) {
queue.push(parentId);
}
});
}
return new Set(rooms.filter(r => visited.has(r.room_id)));
return new Set(rooms.filter((r) => visited.has(r.room_id)));
}, [rooms, hierarchy, query]);
const [error, setError] = useState("");
@ -727,15 +755,12 @@ const SpaceHierarchy = ({
const loaderRef = useIntersectionObserver(loadMore);
if (!loading && hierarchy!.noSupport) {
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
return <p>{_t("Your server does not support showing space hierarchies.")}</p>;
}
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
if (
action === KeyBindingAction.ArrowDown &&
ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")
) {
if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
state.refs[0]?.current?.focus();
}
};
@ -757,89 +782,100 @@ const SpaceHierarchy = ({
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
};
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content: JSX.Element;
if (!hierarchy || (loading && !rooms?.length)) {
content = <Spinner />;
} else {
const hasPermissions = space?.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
return (
<RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{({ onKeyDownHandler }) => {
let content: JSX.Element;
if (!hierarchy || (loading && !rooms?.length)) {
content = <Spinner />;
} else {
const hasPermissions =
space?.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
let results: JSX.Element;
if (filteredRoomSet.size) {
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)}
onJoinRoomClick={(roomId) => joinRoom(cli, hierarchy, roomId)}
/>
</>;
} else if (!hierarchy.canLoadMore) {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
let loader: JSX.Element;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4 className="mx_SpaceHierarchy_listHeader_header">
{ query.trim() ? _t("Results") : _t("Rooms and spaces") }
</h4>
<div className="mx_SpaceHierarchy_listHeader_buttons">
{ additionalButtons }
{ hasPermissions && (
<ManageButtons
let results: JSX.Element;
if (filteredRoomSet.size) {
results = (
<>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
selected={selected}
setSelected={setSelected}
setError={setError}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)}
onJoinRoomClick={(roomId) => joinRoom(cli, hierarchy, roomId)}
/>
) }
</div>
</div>
{ errorText && <div className="mx_SpaceHierarchy_error">
{ errorText }
</div> }
<ul
className="mx_SpaceHierarchy_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{ results }
</ul>
{ loader }
</>;
}
</>
);
} else if (!hierarchy.canLoadMore) {
results = (
<div className="mx_SpaceHierarchy_noResults">
<h3>{_t("No results found")}</h3>
<div>{_t("You may want to try a different search or check for typos.")}</div>
</div>
);
}
return <>
<SearchBox
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
let loader: JSX.Element;
if (hierarchy.canLoadMore) {
loader = (
<div ref={loaderRef}>
<Spinner />
</div>
);
}
{ content }
</>;
} }
</RovingTabIndexProvider>;
content = (
<>
<div className="mx_SpaceHierarchy_listHeader">
<h4 className="mx_SpaceHierarchy_listHeader_header">
{query.trim() ? _t("Results") : _t("Rooms and spaces")}
</h4>
<div className="mx_SpaceHierarchy_listHeader_buttons">
{additionalButtons}
{hasPermissions && (
<ManageButtons
hierarchy={hierarchy}
selected={selected}
setSelected={setSelected}
setError={setError}
/>
)}
</div>
</div>
{errorText && <div className="mx_SpaceHierarchy_error">{errorText}</div>}
<ul
className="mx_SpaceHierarchy_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{results}
</ul>
{loader}
</>
);
}
return (
<>
<SearchBox
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{content}
</>
);
}}
</RovingTabIndexProvider>
);
};
export default SpaceHierarchy;

View file

@ -74,7 +74,7 @@ import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
import MainSplit from './MainSplit';
import MainSplit from "./MainSplit";
import RightPanel from "./RightPanel";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
@ -113,93 +113,101 @@ const SpaceLandingAddButton = ({ space }) => {
let contextMenu: JSX.Element | null = null;
if (menuDisplayed) {
const rect = handle.current.getBoundingClientRect();
contextMenu = <IconizedContextMenu
left={rect.left + window.scrollX + 0}
top={rect.bottom + window.scrollY + 8}
chevronFace={ChevronFace.None}
onFinished={closeMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
{ canCreateRoom && <>
<IconizedContextMenuOption
label={_t("New room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
contextMenu = (
<IconizedContextMenu
left={rect.left + window.scrollX + 0}
top={rect.bottom + window.scrollY + 8}
chevronFace={ChevronFace.None}
onFinished={closeMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
{canCreateRoom && (
<>
<IconizedContextMenuOption
label={_t("New room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
PosthogTrackers.trackInteraction("WebSpaceHomeCreateRoomButton", e);
if (await showCreateNewRoom(space)) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}
/>
{ videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
PosthogTrackers.trackInteraction("WebSpaceHomeCreateRoomButton", e);
if (await showCreateNewRoom(space)) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
if (
await showCreateNewRoom(
space,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
)
) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}
>
<BetaPill />
</IconizedContextMenuOption>
) }
</> }
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconAddExistingRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showAddExistingRooms(space);
}}
/>
{ canCreateSpace &&
if (
await showCreateNewRoom(
space,
elementCallVideoRoomsEnabled
? RoomType.UnstableCall
: RoomType.ElementVideo,
)
) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
)}
<IconizedContextMenuOption
label={_t("Add space")}
iconClassName="mx_RoomList_iconPlus"
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconAddExistingRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewSubspace(space);
showAddExistingRooms(space);
}}
>
<BetaPill />
</IconizedContextMenuOption>
}
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
/>
{canCreateSpace && (
<IconizedContextMenuOption
label={_t("Add space")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewSubspace(space);
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return <>
<ContextMenuButton
kind="primary"
inputRef={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("Add")}
>
{ _t("Add") }
</ContextMenuButton>
{ contextMenu }
</>;
return (
<>
<ContextMenuButton
kind="primary"
inputRef={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("Add")}
>
{_t("Add")}
</ContextMenuButton>
{contextMenu}
</>
);
};
const SpaceLanding = ({ space }: { space: Room }) => {
@ -208,8 +216,9 @@ const SpaceLanding = ({ space }: { space: Room }) => {
const userId = cli.getUserId();
const storeIsShowingSpaceMembers = useCallback(
() => RightPanelStore.instance.isOpenForRoom(space.roomId)
&& RightPanelStore.instance.currentCardForRoom(space.roomId)?.phase === RightPanelPhases.SpaceMemberList,
() =>
RightPanelStore.instance.isOpenForRoom(space.roomId) &&
RightPanelStore.instance.currentCardForRoom(space.roomId)?.phase === RightPanelPhases.SpaceMemberList,
[space.roomId],
);
const isShowingMembers = useEventEmitterState(RightPanelStore.instance, UPDATE_EVENT, storeIsShowingSpaceMembers);
@ -224,13 +233,13 @@ const SpaceLanding = ({ space }: { space: Room }) => {
showSpaceInvite(space);
}}
>
{ _t("Invite") }
{_t("Invite")}
</AccessibleButton>
);
}
const hasAddRoomPermissions = myMembership === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const hasAddRoomPermissions =
myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let addRoomButton;
if (hasAddRoomPermissions) {
@ -239,49 +248,53 @@ const SpaceLanding = ({ space }: { space: Room }) => {
let settingsButton;
if (shouldShowSpaceSettings(space)) {
settingsButton = <AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => {
showSpaceSettings(space);
}}
title={_t("Settings")}
/>;
settingsButton = (
<AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => {
showSpaceSettings(space);
}}
title={_t("Settings")}
/>
);
}
const onMembersClick = () => {
RightPanelStore.instance.setCard({ phase: RightPanelPhases.SpaceMemberList });
};
return <div className="mx_SpaceRoomView_landing">
<div className="mx_SpaceRoomView_landing_header">
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<SpaceFeedbackPrompt />
</div>
<div className="mx_SpaceRoomView_landing_name">
<RoomName room={space}>
{ (name) => {
const tags = { name: () => <h1>{ name }</h1> };
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
} }
</RoomName>
</div>
<div className="mx_SpaceRoomView_landing_infoBar">
<RoomInfoLine room={space} />
<div className="mx_SpaceRoomView_landing_infoBar_interactive">
<RoomFacePile
room={space}
onlyKnownUsers={false}
numShown={7}
onClick={isShowingMembers ? undefined : onMembersClick}
/>
{ inviteButton }
{ settingsButton }
return (
<div className="mx_SpaceRoomView_landing">
<div className="mx_SpaceRoomView_landing_header">
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<SpaceFeedbackPrompt />
</div>
</div>
<RoomTopic room={space} className="mx_SpaceRoomView_landing_topic" />
<div className="mx_SpaceRoomView_landing_name">
<RoomName room={space}>
{(name) => {
const tags = { name: () => <h1>{name}</h1> };
return _t("Welcome to <name/>", {}, tags) as JSX.Element;
}}
</RoomName>
</div>
<div className="mx_SpaceRoomView_landing_infoBar">
<RoomInfoLine room={space} />
<div className="mx_SpaceRoomView_landing_infoBar_interactive">
<RoomFacePile
room={space}
onlyKnownUsers={false}
numShown={7}
onClick={isShowingMembers ? undefined : onMembersClick}
/>
{inviteButton}
{settingsButton}
</div>
</div>
<RoomTopic room={space} className="mx_SpaceRoomView_landing_topic" />
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>;
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>
);
};
const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
@ -292,18 +305,20 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
const fields = new Array(numFields).fill(0).map((x, i) => {
const name = "roomName" + i;
return <Field
key={name}
name={name}
type="text"
label={_t("Room name")}
placeholder={placeholders[i]}
value={roomNames[i]}
onChange={ev => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
disabled={busy}
autoComplete="off"
/>;
return (
<Field
key={name}
name={name}
type="text"
label={_t("Room name")}
placeholder={placeholders[i]}
value={roomNames[i]}
onChange={(ev) => setRoomName(i, ev.target.value)}
autoFocus={i === 2}
disabled={busy}
autoComplete="off"
/>
);
});
const onNextClick = async (ev: ButtonEvent) => {
@ -313,22 +328,24 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setBusy(true);
try {
const isPublic = space.getJoinRule() === JoinRule.Public;
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
const roomIds = await Promise.all(filteredRoomNames.map(name => {
return createRoom({
createOpts: {
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
name,
},
spinner: false,
encryption: false,
andView: false,
inlineErrors: true,
parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined,
suggested: true,
});
}));
const filteredRoomNames = roomNames.map((name) => name.trim()).filter(Boolean);
const roomIds = await Promise.all(
filteredRoomNames.map((name) => {
return createRoom({
createOpts: {
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
name,
},
spinner: false,
encryption: false,
andView: false,
inlineErrors: true,
parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined,
suggested: true,
});
}),
);
onFinished(roomIds[0]);
} catch (e) {
logger.error("Failed to create initial space rooms", e);
@ -342,55 +359,61 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
onFinished();
};
let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) {
if (roomNames.some((name) => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Creating rooms...") : _t("Continue");
}
return <div>
<h1>{ title }</h1>
<div className="mx_SpaceRoomView_description">{ description }</div>
return (
<div>
<h1>{title}</h1>
<div className="mx_SpaceRoomView_description">{description}</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
{ fields }
</form>
{error && <div className="mx_SpaceRoomView_errorText">{error}</div>}
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
{fields}
</form>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
element="input"
type="submit"
form="mx_SpaceSetupFirstRooms"
value={buttonLabel}
/>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
element="input"
type="submit"
form="mx_SpaceSetupFirstRooms"
value={buttonLabel}
/>
</div>
</div>
</div>;
);
};
const SpaceAddExistingRooms = ({ space, onFinished }) => {
return <div>
<h1>{ _t("What do you want to organise?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Pick rooms or conversations to add. This is just a space for you, " +
"no one will be informed. You can add more later.") }
</div>
return (
<div>
<h1>{_t("What do you want to organise?")}</h1>
<div className="mx_SpaceRoomView_description">
{_t(
"Pick rooms or conversations to add. This is just a space for you, " +
"no one will be informed. You can add more later.",
)}
</div>
<AddExistingToSpace
space={space}
emptySelectionButton={
<AccessibleButton kind="primary" onClick={onFinished}>
{ _t("Skip for now") }
</AccessibleButton>
}
filterPlaceholder={_t("Search for rooms or spaces")}
onFinished={onFinished}
roomsRenderer={defaultRoomsRenderer}
dmsRenderer={defaultDmsRenderer}
/>
</div>;
<AddExistingToSpace
space={space}
emptySelectionButton={
<AccessibleButton kind="primary" onClick={onFinished}>
{_t("Skip for now")}
</AccessibleButton>
}
filterPlaceholder={_t("Search for rooms or spaces")}
onFinished={onFinished}
roomsRenderer={defaultRoomsRenderer}
dmsRenderer={defaultDmsRenderer}
/>
</div>
);
};
interface ISpaceSetupPublicShareProps extends Pick<IProps & IState, "justCreatedOpts" | "space" | "firstRoomId"> {
@ -398,56 +421,68 @@ interface ISpaceSetupPublicShareProps extends Pick<IProps & IState, "justCreated
}
const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, firstRoomId }: ISpaceSetupPublicShareProps) => {
return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("It's just you at the moment, it will be even better with others.") }
</div>
return (
<div className="mx_SpaceRoomView_publicShare">
<h1>
{_t("Share %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
})}
</h1>
<div className="mx_SpaceRoomView_description">
{_t("It's just you at the moment, it will be even better with others.")}
</div>
<SpacePublicShare space={space} />
<SpacePublicShare space={space} />
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" onClick={onFinished}>
{ firstRoomId ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" onClick={onFinished}>
{firstRoomId ? _t("Go to my first room") : _t("Go to my space")}
</AccessibleButton>
</div>
</div>
</div>;
);
};
const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
return <div className="mx_SpaceRoomView_privateScope">
<h1>{ _t("Who are you working with?") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access to %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }
</div>
return (
<div className="mx_SpaceRoomView_privateScope">
<h1>{_t("Who are you working with?")}</h1>
<div className="mx_SpaceRoomView_description">
{_t("Make sure the right people have access to %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
})}
</div>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_justMeButton"
onClick={() => { onFinished(false); }}
>
<h3>{ _t("Just me") }</h3>
<div>{ _t("A private space to organise your rooms") }</div>
</AccessibleButton>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_meAndMyTeammatesButton"
onClick={() => { onFinished(true); }}
>
<h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div>
</AccessibleButton>
</div>;
<AccessibleButton
className="mx_SpaceRoomView_privateScope_justMeButton"
onClick={() => {
onFinished(false);
}}
>
<h3>{_t("Just me")}</h3>
<div>{_t("A private space to organise your rooms")}</div>
</AccessibleButton>
<AccessibleButton
className="mx_SpaceRoomView_privateScope_meAndMyTeammatesButton"
onClick={() => {
onFinished(true);
}}
>
<h3>{_t("Me and my teammates")}</h3>
<div>{_t("A private space for you and your teammates")}</div>
</AccessibleButton>
</div>
);
};
const validateEmailRules = withValidation({
rules: [{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
}],
rules: [
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
@ -458,19 +493,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
const [emailAddresses, setEmailAddress] = useStateArray(numFields, "");
const fields = new Array(numFields).fill(0).map((x, i) => {
const name = "emailAddress" + i;
return <Field
key={name}
name={name}
type="text"
label={_t("Email address")}
placeholder={_t("Email")}
value={emailAddresses[i]}
onChange={ev => setEmailAddress(i, ev.target.value)}
ref={fieldRefs[i]}
onValidate={validateEmailRules}
autoFocus={i === 0}
disabled={busy}
/>;
return (
<Field
key={name}
name={name}
type="text"
label={_t("Email address")}
placeholder={_t("Email")}
value={emailAddresses[i]}
onChange={(ev) => setEmailAddress(i, ev.target.value)}
ref={fieldRefs[i]}
onValidate={validateEmailRules}
autoFocus={i === 0}
disabled={busy}
/>
);
});
const onNextClick = async (ev) => {
@ -480,7 +517,8 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
for (const fieldRef of fieldRefs) {
const valid = await fieldRef.current.validate({ allowEmpty: true });
if (valid === false) { // true/null are allowed
if (valid === false) {
// true/null are allowed
fieldRef.current.focus();
fieldRef.current.validate({ allowEmpty: true, focused: true });
return;
@ -488,16 +526,18 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
}
setBusy(true);
const targetIds = emailAddresses.map(name => name.trim()).filter(Boolean);
const targetIds = emailAddresses.map((name) => name.trim()).filter(Boolean);
try {
const result = await inviteMultipleToRoom(space.roomId, targetIds);
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error");
const failedUsers = Object.keys(result.states).filter((a) => result.states[a] === "error");
if (failedUsers.length > 0) {
logger.log("Failed to invite users to space: ", result);
setError(_t("Failed to invite the following users to your space: %(csvUsers)s", {
csvUsers: failedUsers.join(", "),
}));
setError(
_t("Failed to invite the following users to your space: %(csvUsers)s", {
csvUsers: failedUsers.join(", "),
}),
);
} else {
onFinished();
}
@ -513,53 +553,61 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
onFinished();
};
let buttonLabel = _t("Skip for now");
if (emailAddresses.some(name => name.trim())) {
if (emailAddresses.some((name) => name.trim())) {
onClick = onNextClick;
buttonLabel = busy ? _t("Inviting...") : _t("Continue");
}
return <div className="mx_SpaceRoomView_inviteTeammates">
<h1>{ _t("Invite your teammates") }</h1>
<div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access. You can invite more later.") }
</div>
return (
<div className="mx_SpaceRoomView_inviteTeammates">
<h1>{_t("Invite your teammates")}</h1>
<div className="mx_SpaceRoomView_description">
{_t("Make sure the right people have access. You can invite more later.")}
</div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
{ _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>,
link: () => <a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
app.element.io
</a>,
}) }
</div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
{_t(
"<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.",
{},
{
b: (sub) => <b>{sub}</b>,
link: () => (
<a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
app.element.io
</a>
),
},
)}
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
{ fields }
</form>
{error && <div className="mx_SpaceRoomView_errorText">{error}</div>}
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
{fields}
</form>
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
<AccessibleButton
className="mx_SpaceRoomView_inviteTeammates_inviteDialogButton"
onClick={() => showRoomInviteDialog(space.roomId)}
>
{ _t("Invite by username") }
</AccessibleButton>
</div>
<div className="mx_SpaceRoomView_inviteTeammates_buttons">
<AccessibleButton
className="mx_SpaceRoomView_inviteTeammates_inviteDialogButton"
onClick={() => showRoomInviteDialog(space.roomId)}
>
{_t("Invite by username")}
</AccessibleButton>
</div>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
element="input"
type="submit"
form="mx_SpaceSetupPrivateInvite"
value={buttonLabel}
/>
<div className="mx_SpaceRoomView_buttons">
<AccessibleButton
kind="primary"
disabled={busy}
onClick={onClick}
element="input"
type="submit"
form="mx_SpaceSetupPrivateInvite"
value={buttonLabel}
/>
</div>
</div>
</div>;
);
};
export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
@ -578,8 +626,10 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
const showSetup = this.props.justCreatedOpts && context.getUserId() === this.creator;
if (showSetup) {
phase = this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat
? Phase.PublicCreateRooms : Phase.PrivateScope;
phase =
this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat
? Phase.PublicCreateRooms
: Phase.PrivateScope;
}
this.state = {
@ -667,76 +717,97 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
if (this.state.myMembership === "join") {
return <SpaceLanding space={this.props.space} />;
} else {
return <RoomPreviewCard
room={this.props.space}
onJoinButtonClicked={this.props.onJoinButtonClicked}
onRejectButtonClicked={this.props.onRejectButtonClicked}
/>;
return (
<RoomPreviewCard
room={this.props.space}
onJoinButtonClicked={this.props.onJoinButtonClicked}
onRejectButtonClicked={this.props.onRejectButtonClicked}
/>
);
}
case Phase.PublicCreateRooms:
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What are some things you want to discuss in %(spaceName)s?", {
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
})}
description={<>
{ _t("Let's create a room for each of them.") }
<br />
{ _t("You can add more later too, including already existing ones.") }
</>}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })}
/>;
return (
<SpaceSetupFirstRooms
space={this.props.space}
title={_t("What are some things you want to discuss in %(spaceName)s?", {
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
})}
description={
<>
{_t("Let's create a room for each of them.")}
<br />
{_t("You can add more later too, including already existing ones.")}
</>
}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PublicShare, firstRoomId })}
/>
);
case Phase.PublicShare:
return <SpaceSetupPublicShare
justCreatedOpts={this.props.justCreatedOpts}
space={this.props.space}
onFinished={this.goToFirstRoom}
firstRoomId={this.state.firstRoomId}
/>;
return (
<SpaceSetupPublicShare
justCreatedOpts={this.props.justCreatedOpts}
space={this.props.space}
onFinished={this.goToFirstRoom}
firstRoomId={this.state.firstRoomId}
/>
);
case Phase.PrivateScope:
return <SpaceSetupPrivateScope
space={this.props.space}
justCreatedOpts={this.props.justCreatedOpts}
onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateCreateRooms : Phase.PrivateExistingRooms });
}}
/>;
return (
<SpaceSetupPrivateScope
space={this.props.space}
justCreatedOpts={this.props.justCreatedOpts}
onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateCreateRooms : Phase.PrivateExistingRooms });
}}
/>
);
case Phase.PrivateInvite:
return <SpaceSetupPrivateInvite
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
return (
<SpaceSetupPrivateInvite
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>
);
case Phase.PrivateCreateRooms:
return <SpaceSetupFirstRooms
space={this.props.space}
title={_t("What projects are your team working on?")}
description={<>
{ _t("We'll create rooms for each of them.") }
<br />
{ _t("You can add more later too, including already existing ones.") }
</>}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PrivateInvite, firstRoomId })}
/>;
return (
<SpaceSetupFirstRooms
space={this.props.space}
title={_t("What projects are your team working on?")}
description={
<>
{_t("We'll create rooms for each of them.")}
<br />
{_t("You can add more later too, including already existing ones.")}
</>
}
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PrivateInvite, firstRoomId })}
/>
);
case Phase.PrivateExistingRooms:
return <SpaceAddExistingRooms
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>;
return (
<SpaceAddExistingRooms
space={this.props.space}
onFinished={() => this.setState({ phase: Phase.Landing })}
/>
);
}
}
render() {
const rightPanel = this.state.showRightPanel && this.state.phase === Phase.Landing
? <RightPanel room={this.props.space} resizeNotifier={this.props.resizeNotifier} />
: null;
const rightPanel =
this.state.showRightPanel && this.state.phase === Phase.Landing ? (
<RightPanel room={this.props.space} resizeNotifier={this.props.resizeNotifier} />
) : null;
return <main className="mx_SpaceRoomView">
<ErrorBoundary>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
{ this.renderBody() }
</MainSplit>
</ErrorBoundary>
</main>;
return (
<main className="mx_SpaceRoomView">
<ErrorBoundary>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
{this.renderBody()}
</MainSplit>
</ErrorBoundary>
</main>
);
}
}

View file

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

View file

@ -20,8 +20,8 @@ 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 { _t } from "../../languageHandler";
import AutoHideScrollbar from "./AutoHideScrollbar";
import AccessibleButton from "../views/elements/AccessibleButton";
import { PosthogScreenTracker, ScreenName } from "../../PosthogTrackers";
@ -47,8 +47,8 @@ export class Tab {
}
export enum TabLocation {
LEFT = 'left',
TOP = 'top',
LEFT = "left",
TOP = "top",
}
interface IProps {
@ -67,7 +67,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
constructor(props: IProps) {
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,
};
@ -78,7 +78,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
};
private getTabById(id: string): Tab | undefined {
return this.props.tabs.find(tab => tab.id === id);
return this.props.tabs.find((tab) => tab.id === id);
}
/**
@ -116,10 +116,8 @@ export default class TabbedView extends React.Component<IProps, IState> {
onClick={onClickHandler}
data-testid={`settings-tab-${tab.id}`}
>
{ tabIcon }
<span className="mx_TabbedView_tabLabel_text">
{ label }
</span>
{tabIcon}
<span className="mx_TabbedView_tabLabel_text">{label}</span>
</AccessibleButton>
);
}
@ -127,31 +125,27 @@ export default class TabbedView extends React.Component<IProps, IState> {
private renderTabPanel(tab: Tab): React.ReactNode {
return (
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
<AutoHideScrollbar className='mx_TabbedView_tabPanelContent'>
{ tab.body }
</AutoHideScrollbar>
<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,
});
return (
<div className={tabbedViewClasses}>
<PosthogScreenTracker screenName={tab?.screenName ?? this.props.screenName} />
<div className="mx_TabbedView_tabLabels">
{ labels }
</div>
{ panel }
<div className="mx_TabbedView_tabLabels">{labels}</div>
{panel}
</div>
);
}

View file

@ -14,32 +14,32 @@ 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 } from 'matrix-js-sdk/src/models/thread';
import { Room } from 'matrix-js-sdk/src/models/room';
import React, { useContext, useEffect, useRef, useState } from "react";
import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set";
import { Thread } from "matrix-js-sdk/src/models/thread";
import { Room } from "matrix-js-sdk/src/models/room";
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 { 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 Spinner from "../views/elements/Spinner";
import Heading from '../views/typography/Heading';
import Heading from "../views/typography/Heading";
import { shouldShowFeedback } from "../../utils/Feedback";
interface IProps {
@ -51,7 +51,7 @@ interface IProps {
export enum ThreadFilterType {
"My",
"All"
"All",
}
type ThreadPanelHeaderOption = {
@ -69,17 +69,19 @@ export const ThreadPanelHeaderFilterOptionItem = ({
onClick: () => void;
isSelected: boolean;
}) => {
return <MenuItemRadio
active={isSelected}
className="mx_ThreadPanel_Header_FilterOptionItem"
onClick={onClick}
>
<span>{ label }</span>
<span>{ description }</span>
</MenuItemRadio>;
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 = ({
filterOption,
setFilterOption,
empty,
}: {
filterOption: ThreadFilterType;
setFilterOption: (filterOption: ThreadFilterType) => void;
empty: boolean;
@ -88,7 +90,7 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption, empty }: {
const options: readonly ThreadPanelHeaderOption[] = [
{
label: _t("All threads"),
description: _t('Shows all threads from current room'),
description: _t("Shows all threads from current room"),
key: ThreadFilterType.All,
},
{
@ -98,43 +100,53 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption, empty }: {
},
];
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="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>
);
};
interface EmptyThreadIProps {
@ -146,46 +158,56 @@ interface EmptyThreadIProps {
const EmptyThread: React.FC<EmptyThreadIProps> = ({ hasThreads, filterOption, showAllThreadsCallback }) => {
let body: JSX.Element;
if (hasThreads) {
body = <>
<p>
{ _t("Reply to an ongoing thread or use “%(replyInThread)s” "
+ "when hovering over a message to start a new one.", {
replyInThread: _t("Reply in thread"),
}) }
</p>
<p>
{ /* Always display that paragraph to prevent layout shift when hiding the button */ }
{ (filterOption === ThreadFilterType.My)
? <button onClick={showAllThreadsCallback}>{ _t("Show all threads") }</button>
: <>&nbsp;</>
}
</p>
</>;
body = (
<>
<p>
{_t(
"Reply to an ongoing thread or use “%(replyInThread)s” " +
"when hovering over a message to start a new one.",
{
replyInThread: _t("Reply in thread"),
},
)}
</p>
<p>
{/* Always display that paragraph to prevent layout shift when hiding the button */}
{filterOption === ThreadFilterType.My ? (
<button onClick={showAllThreadsCallback}>{_t("Show all threads")}</button>
) : (
<>&nbsp;</>
)}
</p>
</>
);
} 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 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>
</>
);
}
return <aside className="mx_ThreadPanel_empty">
<div className="mx_ThreadPanel_largeIcon" />
<h2>{ _t("Keep discussions organised with threads") }</h2>
{ body }
</aside>;
return (
<aside className="mx_ThreadPanel_empty">
<div className="mx_ThreadPanel_largeIcon" />
<h2>{_t("Keep discussions organised with threads")}</h2>
{body}
</aside>
);
};
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>();
@ -198,12 +220,14 @@ const ThreadPanel: React.FC<IProps> = ({
useEffect(() => {
const room = mxClient.getRoom(roomId);
room.createThreadsTimelineSets().then(() => {
return room.fetchRoomThreads();
}).then(() => {
setFilterOption(ThreadFilterType.All);
setRoom(room);
});
room.createThreadsTimelineSets()
.then(() => {
return room.fetchRoomThreads();
})
.then(() => {
setFilterOption(ThreadFilterType.All);
setRoom(room);
});
}, [mxClient, roomId]);
useEffect(() => {
@ -222,53 +246,66 @@ const ThreadPanel: React.FC<IProps> = ({
}
}, [timelineSet, timelinePanel]);
const openFeedback = shouldShowFeedback() ? () => {
Modal.createDialog(BetaFeedbackDialog, {
featureId: "feature_thread",
});
} : null;
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={!timelineSet?.getLiveTimeline()?.getEvents().length}
/>
{ openFeedback && _t("<a>Give feedback</a>", {}, {
a: sub =>
<AccessibleButton kind="link_inline" onClick={openFeedback}>{ sub }</AccessibleButton>,
}) }
</>}
}
footer={
<>
<BetaPill
tooltipTitle={_t("Threads are a beta feature")}
tooltipCaption={_t("Click for more info")}
onClick={() => {
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}
/>
{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)}
<Measured sensor={card.current} onMeasurement={setNarrow} />
{timelineSet ? (
<TimelinePanel
key={timelineSet.getFilter()?.filterId ?? roomId + ":" + filterOption}
ref={timelinePanel}
showReadReceipts={false} // No RR support in thread's list
manageReadReceipts={false} // No RR support in thread's list
@ -276,11 +313,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={room.threadsTimelineSets?.[0]?.getLiveTimeline().getEvents().length > 0}
filterOption={filterOption}
showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)}
/>
}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
@ -291,10 +330,11 @@ const ThreadPanel: React.FC<IProps> = ({
permalinkCreator={permalinkCreator}
disableGrouping={true}
/>
: <div className="mx_AutoHideScrollbar">
) : (
<div className="mx_AutoHideScrollbar">
<Spinner />
</div>
}
)}
</BaseCard>
</RoomContext.Provider>
);

View file

@ -14,45 +14,45 @@ 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 } 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 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;
@ -99,7 +99,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 }),
);
}
@ -169,15 +169,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) : null,
},
() => {
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,
@ -220,10 +223,13 @@ export default class ThreadView extends React.Component<IProps, IState> {
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),
);
}
};
@ -249,8 +255,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,
@ -273,10 +282,13 @@ export default class ThreadView extends React.Component<IProps, IState> {
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;
}
@ -305,15 +317,15 @@ 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,
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 +333,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="h4" className="mx_BaseCard_header_title_heading">
{_t("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;
const highlightedEventId = this.props.isInitialEventHighlighted ? this.props.initialEvent?.getId() : null;
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={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}
/>
</>
);
} 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 +418,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>
<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>
);

View file

@ -14,35 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, ReactNode } from 'react';
import React, { createRef, ReactNode } from "react";
import ReactDOM from "react-dom";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { EventTimelineSet, IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set";
import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
import { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event';
import { SyncState } from 'matrix-js-sdk/src/sync';
import { RoomMember, RoomMemberEvent } from 'matrix-js-sdk/src/models/room-member';
import { debounce, throttle } from 'lodash';
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { SyncState } from "matrix-js-sdk/src/sync";
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/models/room-member";
import { debounce, throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread";
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
import { MatrixError } from 'matrix-js-sdk/src/http-api';
import { ReadReceipt } from 'matrix-js-sdk/src/models/read-receipt';
import { MatrixError } from "matrix-js-sdk/src/http-api";
import { ReadReceipt } from "matrix-js-sdk/src/models/read-receipt";
import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/enums/Layout";
import { _t } from '../../languageHandler';
import { _t } from "../../languageHandler";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import UserActivity from "../../UserActivity";
import Modal from "../../Modal";
import dis from "../../dispatcher/dispatcher";
import { Action } from '../../dispatcher/actions';
import Timer from '../../utils/Timer';
import shouldHideEvent from '../../shouldHideEvent';
import { Action } from "../../dispatcher/actions";
import Timer from "../../utils/Timer";
import shouldHideEvent from "../../shouldHideEvent";
import { arrayFastClone } from "../../utils/arrays";
import MessagePanel from "./MessagePanel";
import { IScrollState } from "./ScrollPanel";
@ -50,8 +50,8 @@ import { ActionPayload } from "../../dispatcher/payloads";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Spinner from "../views/elements/Spinner";
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import ErrorDialog from '../views/dialogs/ErrorDialog';
import EditorStateTransfer from "../../utils/EditorStateTransfer";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "./LegacyCallEventGrouper";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
@ -232,7 +232,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// By default, disable the timelineCap in favour of unpaginating based on
// event tile heights. (See _unpaginateEvents)
timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel',
className: "mx_RoomView_messagePanel",
sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
disableGrouping: false,
@ -262,7 +262,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// but for now we just do it per room for simplicity.
let initialReadMarker: string | null = null;
if (this.props.manageReadMarkers) {
const readmarker = this.props.timelineSet.room.getAccountData('m.fully_read');
const readmarker = this.props.timelineSet.room.getAccountData("m.fully_read");
if (readmarker) {
initialReadMarker = readmarker.getContent().event_id;
} else {
@ -343,8 +343,10 @@ class TimelinePanel extends React.Component<IProps, IState> {
const differentHighlightedEventId = prevProps.highlightedEventId != this.props.highlightedEventId;
const differentAvoidJump = prevProps.eventScrollIntoView && !this.props.eventScrollIntoView;
if (differentEventId || differentHighlightedEventId || differentAvoidJump) {
logger.log(`TimelinePanel switching to eventId ${this.props.eventId} (was ${prevProps.eventId}), ` +
`scrollIntoView: ${this.props.eventScrollIntoView} (was ${prevProps.eventScrollIntoView})`);
logger.log(
`TimelinePanel switching to eventId ${this.props.eventId} (was ${prevProps.eventId}), ` +
`scrollIntoView: ${this.props.eventScrollIntoView} (was ${prevProps.eventScrollIntoView})`,
);
this.initTimeline(this.props);
}
}
@ -408,9 +410,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (messagePanel) {
const messagePanelNode = ReactDOM.findDOMNode(messagePanel) as Element;
if (messagePanelNode) {
const actuallyRenderedEvents = messagePanelNode.querySelectorAll('[data-event-id]');
const actuallyRenderedEvents = messagePanelNode.querySelectorAll("[data-event-id]");
renderedEventIds = [...actuallyRenderedEvents].map((renderedEvent) => {
return renderedEvent.getAttribute('data-event-id');
return renderedEvent.getAttribute("data-event-id");
});
}
}
@ -439,12 +441,16 @@ class TimelinePanel extends React.Component<IProps, IState> {
// Serialize all threads in the room from theadId -> event IDs in the thread
room.getThreads().forEach((thread) => {
serializedThreadsMap[thread.id] = {
events: thread.events.map(ev => ev.getId()),
events: thread.events.map((ev) => ev.getId()),
numTimelines: thread.timelineSet.getTimelines().length,
liveTimeline: thread.timelineSet.getLiveTimeline().getEvents().length,
prevTimeline: thread.timelineSet.getLiveTimeline().getNeighbouringTimeline(Direction.Backward)
prevTimeline: thread.timelineSet
.getLiveTimeline()
.getNeighbouringTimeline(Direction.Backward)
?.getEvents().length,
nextTimeline: thread.timelineSet.getLiveTimeline().getNeighbouringTimeline(Direction.Forward)
nextTimeline: thread.timelineSet
.getLiveTimeline()
.getNeighbouringTimeline(Direction.Forward)
?.getEvents().length,
};
});
@ -455,28 +461,30 @@ class TimelinePanel extends React.Component<IProps, IState> {
let timelineWindowEventIds: string[];
try {
timelineWindowEventIds = this.timelineWindow.getEvents().map(ev => ev.getId());
timelineWindowEventIds = this.timelineWindow.getEvents().map((ev) => ev.getId());
} catch (err) {
logger.error(`onDumpDebugLogs: Failed to get event IDs from the timelineWindow`, err);
}
let pendingEventIds: string[];
try {
pendingEventIds = this.props.timelineSet.getPendingEvents().map(ev => ev.getId());
pendingEventIds = this.props.timelineSet.getPendingEvents().map((ev) => ev.getId());
} catch (err) {
logger.error(`onDumpDebugLogs: Failed to get pending event IDs`, err);
}
logger.debug(
`TimelinePanel(${this.context.timelineRenderingType}): Debugging info for ${room?.roomId}\n` +
`\tevents(${eventIdList.length})=${JSON.stringify(eventIdList)}\n` +
`\trenderedEventIds(${renderedEventIds?.length ?? 0})=` +
`${JSON.stringify(renderedEventIds)}\n` +
`\tserializedEventIdsFromTimelineSets=${JSON.stringify(serializedEventIdsFromTimelineSets)}\n` +
`\tserializedEventIdsFromThreadsTimelineSets=` +
`${JSON.stringify(serializedEventIdsFromThreadsTimelineSets)}\n` +
`\tserializedThreadsMap=${JSON.stringify(serializedThreadsMap)}\n` +
`\ttimelineWindowEventIds(${timelineWindowEventIds.length})=${JSON.stringify(timelineWindowEventIds)}\n` +
`\tpendingEventIds(${pendingEventIds.length})=${JSON.stringify(pendingEventIds)}`,
`\tevents(${eventIdList.length})=${JSON.stringify(eventIdList)}\n` +
`\trenderedEventIds(${renderedEventIds?.length ?? 0})=` +
`${JSON.stringify(renderedEventIds)}\n` +
`\tserializedEventIdsFromTimelineSets=${JSON.stringify(serializedEventIdsFromTimelineSets)}\n` +
`\tserializedEventIdsFromThreadsTimelineSets=` +
`${JSON.stringify(serializedEventIdsFromThreadsTimelineSets)}\n` +
`\tserializedThreadsMap=${JSON.stringify(serializedThreadsMap)}\n` +
`\ttimelineWindowEventIds(${timelineWindowEventIds.length})=${JSON.stringify(
timelineWindowEventIds,
)}\n` +
`\tpendingEventIds(${pendingEventIds.length})=${JSON.stringify(pendingEventIds)}`,
);
};
@ -489,11 +497,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
// this particular event should be the first or last to be unpaginated.
const eventId = scrollToken;
const marker = this.state.events.findIndex(
(ev) => {
return ev.getId() === eventId;
},
);
const marker = this.state.events.findIndex((ev) => {
return ev.getId() === eventId;
});
const count = backwards ? marker + 1 : this.state.events.length - marker;
@ -536,8 +542,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (!this.shouldPaginate()) return Promise.resolve(false);
const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate';
const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating';
const canPaginateKey = backwards ? "canBackPaginate" : "canForwardPaginate";
const paginatingKey = backwards ? "backPaginating" : "forwardPaginating";
if (!this.state[canPaginateKey]) {
debuglog("have given up", dir, "paginating this timeline");
@ -555,13 +561,15 @@ class TimelinePanel extends React.Component<IProps, IState> {
return Promise.resolve(false);
}
debuglog("Initiating paginate; backwards:"+backwards);
debuglog("Initiating paginate; backwards:" + backwards);
this.setState<null>({ [paginatingKey]: true });
return this.onPaginationRequest(this.timelineWindow, dir, PAGINATE_SIZE).then((r) => {
if (this.unmounted) { return; }
if (this.unmounted) {
return;
}
debuglog("paginate complete backwards:"+backwards+"; success:"+r);
debuglog("paginate complete backwards:" + backwards + "; success:" + r);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
@ -576,10 +584,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
// moving the window in this direction may mean that we can now
// paginate in the other where we previously could not.
const otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
const canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate';
if (!this.state[canPaginateOtherWayKey] &&
this.timelineWindow.canPaginate(otherDirection)) {
debuglog('can now', otherDirection, 'paginate again');
const canPaginateOtherWayKey = backwards ? "canForwardPaginate" : "canBackPaginate";
if (!this.state[canPaginateOtherWayKey] && this.timelineWindow.canPaginate(otherDirection)) {
debuglog("can now", otherDirection, "paginate again");
newState[canPaginateOtherWayKey] = true;
}
@ -600,7 +607,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
});
};
private onMessageListScroll = e => {
private onMessageListScroll = (e) => {
this.props.onScroll?.(e);
if (this.props.manageReadMarkers) {
this.doManageReadMarkers();
@ -616,21 +623,25 @@ class TimelinePanel extends React.Component<IProps, IState> {
* we really only need to update this once the user has finished scrolling,
* not periodically while they scroll).
*/
private doManageReadMarkers = debounce(() => {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (rmPosition < 0) {
this.setState({ readMarkerVisible: true });
}
private doManageReadMarkers = debounce(
() => {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (rmPosition < 0) {
this.setState({ readMarkerVisible: true });
}
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer?.changeTimeout(timeout);
}, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true });
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer?.changeTimeout(timeout);
},
READ_MARKER_DEBOUNCE_MS,
{ leading: false, trailing: true },
);
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
@ -652,8 +663,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
): void => {
// ignore events for other timeline sets
if (
data.timeline.getTimelineSet() !== this.props.timelineSet
&& data.timeline.getTimelineSet() !== this.props.overlayTimelineSet
data.timeline.getTimelineSet() !== this.props.timelineSet &&
data.timeline.getTimelineSet() !== this.props.overlayTimelineSet
) {
return;
}
@ -701,7 +712,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
})
.then(() => {
if (this.unmounted) { return; }
if (this.unmounted) {
return;
}
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
@ -715,21 +728,21 @@ class TimelinePanel extends React.Component<IProps, IState> {
let callRMUpdated = false;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userActiveRecently.
//
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
// read marker is visible.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userActiveRecently.
//
const myUserId = MatrixClientPeg.get().credentials.userId;
callRMUpdated = false;
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true);
updatedState.readMarkerVisible = false;
@ -871,9 +884,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
// XXX: roomReadMarkerTsMap not updated here so it is now inconsistent. Replace
// this mechanism of determining where the RM is relative to the view-port with
// one supported by the server (the client needs more than an event ID).
this.setState({
readMarkerEventId: ev.getContent().event_id,
}, this.props.onReadMarkerUpdated);
this.setState(
{
readMarkerEventId: ev.getContent().event_id,
},
this.props.onReadMarkerUpdated,
);
};
private onEventDecrypted = (ev: MatrixEvent): void => {
@ -901,28 +917,35 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState({ clientSyncState });
};
private recheckFirstVisibleEventIndex = throttle((): void => {
const firstVisibleEventIndex = this.checkForPreJoinUISI(this.state.events);
if (firstVisibleEventIndex !== this.state.firstVisibleEventIndex) {
this.setState({ firstVisibleEventIndex });
}
}, 500, { leading: true, trailing: true });
private recheckFirstVisibleEventIndex = throttle(
(): void => {
const firstVisibleEventIndex = this.checkForPreJoinUISI(this.state.events);
if (firstVisibleEventIndex !== this.state.firstVisibleEventIndex) {
this.setState({ firstVisibleEventIndex });
}
},
500,
{ leading: true, trailing: true },
);
private readMarkerTimeout(readMarkerPosition: number): number {
return readMarkerPosition === 0 ?
this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs :
this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs;
return readMarkerPosition === 0
? this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs
: this.context?.readMarkerOutOfViewThresholdMs ?? this.state.readMarkerOutOfViewThresholdMs;
}
private async updateReadMarkerOnUserActivity(): Promise<void> {
const initialTimeout = this.readMarkerTimeout(this.getReadMarkerPosition());
this.readMarkerActivityTimer = new Timer(initialTimeout);
while (this.readMarkerActivityTimer) { //unset on unmount
while (this.readMarkerActivityTimer) {
//unset on unmount
UserActivity.sharedInstance().timeWhileActiveRecently(this.readMarkerActivityTimer);
try {
await this.readMarkerActivityTimer.finished();
} catch (e) { continue; /* aborted */ }
} catch (e) {
continue; /* aborted */
}
// outside of try/catch to not swallow errors
this.updateReadMarker();
}
@ -930,11 +953,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
private async updateReadReceiptOnUserActivity(): Promise<void> {
this.readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
while (this.readReceiptActivityTimer) { //unset on unmount
while (this.readReceiptActivityTimer) {
//unset on unmount
UserActivity.sharedInstance().timeWhileActiveNow(this.readReceiptActivityTimer);
try {
await this.readReceiptActivityTimer.finished();
} catch (e) { continue; /* aborted */ }
} catch (e) {
continue; /* aborted */
}
// outside of try/catch to not swallow errors
this.sendReadReceipt();
}
@ -969,8 +995,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
// RRs) - but that is a bit of a niche case. It will sort itself out when
// the user eventually hits the live timeline.
//
if (currentRREventId && currentRREventIndex === null &&
this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
if (
currentRREventId &&
currentRREventIndex === null &&
this.timelineWindow.canPaginate(EventTimeline.FORWARDS)
) {
shouldSendRR = false;
}
@ -981,7 +1010,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
shouldSendRR = false;
}
let lastReadEvent: MatrixEvent | null = this.state.events[lastReadEventIndex ?? 0];
shouldSendRR = shouldSendRR &&
shouldSendRR =
shouldSendRR &&
// Only send a RR if the last read event is ahead in the timeline relative to
// the current RR event.
lastReadEventIndex > currentRREventIndex &&
@ -989,8 +1019,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.lastRRSentEventId !== lastReadEvent?.getId();
// Only send a RM if the last RM sent != the one we would send
const shouldSendRM =
this.lastRMSentEventId != this.state.readMarkerEventId;
const shouldSendRM = this.lastRMSentEventId != this.state.readMarkerEventId;
// we also remember the last read receipt we sent to avoid spamming the
// same one at the server repeatedly
@ -1010,30 +1039,27 @@ class TimelinePanel extends React.Component<IProps, IState> {
`rm=${this.state.readMarkerEventId} `,
`rr=${sendRRs ? lastReadEvent?.getId() : null} `,
`prr=${lastReadEvent?.getId()}`,
);
if (this.props.timelineSet.thread && sendRRs && lastReadEvent) {
// There's no support for fully read markers on threads
// as defined by MSC3771
cli.sendReadReceipt(
lastReadEvent,
sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate,
);
cli.sendReadReceipt(lastReadEvent, sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate);
} else {
cli.setRoomReadMarkers(
roomId,
this.state.readMarkerEventId ?? "",
sendRRs ? (lastReadEvent ?? undefined) : undefined, // Public read receipt (could be null)
sendRRs ? lastReadEvent ?? undefined : undefined, // Public read receipt (could be null)
lastReadEvent ?? undefined, // Private read receipt (could be null)
).catch(async (e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
if (e.errcode === "M_UNRECOGNIZED" && lastReadEvent) {
if (
!sendRRs
&& !(await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable"))
&& !(await cli.isVersionSupported("v1.4"))
) return;
!sendRRs &&
!(await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) &&
!(await cli.isVersionSupported("v1.4"))
)
return;
try {
return await cli.sendReadReceipt(
lastReadEvent,
@ -1060,7 +1086,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0);
this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
dis.dispatch({
action: 'on_room_read',
action: "on_room_read",
roomId: this.props.timelineSet.room.roomId,
});
}
@ -1088,10 +1114,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
return;
}
const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
this.setReadMarker(
lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs(),
);
this.setReadMarker(lastDisplayedEvent.getId(), lastDisplayedEvent.getTs());
// the read-marker should become invisible, so that if the user scrolls
// down, they don't see it.
@ -1177,15 +1200,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (ret !== null) {
// The messagepanel knows where the RM is, so we must have loaded
// the relevant event.
this.messagePanel.current.scrollToEvent(this.state.readMarkerEventId,
0, 1/3);
this.messagePanel.current.scrollToEvent(this.state.readMarkerEventId, 0, 1 / 3);
return;
}
// Looks like we haven't loaded the event corresponding to the read-marker.
// As with jumpToLiveTimeline, we want to reload the timeline around the
// read-marker.
this.loadTimeline(this.state.readMarkerEventId, 0, 1/3);
this.loadTimeline(this.state.readMarkerEventId, 0, 1 / 3);
};
/* update the read-up-to marker to match the read receipt
@ -1200,7 +1222,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
const tl = this.props.timelineSet.getTimelineForEvent(rmId ?? "");
let rmTs: number;
if (tl) {
const event = tl.getEvents().find((e) => { return e.getId() == rmId; });
const event = tl.getEvents().find((e) => {
return e.getId() == rmId;
});
if (event) {
rmTs = event.getTs();
}
@ -1217,9 +1241,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
* at the end of the live timeline.
*/
public isAtEndOfLiveTimeline = (): boolean | undefined => {
return this.messagePanel.current?.isAtBottom()
&& this.timelineWindow
&& !this.timelineWindow.canPaginate(EventTimeline.FORWARDS);
return (
this.messagePanel.current?.isAtBottom() &&
this.timelineWindow &&
!this.timelineWindow.canPaginate(EventTimeline.FORWARDS)
);
};
/* get the current scroll state. See ScrollPanel.getScrollState for
@ -1228,7 +1254,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
* returns null if we are not mounted.
*/
public getScrollState = (): IScrollState => {
if (!this.messagePanel.current) { return null; }
if (!this.messagePanel.current) {
return null;
}
return this.messagePanel.current.getScrollState();
};
@ -1266,7 +1294,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
// 3. We want to show the bar if the read-marker is off the top of the screen.
// 4. Also, if pos === null, the event might not be paginated - show the unread bar
const pos = this.getReadMarkerPosition();
const ret = this.state.readMarkerEventId !== null && // 1.
const ret =
this.state.readMarkerEventId !== null && // 1.
(pos < 0 || pos === null); // 3., 4.
return ret;
};
@ -1276,7 +1305,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
*
* We pass it down to the scroll panel.
*/
public handleScrollKey = ev => {
public handleScrollKey = (ev) => {
if (!this.messagePanel.current) return;
// jump to the live timeline on ctrl-end, rather than the end of the
@ -1307,13 +1336,15 @@ class TimelinePanel extends React.Component<IProps, IState> {
const doScroll = () => {
if (!this.messagePanel.current) return;
if (eventId) {
debuglog("TimelinePanel scrolling to eventId " + eventId +
" at position " + (offsetBase * 100) + "% + " + pixelOffset);
this.messagePanel.current.scrollToEvent(
eventId,
pixelOffset,
offsetBase,
debuglog(
"TimelinePanel scrolling to eventId " +
eventId +
" at position " +
offsetBase * 100 +
"% + " +
pixelOffset,
);
this.messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase);
} else {
debuglog("TimelinePanel scrolling to bottom");
this.messagePanel.current.scrollToBottom();
@ -1373,29 +1404,32 @@ class TimelinePanel extends React.Component<IProps, IState> {
// We need to skip over any which have subsequently been sent.
this.advanceReadMarkerPastMyEvents();
this.setState({
canBackPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS),
canForwardPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS),
timelineLoading: false,
}, () => {
// initialise the scroll state of the message panel
if (!this.messagePanel.current) {
// this shouldn't happen - we know we're mounted because
// we're in a setState callback, and we know
// timelineLoading is now false, so render() should have
// mounted the message panel.
logger.log("can't initialise scroll state because messagePanel didn't load");
return;
}
this.setState(
{
canBackPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS),
canForwardPaginate: !!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS),
timelineLoading: false,
},
() => {
// initialise the scroll state of the message panel
if (!this.messagePanel.current) {
// this shouldn't happen - we know we're mounted because
// we're in a setState callback, and we know
// timelineLoading is now false, so render() should have
// mounted the message panel.
logger.log("can't initialise scroll state because messagePanel didn't load");
return;
}
if (scrollIntoView) {
this.scrollIntoView(eventId, pixelOffset, offsetBase);
}
if (scrollIntoView) {
this.scrollIntoView(eventId, pixelOffset, offsetBase);
}
if (this.props.sendReadReceiptOnLoad) {
this.sendReadReceipt();
}
});
if (this.props.sendReadReceiptOnLoad) {
this.sendReadReceipt();
}
},
);
};
const onError = (error: MatrixError) => {
@ -1422,15 +1456,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
let description: string;
if (error.errcode == 'M_FORBIDDEN') {
if (error.errcode == "M_FORBIDDEN") {
description = _t(
"Tried to load a specific point in this room's timeline, but you " +
"do not have permission to view the message in question.",
"do not have permission to view the message in question.",
);
} else {
description = _t(
"Tried to load a specific point in this room's timeline, but was " +
"unable to find it.",
"Tried to load a specific point in this room's timeline, but was " + "unable to find it.",
);
}
@ -1507,24 +1540,27 @@ class TimelinePanel extends React.Component<IProps, IState> {
// maintain the main timeline event order as returned from the HS
// merge overlay events at approximately the right position based on local timestamp
const events = overlayEvents.reduce((acc: MatrixEvent[], overlayEvent: MatrixEvent) => {
// find the first main tl event with a later timestamp
const index = acc.findIndex(event => event.localTimestamp > overlayEvent.localTimestamp);
// insert overlay event into timeline at approximately the right place
if (index > -1) {
acc.splice(index, 0, overlayEvent);
} else {
acc.push(overlayEvent);
}
return acc;
}, [...mainEvents]);
const events = overlayEvents.reduce(
(acc: MatrixEvent[], overlayEvent: MatrixEvent) => {
// find the first main tl event with a later timestamp
const index = acc.findIndex((event) => event.localTimestamp > overlayEvent.localTimestamp);
// insert overlay event into timeline at approximately the right place
if (index > -1) {
acc.splice(index, 0, overlayEvent);
} else {
acc.push(overlayEvent);
}
return acc;
},
[...mainEvents],
);
// `arrayFastClone` performs a shallow copy of the array
// we want the last event to be decrypted first but displayed last
// `reverse` is destructive and unfortunately mutates the "events" array
arrayFastClone(events)
.reverse()
.forEach(event => {
.forEach((event) => {
const client = MatrixClientPeg.get();
client.decryptEventIfNeeded(event);
});
@ -1538,18 +1574,21 @@ class TimelinePanel extends React.Component<IProps, IState> {
// if we're at the end of the live timeline, append the pending events
if (!this.timelineWindow?.canPaginate(EventTimeline.FORWARDS)) {
const pendingEvents = this.props.timelineSet.getPendingEvents();
events.push(...pendingEvents.filter(event => {
const {
shouldLiveInRoom,
threadId,
} = this.props.timelineSet.room!.eventShouldLiveIn(event, pendingEvents);
events.push(
...pendingEvents.filter((event) => {
const { shouldLiveInRoom, threadId } = this.props.timelineSet.room!.eventShouldLiveIn(
event,
pendingEvents,
);
if (this.context.timelineRenderingType === TimelineRenderingType.Thread) {
return threadId === this.context.threadId;
} {
return shouldLiveInRoom;
}
}));
if (this.context.timelineRenderingType === TimelineRenderingType.Thread) {
return threadId === this.context.threadId;
}
{
return shouldLiveInRoom;
}
}),
);
}
return {
@ -1573,8 +1612,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
const cli = MatrixClientPeg.get();
const room = this.props.timelineSet.room;
const isThreadTimeline = [TimelineRenderingType.Thread, TimelineRenderingType.ThreadsList]
.includes(this.context.timelineRenderingType);
const isThreadTimeline = [TimelineRenderingType.Thread, TimelineRenderingType.ThreadsList].includes(
this.context.timelineRenderingType,
);
if (events.length === 0 || !room || !cli.isRoomEncrypted(room.roomId) || isThreadTimeline) {
logger.info("checkForPreJoinUISI: showing all messages, skipping check");
return 0;
@ -1594,8 +1634,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// a timeline, even though that should not happen. :(
// https://github.com/vector-im/element-web/issues/12120
logger.warn(
`Event ${events[i].getId()} in room ${room.roomId} is live, ` +
`but it does not have a timeline`,
`Event ${events[i].getId()} in room ${room.roomId} is live, ` + `but it does not have a timeline`,
);
continue;
}
@ -1633,7 +1672,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
private indexForEventId(evId: string | null): number | null {
if (evId === null) { return null; }
if (evId === null) {
return null;
}
/* Threads do not have server side support for read receipts and the concept
is very tied to the main room timeline, we are forcing the timeline to
send read receipts for threaded events */
@ -1641,10 +1682,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_thread") && isThreadTimeline) {
return 0;
}
const index = this.state.events.findIndex(ev => ev.getId() === evId);
return index > -1
? index
: null;
const index = this.state.events.findIndex((ev) => ev.getId() === evId);
return index > -1 ? index : null;
}
private getLastDisplayedEventIndex(opts: IEventIndexOpts = {}): number | null {
@ -1697,10 +1736,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
adjacentInvisibleEventCount = 0;
}
const shouldIgnore = !!ev.status || // local echo
const shouldIgnore =
!!ev.status || // local echo
(ignoreOwn && ev.getSender() === myUserId); // own message
const isWithoutTile = !haveRendererForEvent(ev, this.context?.showHiddenEvents) ||
shouldHideEvent(ev, this.context);
const isWithoutTile =
!haveRendererForEvent(ev, this.context?.showHiddenEvents) || shouldHideEvent(ev, this.context);
if (isWithoutTile || !node) {
// don't start counting if the event should be ignored,
@ -1742,8 +1782,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
const myUserId = client.credentials.userId;
const receiptStore: ReadReceipt<any, any> =
this.props.timelineSet.thread ?? this.props.timelineSet.room;
const receiptStore: ReadReceipt<any, any> = this.props.timelineSet.thread ?? this.props.timelineSet.room;
return receiptStore?.getEventReadUpTo(myUserId, ignoreSynthesized);
}
@ -1767,9 +1806,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
// Do the local echo of the RM
// run the render cycle before calling the callback, so that
// getReadMarkerPosition() returns the right thing.
this.setState({
readMarkerEventId: eventId,
}, this.props.onReadMarkerUpdated);
this.setState(
{
readMarkerEventId: eventId,
},
this.props.onReadMarkerUpdated,
);
}
private shouldPaginate(): boolean {
@ -1816,7 +1858,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
return (
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
<div className="mx_RoomView_empty">{ this.props.empty }</div>
<div className="mx_RoomView_empty">{this.props.empty}</div>
</div>
);
}
@ -1833,10 +1875,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
// If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with
// the HS and fetch the latest events, so we are effectively forward paginating.
const forwardPaginating = (
this.state.forwardPaginating ||
['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState)
);
const forwardPaginating =
this.state.forwardPaginating || ["PREPARED", "CATCHUP"].includes(this.state.clientSyncState);
const events = this.state.firstVisibleEventIndex
? this.state.events.slice(this.state.firstVisibleEventIndex)
: this.state.events;
@ -1897,7 +1937,7 @@ function serializeEventIdsFromTimelineSets(timelineSets): { [key: string]: strin
// Add a special label when it is the live timeline so we can tell
// it apart from the others
const isLiveTimeline = timeline === liveTimeline;
timelineMap[isLiveTimeline ? 'liveTimeline' : `${index}`] = timeline.getEvents().map(ev => ev.getId());
timelineMap[isLiveTimeline ? "liveTimeline" : `${index}`] = timeline.getEvents().map((ev) => ev.getId());
});
return timelineMap;

View file

@ -36,11 +36,11 @@ export default class ToastContainer extends React.Component<{}, IState> {
// toasts may dismiss themselves in their didMount if they find
// they're already irrelevant by the time they're mounted, and
// our own componentDidMount is too late.
ToastStore.sharedInstance().on('update', this.onToastStoreUpdate);
ToastStore.sharedInstance().on("update", this.onToastStoreUpdate);
}
componentWillUnmount() {
ToastStore.sharedInstance().removeListener('update', this.onToastStoreUpdate);
ToastStore.sharedInstance().removeListener("update", this.onToastStoreUpdate);
}
private onToastStoreUpdate = () => {
@ -60,7 +60,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
const { title, icon, key, component, className, bodyClassName, props } = topToast;
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
const toastClasses = classNames("mx_Toast_toast", className, {
"mx_Toast_hasIcon": icon,
mx_Toast_hasIcon: icon,
[`mx_Toast_icon_${icon}`]: icon,
});
const toastProps = Object.assign({}, props, {
@ -70,7 +70,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
const content = React.createElement(component, toastProps);
let countIndicator;
if (title && isStacked || this.state.countSeen > 0) {
if ((title && isStacked) || this.state.countSeen > 0) {
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
}
@ -78,29 +78,27 @@ export default class ToastContainer extends React.Component<{}, IState> {
if (title) {
titleElement = (
<div className="mx_Toast_title">
<h2>{ title }</h2>
<span className="mx_Toast_title_countIndicator">{ countIndicator }</span>
<h2>{title}</h2>
<span className="mx_Toast_title_countIndicator">{countIndicator}</span>
</div>
);
}
toast = (
<div className={toastClasses}>
{ titleElement }
<div className={bodyClasses}>{ content }</div>
{titleElement}
<div className={bodyClasses}>{content}</div>
</div>
);
containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
mx_ToastContainer_stacked: isStacked,
});
}
return toast
? (
<div className={containerClasses} role="alert">
{ toast }
</div>
)
: null;
return toast ? (
<div className={containerClasses} role="alert">
{toast}
</div>
) : null;
}
}

View file

@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
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 { 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";
interface IProps {
@ -77,7 +77,7 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
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 {
@ -109,18 +109,18 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
}
// MUST use var name 'count' for pluralization to kick in
const uploadText = _t(
"Uploading %(filename)s and %(count)s others", {
filename: this.state.currentFile,
count: this.state.countFiles - 1,
},
);
const uploadText = _t("Uploading %(filename)s and %(count)s others", {
filename: this.state.currentFile,
count: this.state.countFiles - 1,
});
const uploadSize = filesize(this.state.currentTotal!);
return (
<div className="mx_UploadBar">
<div className="mx_UploadBar_filename">{ uploadText } ({ uploadSize })</div>
<AccessibleButton onClick={this.onCancelClick} className='mx_UploadBar_cancel' />
<div className="mx_UploadBar_filename">
{uploadText} ({uploadSize})
</div>
<AccessibleButton onClick={this.onCancelClick} className="mx_UploadBar_cancel" />
<ProgressBar value={this.state.currentLoaded!} max={this.state.currentTotal!} />
</div>
);

View file

@ -30,15 +30,13 @@ 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,
@ -178,11 +176,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
};
private onThemeChanged = () => {
this.setState(
{
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
});
this.setState({
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
});
};
private onAction = (payload: ActionPayload) => {
@ -263,7 +260,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const cli = MatrixClientPeg.get();
if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) {
// log out without user prompt if they have no local megolm sessions
defaultDispatcher.dispatch({ action: 'logout' });
defaultDispatcher.dispatch({ action: "logout" });
} else {
Modal.createDialog(LogoutDialog);
}
@ -272,12 +269,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
};
private onSignInClick = () => {
defaultDispatcher.dispatch({ action: 'start_login' });
defaultDispatcher.dispatch({ action: "start_login" });
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onRegisterClick = () => {
defaultDispatcher.dispatch({ action: 'start_registration' });
defaultDispatcher.dispatch({ action: "start_registration" });
this.setState({ contextMenuPosition: null }); // also close the menu
};
@ -297,20 +294,28 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (MatrixClientPeg.get().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
{ _t("Got an account? <a>Sign in</a>", {}, {
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(
"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>
),
},
)}
</div>
);
} else if (hostSignupConfig?.get("url")) {
@ -318,7 +323,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
// 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}`)));
const validDomains = hostSignupDomains.filter((d) => d === mxDomain || mxDomain.endsWith(`.${d}`));
if (!hostSignupConfig.get("domains") || validDomains.length > 0) {
topSection = <HostSignupAction onClick={this.onCloseMenu} />;
}
@ -337,16 +342,18 @@ export default class UserMenu extends React.Component<IProps, IState> {
let feedbackButton;
if (SettingsStore.getValue(UIFeature.Feedback)) {
feedbackButton = <IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")}
onClick={this.onProvideFeedback}
/>;
feedbackButton = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")}
onClick={this.onProvideFeedback}
/>
);
}
let primaryOptionList = (
<IconizedContextMenuOptionList>
{ homeButton }
{homeButton}
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notifications")}
@ -362,7 +369,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
{feedbackButton}
<IconizedContextMenuOption
className="mx_IconizedContextMenu_option_red"
iconClassName="mx_UserMenu_iconSignOut"
@ -375,13 +382,13 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<IconizedContextMenuOptionList>
{ homeButton }
{homeButton}
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
{feedbackButton}
</IconizedContextMenuOptionList>
);
}
@ -390,37 +397,36 @@ 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.get().getUserId(), {
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("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>
);
};
public render() {
@ -432,42 +438,42 @@ export default class UserMenu extends React.Component<IProps, IState> {
let name: JSX.Element;
if (!this.props.isPanelCollapsed) {
name = <div className="mx_UserMenu_name">
{ displayName }
</div>;
name = <div className="mx_UserMenu_name">{displayName}</div>;
}
const liveAvatarAddon = this.state.showLiveAvatarAddon
? <div className="mx_UserMenu_userAvatarLive" data-testid="user-menu-live-vb">
const liveAvatarAddon = this.state.showLiveAvatarAddon ? (
<div className="mx_UserMenu_userAvatarLive" data-testid="user-menu-live-vb">
<LiveIcon className="mx_Icon_8" />
</div>
: null;
) : null;
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"
/>
{ liveAvatarAddon }
</div>
{ name }
{ this.renderContextMenu() }
</ContextMenuButton>
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"
/>
{liveAvatarAddon}
</div>
{name}
{this.renderContextMenu()}
</ContextMenuButton>
{ this.props.children }
</div>;
{this.props.children}
</div>
);
}
}

View file

@ -20,8 +20,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
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";
@ -71,8 +71,8 @@ export default class UserView extends React.Component<IProps, IState> {
profileInfo = await cli.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("Could not load user profile"),
description: err && err.message ? err.message : _t("Operation failed"),
});
this.setState({ loading: false });
return;
@ -87,15 +87,19 @@ export default class UserView extends React.Component<IProps, IState> {
if (this.state.loading) {
return <Spinner />;
} else if (this.state.member) {
const panel = <RightPanel
overwriteCard={{ phase: RightPanelPhases.RoomMemberInfo, state: { member: this.state.member } }}
resizeNotifier={this.props.resizeNotifier}
/>;
return (<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
<UserOnboardingPage />
</MainSplit>);
const panel = (
<RightPanel
overwriteCard={{ phase: RightPanelPhases.RoomMemberInfo, state: { member: this.state.member } }}
resizeNotifier={this.props.resizeNotifier}
/>
);
return (
<MainSplit panel={panel} resizeNotifier={this.props.resizeNotifier}>
<UserOnboardingPage />
</MainSplit>
);
} else {
return (<div />);
return <div />;
}
}
}

View file

@ -23,7 +23,7 @@ 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 { MatrixClientPeg } from "../../MatrixClientPeg";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import BaseDialog from "../views/dialogs/BaseDialog";
import { DevtoolsContext } from "../views/dialogs/devtools/BaseTool";
@ -75,26 +75,18 @@ export default class ViewSource extends React.Component<IProps, IState> {
<>
<details open className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">
{ _t("Decrypted event source") }
</span>
<span className="mx_ViewSource_heading">{_t("Decrypted event source")}</span>
</summary>
<CopyableText getTextToCopy={copyDecryptedFunc}>
<SyntaxHighlight language="json">
{ stringify(decryptedEventSource) }
</SyntaxHighlight>
<SyntaxHighlight language="json">{stringify(decryptedEventSource)}</SyntaxHighlight>
</CopyableText>
</details>
<details className="mx_ViewSource_details">
<summary>
<span className="mx_ViewSource_heading">
{ _t("Original event source") }
</span>
<span className="mx_ViewSource_heading">{_t("Original event source")}</span>
</summary>
<CopyableText getTextToCopy={copyOriginalFunc}>
<SyntaxHighlight language="json">
{ stringify(originalEventSource) }
</SyntaxHighlight>
<SyntaxHighlight language="json">{stringify(originalEventSource)}</SyntaxHighlight>
</CopyableText>
</details>
</>
@ -102,13 +94,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("Original event source")}</div>
<CopyableText getTextToCopy={copyOriginalFunc}>
<SyntaxHighlight language="json">
{ stringify(originalEventSource) }
</SyntaxHighlight>
<SyntaxHighlight language="json">{stringify(originalEventSource)}</SyntaxHighlight>
</CopyableText>
</>
);
@ -125,22 +113,22 @@ export default class ViewSource extends React.Component<IProps, IState> {
if (isStateEvent) {
return (
<MatrixClientContext.Consumer>
{ (cli) => (
{(cli) => (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId) }}>
<StateEventEditor onBack={this.onBack} mxEvent={mxEvent} />
</DevtoolsContext.Provider>
) }
)}
</MatrixClientContext.Consumer>
);
}
return (
<MatrixClientContext.Consumer>
{ (cli) => (
{(cli) => (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId) }}>
<TimelineEventEditor onBack={this.onBack} mxEvent={mxEvent} />
</DevtoolsContext.Provider>
) }
)}
</MatrixClientContext.Consumer>
);
}
@ -162,25 +150,25 @@ export default class ViewSource extends React.Component<IProps, IState> {
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("View Source")}>
<div className="mx_ViewSource_header">
<CopyableText getTextToCopy={() => roomId} border={false}>
{ _t("Room ID: %(roomId)s", { roomId }) }
{_t("Room ID: %(roomId)s", { roomId })}
</CopyableText>
<CopyableText getTextToCopy={() => eventId} border={false}>
{ _t("Event ID: %(eventId)s", { eventId }) }
{_t("Event ID: %(eventId)s", { eventId })}
</CopyableText>
{ mxEvent.threadRootId && (
{mxEvent.threadRootId && (
<CopyableText getTextToCopy={() => mxEvent.threadRootId!} border={false}>
{ _t("Thread root ID: %(threadRootId)s", {
{_t("Thread root ID: %(threadRootId)s", {
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("Edit")}</button>
</div>
) }
)}
</BaseDialog>
);
}

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { _t } from '../../../languageHandler';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import { _t } from "../../../languageHandler";
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
import SetupEncryptionBody from "./SetupEncryptionBody";
import AccessibleButton from '../../views/elements/AccessibleButton';
import AccessibleButton from "../../views/elements/AccessibleButton";
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import AuthPage from "../../views/auth/AuthPage";
@ -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("Skip verification for now")}
/>
);
}
@ -101,9 +105,9 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
<AuthPage>
<CompleteSecurityBody>
<h1 className="mx_CompleteSecurity_header">
{ icon }
{ title }
{ skipButton }
{icon}
{title}
{skipButton}
</h1>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} />

View file

@ -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;

View file

@ -16,33 +16,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from 'react';
import { logger } from 'matrix-js-sdk/src/logger';
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 { 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 AuthPage from "../../views/auth/AuthPage";
import PassphraseField from '../../views/auth/PassphraseField';
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
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 StyledCheckbox from '../../views/elements/StyledCheckbox';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
import StyledCheckbox from "../../views/elements/StyledCheckbox";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { Icon as LockIcon } from "../../../../res/img/element-icons/lock.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 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 { Icon as CheckboxIcon } from "../../../../res/img/element-icons/Checkbox.svg";
import { VerifyEmailModal } from './forgot-password/VerifyEmailModal';
import Spinner from '../../views/elements/Spinner';
import { formatSeconds } from '../../../DateUtils';
import AutoDiscoveryUtils from '../../../utils/AutoDiscoveryUtils';
import { VerifyEmailModal } from "./forgot-password/VerifyEmailModal";
import Spinner from "../../views/elements/Spinner";
import { formatSeconds } from "../../../DateUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
const emailCheckInterval = 2000;
@ -115,7 +115,8 @@ export default class ForgotPassword extends React.Component<Props, State> {
}
public componentDidUpdate(prevProps: Readonly<Props>) {
if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
// Do a liveliness check on the new URLs
@ -128,19 +129,16 @@ export default class ForgotPassword extends React.Component<Props, State> {
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: any) {
const {
serverIsAlive,
serverDeadError,
} = AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password");
const { serverIsAlive, serverDeadError } = AutoDiscoveryUtils.authComponentStateForError(
e,
"forgot_password",
);
this.setState({
serverIsAlive,
errorText: serverDeadError,
@ -190,12 +188,9 @@ export default class ForgotPassword extends React.Component<Props, State> {
const errorText = isNaN(retryAfterMs)
? _t("Too many attempts in a short time. Wait some time before trying again.")
: _t(
"Too many attempts in a short time. Retry after %(timeout)s.",
{
timeout: formatSeconds(retryAfterMs / 1000),
},
);
: _t("Too many attempts in a short time. Retry after %(timeout)s.", {
timeout: formatSeconds(retryAfterMs / 1000),
});
this.setState({
errorText,
@ -205,8 +200,10 @@ export default class ForgotPassword extends React.Component<Props, State> {
if (err?.name === "ConnectionError") {
this.setState({
errorText: _t("Cannot reach homeserver") + ": "
+ _t("Ensure you have a stable internet connection, or get in touch with the server admin"),
errorText:
_t("Cannot reach homeserver") +
": " +
_t("Ensure you have a stable internet connection, or get in touch with the server admin"),
});
return;
}
@ -227,10 +224,7 @@ export default class ForgotPassword extends React.Component<Props, State> {
}
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
const fieldIdsInDisplayOrder = [
this.fieldPassword,
this.fieldPasswordConfirm,
];
const fieldIdsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
const invalidFields: Field[] = [];
@ -256,7 +250,7 @@ export default class ForgotPassword extends React.Component<Props, State> {
}
private async onPhasePasswordInputSubmit(): Promise<void> {
if (!await this.verifyFieldsBeforeSubmit()) return;
if (!(await this.verifyFieldsBeforeSubmit())) return;
if (this.state.logoutDevices) {
const logoutDevicesConfirmation = await this.renderConfirmLogoutDevicesDialog();
@ -357,124 +351,137 @@ export default class ForgotPassword extends React.Component<Props, State> {
};
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}
/>;
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}
/>
);
}
async renderConfirmLogoutDevicesDialog(): Promise<boolean> {
const { finished } = Modal.createDialog<[boolean]>(QuestionDialog, {
title: _t('Warning!'),
description:
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'),
<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"),
});
const [confirmed] = await finished;
return confirmed;
}
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}
/>;
return (
<CheckEmail
email={this.state.email}
errorText={this.state.errorText}
onReEnterEmailClick={() => this.setState({ phase: Phase.EnterEmail })}
onResendClick={this.sendVerificationMail}
onSubmitForm={this.onSubmitForm}
/>
);
}
renderSetPassword(): JSX.Element {
const submitButtonChild = this.state.phase === Phase.ResettingPassword
? <Spinner w={16} h={16} />
: _t("Reset password");
const submitButtonChild =
this.state.phase === Phase.ResettingPassword ? <Spinner w={16} h={16} /> : _t("Reset password");
return <>
<LockIcon className="mx_AuthBody_lockIcon" />
<h1>{ _t("Reset your password") }</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("New Password")}
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("Confirm new password")}
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.fieldPasswordConfirm = field}
onChange={this.onInputChanged.bind(this, "password2")}
autoComplete="new-password"
/>
</div>
{ this.state.serverSupportsControlOfDevicesLogout ?
return (
<>
<LockIcon className="mx_AuthBody_lockIcon" />
<h1>{_t("Reset your password")}</h1>
<form onSubmit={this.onSubmitForm}>
<fieldset disabled={this.state.phase === Phase.ResettingPassword}>
<div className="mx_AuthBody_fieldRow">
<StyledCheckbox onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}>
{ _t("Sign out of all devices") }
</StyledCheckbox>
</div> : null
}
{ this.state.errorText && <ErrorMessage message={this.state.errorText} /> }
<button
type="submit"
className="mx_Login_submit"
>
{ submitButtonChild }
</button>
</fieldset>
</form>
</>;
<PassphraseField
name="reset_password"
type="password"
label={_td("New Password")}
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("Confirm new password")}
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.fieldPasswordConfirm = 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 of all devices")}
</StyledCheckbox>
</div>
) : null}
{this.state.errorText && <ErrorMessage message={this.state.errorText} />}
<button type="submit" className="mx_Login_submit">
{submitButtonChild}
</button>
</fieldset>
</form>
</>
);
}
renderDone() {
return <>
<CheckboxIcon className="mx_Icon mx_Icon_32 mx_Icon_accent" />
<h1>{ _t("Your password has been reset.") }</h1>
{ 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')} />
</>;
return (
<>
<CheckboxIcon className="mx_Icon mx_Icon_32 mx_Icon_accent" />
<h1>{_t("Your password has been reset.")}</h1>
{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")}
/>
</>
);
}
render() {
@ -507,9 +514,7 @@ export default class ForgotPassword extends React.Component<Props, State> {
return (
<AuthPage>
<AuthHeader />
<AuthBody className="mx_AuthBody_forgot-password">
{ resetPasswordJsx }
</AuthBody>
<AuthBody className="mx_AuthBody_forgot-password">{resetPasswordJsx}</AuthBody>
</AuthPage>
);
}

View file

@ -14,19 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { _t, _td } from '../../../languageHandler';
import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import { _t, _td } from "../../../languageHandler";
import Login from "../../../Login";
import SdkConfig from "../../../SdkConfig";
import { messageForResourceLimitError } 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,8 +37,8 @@ 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';
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.
@ -122,7 +122,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
flows: null,
username: props.defaultUsername? props.defaultUsername: '',
username: props.defaultUsername ? props.defaultUsername : "",
phoneCountry: null,
phoneNumber: "",
@ -134,13 +134,13 @@ 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"),
};
}
@ -153,7 +153,8 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
public componentDidUpdate(prevProps) {
if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
// Ensure that we end up actually logging in to the right place
@ -197,91 +198,77 @@ 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;
}
let errorText;
// 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")) {
// 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>{ _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>{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>
);
} else {
errorText = _t("Incorrect username and/or password.");
}
} else {
errorText = _t('Incorrect username and/or password.');
// other errors, not specific to doing a password login
errorText = this.errorTextFromError(error);
}
} 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: 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 => {
onUsernameChanged = (username) => {
this.setState({ username: username });
};
onUsernameBlur = async username => {
onUsernameBlur = async (username) => {
const doWellknownLookup = username[0] === "@";
this.setState({
username: username,
@ -290,7 +277,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);
@ -328,34 +315,37 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
}
};
onPhoneCountryChanged = phoneCountry => {
onPhoneCountryChanged = (phoneCountry) => {
this.setState({ phoneCountry: phoneCountry });
};
onPhoneNumberChanged = phoneNumber => {
onPhoneNumberChanged = (phoneNumber) => {
this.setState({
phoneNumber: phoneNumber,
});
};
onRegisterClick = ev => {
onRegisterClick = (ev) => {
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");
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");
// 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,
);
} else {
// Don't intercept - just go through to the register page
this.onRegisterClick(ev);
@ -364,9 +354,10 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig) {
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
&& isUrl === this.props.serverConfig.isUrl
if (
this.props.serverConfig.isDefault &&
hsUrl === this.props.serverConfig.hsUrl &&
isUrl === this.props.serverConfig.isUrl
) {
isDefaultServer = true;
}
@ -385,8 +376,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
// 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),
@ -405,32 +395,40 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
});
}
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
const supportedFlows = flows.filter(this.isSupportedFlow);
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) {
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({
flows: supportedFlows,
busy: false,
});
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 => {
@ -449,41 +447,55 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
errCode = "HTTP " + err.httpStatus;
}
let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " +
"please try again later.") + (errCode ? " (" + errCode + ")" : "");
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"))
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>;
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>;
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>
);
}
}
@ -494,18 +506,17 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
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 = ["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 = 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>
);
}
private renderPasswordStep = () => {
@ -528,8 +539,8 @@ 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 renderSsoStep = (loginType) => {
const flow = this.state.flows.find((flow) => flow.type === "m.login." + loginType) as ISSOFlow;
return (
<SSOButtons
@ -537,60 +548,65 @@ 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")}
/>
);
};
render() {
const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Spinner /></div> : null;
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("Syncing...") : _t("Signing In...")}
</div>
{this.props.isSyncing && (
<div className="mx_AuthBody_paddedFooter_subtitle">
{_t("If you've joined lots of rooms, this might take a while")}
</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(
"New? <a>Create account</a>",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onTryRegisterClick}>
{sub}
</AccessibleButton>
),
},
)}
</span>
);
}
@ -600,17 +616,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("Sign in")}
{loader}
</h1>
{ errorTextSection }
{ serverDeadSection }
{errorTextSection}
{serverDeadSection}
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
/>
{ this.renderLoginComponentForFlows() }
{ footer }
{this.renderLoginComponentForFlows()}
{footer}
</AuthBody>
</AuthPage>
);

View file

@ -14,33 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { AuthType, createClient, IAuthData } from 'matrix-js-sdk/src/matrix';
import React, { Fragment, ReactNode } from 'react';
import { AuthType, createClient, IAuthData } from "matrix-js-sdk/src/matrix";
import React, { Fragment, ReactNode } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
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, _td } from "../../../languageHandler";
import { messageForResourceLimitError } 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 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 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";
const debuglog = (...args: any[]) => {
if (SettingsStore.getValue("debug_registration")) {
@ -167,7 +167,8 @@ export default class Registration extends React.Component<IProps, IState> {
};
public componentDidUpdate(prevProps) {
if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
if (
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
) {
this.replaceClient(this.props.serverConfig);
@ -218,7 +219,7 @@ export default class Registration extends React.Component<IProps, IState> {
try {
const loginFlows = await this.loginLogic.getFlows();
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 ISSOFlow;
} 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);
@ -253,7 +254,7 @@ export default class Registration extends React.Component<IProps, IState> {
// 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
@ -301,34 +302,32 @@ export default class Registration extends React.Component<IProps, IState> {
if (!success) {
let errorText: ReactNode = response.message || response.toString();
// can we give a better error message?
if (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."),
},
);
if (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."),
});
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact,
{
'': _td("Please <a>contact your service administrator</a> to continue using this service."),
"": _td("Please <a>contact your service administrator</a> to continue using this service."),
},
);
errorText = <div>
<p>{ errorTop }</p>
<p>{ errorDetail }</p>
</div>;
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);
}
if (!msisdnAvailable) {
errorText = _t('This server does not support authentication with a phone number.');
errorText = _t("This server does not support authentication with a phone number.");
}
} else if (response.errcode === "M_USER_IN_USE") {
errorText = _t("Someone already has that username, please try another.");
@ -362,9 +361,7 @@ export default class Registration extends React.Component<IProps, IState> {
// the user had a separate guest session they didn't actually mean to replace.
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.user_id) {
logger.log(
`Found a session for ${sessionOwner} but ${response.user_id} has just registered.`,
);
logger.log(`Found a session for ${sessionOwner} but ${response.user_id} has just registered.`);
newState.differentLoggedInUserId = sessionOwner;
}
@ -387,13 +384,16 @@ export default class Registration extends React.Component<IProps, IState> {
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);
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,
);
this.setupPushers();
} else {
@ -409,31 +409,37 @@ export default class Registration extends React.Component<IProps, IState> {
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);
});
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) => {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
};
private onGoToFormClicked = ev => {
private onGoToFormClicked = (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.replaceClient(this.props.serverConfig);
@ -469,7 +475,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) => {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true });
@ -483,23 +489,27 @@ export default class Registration extends React.Component<IProps, IState> {
private renderRegisterComponent() {
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>;
return (
<div className="mx_AuthBody_spinner">
<Spinner />
</div>
);
} else if (this.state.flows.length) {
let ssoSection;
if (this.state.ssoFlow) {
@ -508,47 +518,50 @@ export default class Registration extends React.Component<IProps, IState> {
// 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("Continue with %(ssoButtons)s", { 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}
/>
<h2 className="mx_AuthBody_centered">
{_t("%(ssoButtons)s Or %(usernamePassword)s", {
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>
);
}
}
@ -556,118 +569,144 @@ export default class Registration extends React.Component<IProps, IState> {
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(
"Already have an account? <a>Sign in here</a>",
{},
{
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("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(
"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>
);
} 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(
"<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>
);
}
body = <div>
<h1>{ _t("Registration Successful") }</h1>
{ regDoneText }
</div>;
body = (
<div>
<h1>{_t("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("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>
);
}
return (
<AuthPage>
<AuthHeader />
<AuthHeaderProvider>
<AuthBody flex>
{ body }
</AuthBody>
<AuthBody flex>{body}</AuthBody>
</AuthHeaderProvider>
</AuthPage>
);

View file

@ -14,27 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api';
import React from "react";
import { ISecretStorageKeyInfo } from "matrix-js-sdk/src/crypto/api";
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { 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 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 {
@ -146,33 +142,34 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
};
public render() {
const {
phase,
lostKeys,
} = this.state;
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}
/>;
return (
<EncryptionPanel
layout="dialog"
verificationRequest={this.state.verificationRequest}
onClose={this.onEncryptionPanelClose}
member={MatrixClientPeg.get().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(
"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>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
{_t("Proceed with reset")}
</AccessibleButton>
</div>
</div>
@ -188,38 +185,44 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
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("Verify with another device")}
</AccessibleButton>
);
}
return (
<div>
<p>{ _t(
"Verify your identity to access encrypted messages and prove your identity to others.",
) }</p>
<p>
{_t("Verify your identity to access encrypted messages and prove your identity to others.")}
</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("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>
),
})}
</div>
</div>
);
@ -227,25 +230,24 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
} else if (phase === Phase.Done) {
let message;
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(
"Your new device is now verified. It has access to your " +
"encrypted messages, and other users will see it as trusted.",
)}
</p>
);
} else {
message = <p>{ _t(
"Your new device is now verified. Other users will see it as trusted.",
) }</p>;
message = <p>{_t("Your new device is now verified. Other users will see it as trusted.")}</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("Done")}
</AccessibleButton>
</div>
</div>
@ -253,22 +255,18 @@ 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(
"Without verifying, you won't have access to all your messages " +
"and may appear as untrusted to others.",
)}
</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("I'll verify later")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.onSkipBackClick}
>
{ _t("Go Back") }
<AccessibleButton kind="primary" onClick={this.onSkipBackClick}>
{_t("Go Back")}
</AccessibleButton>
</div>
</div>
@ -276,23 +274,27 @@ 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(
"Resetting your verification keys cannot be undone. After resetting, " +
"you won't have access to old encrypted messages, and any friends who " +
"have previously verified you will see security warnings until you " +
"re-verify with them.",
)}
</p>
<p>
{_t(
"Please only proceed if you're sure you've lost all of your other " +
"devices and your security key.",
)}
</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
{_t("Proceed with reset")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
{ _t("Go Back") }
{_t("Go Back")}
</AccessibleButton>
</div>
</div>

View file

@ -14,23 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React 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 { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import * as Lifecycle from "../../../Lifecycle";
import Modal from "../../../Modal";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { sendLoginRequest } from "../../../Login";
import 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";
@ -95,7 +95,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled()) {
cli.countSessionsNeedingBackup().then(remaining => {
cli.countSessionsNeedingBackup().then((remaining) => {
this.setState({ keyBackupNeeded: remaining > 0 });
});
}
@ -114,7 +114,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
private async initLogin() {
const queryParams = this.props.realQueryParams;
const hasAllParams = queryParams && queryParams['loginToken'];
const hasAllParams = queryParams && queryParams["loginToken"];
if (hasAllParams) {
this.setState({ loginView: LoginView.Loading });
this.trySsoLogin();
@ -125,10 +125,10 @@ export default class SoftLogout extends React.Component<IProps, IState> {
// care about login flows here, unless it is the single flow we support.
const client = MatrixClientPeg.get();
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 });
}
@ -138,7 +138,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
};
private onForgotPassword = () => {
dis.dispatch({ action: 'start_password_recovery' });
dis.dispatch({ action: "start_password_recovery" });
};
private onPasswordLogin = async (ev) => {
@ -188,7 +188,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
const loginType = "m.login.token";
const loginParams = {
token: this.props.realQueryParams['loginToken'],
token: this.props.realQueryParams["loginToken"],
device_id: MatrixClientPeg.get().getDeviceId(),
};
@ -201,24 +201,26 @@ export default class SoftLogout extends React.Component<IProps, IState> {
return;
}
Lifecycle.hydrateSession(credentials).then(() => {
if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted();
}).catch((e) => {
logger.error(e);
this.setState({ busy: false, loginView: LoginView.Unsupported });
});
Lifecycle.hydrateSession(credentials)
.then(() => {
if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted();
})
.catch((e) => {
logger.error(e);
this.setState({ busy: false, loginView: LoginView.Unsupported });
});
}
private renderPasswordForm(introText: Optional<string>): JSX.Element {
let error: JSX.Element = null;
if (this.state.errorText) {
error = <span className='mx_Login_error'>{ this.state.errorText }</span>;
error = <span className="mx_Login_error">{this.state.errorText}</span>;
}
return (
<form onSubmit={this.onPasswordLogin}>
{ introText ? <p>{ introText }</p> : null }
{ error }
{introText ? <p>{introText}</p> : null}
{error}
<Field
type="password"
label={_t("Password")}
@ -232,10 +234,10 @@ export default class SoftLogout extends React.Component<IProps, IState> {
type="submit"
disabled={this.state.busy}
>
{ _t("Sign In") }
{_t("Sign In")}
</AccessibleButton>
<AccessibleButton onClick={this.onForgotPassword} kind="link">
{ _t("Forgotten your password?") }
{_t("Forgotten your password?")}
</AccessibleButton>
</form>
);
@ -243,17 +245,17 @@ 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 ISSOFlow;
return (
<div>
{ introText ? <p>{ introText }</p> : null }
{introText ? <p>{introText}</p> : null}
<SSOButtons
matrixClient={MatrixClientPeg.get()}
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")}
/>
</div>
);
@ -268,7 +270,8 @@ export default class SoftLogout extends React.Component<IProps, IState> {
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.");
"Without them, you won't be able to read all of your secure messages in any session.",
);
}
if (this.state.loginView === LoginView.Password) {
@ -296,29 +299,28 @@ export default class SoftLogout extends React.Component<IProps, IState> {
// 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>{introText}</p>
{this.renderSsoForm(null)}
<h2 className="mx_AuthBody_centered">
{_t("%(ssoButtons)s Or %(usernamePassword)s", {
ssoButtons: "",
usernamePassword: "",
},
).trim() }
</h2>
{ this.renderPasswordForm(null) }
</>;
}).trim()}
</h2>
{this.renderPasswordForm(null)}
</>
);
}
// Default: assume unsupported/error
return (
<p>
{ _t(
{_t(
"You cannot sign in to your account. Please contact your " +
"homeserver admin for more information.",
) }
"homeserver admin for more information.",
)}
</p>
);
}
@ -328,26 +330,22 @@ export default class SoftLogout extends React.Component<IProps, IState> {
<AuthPage>
<AuthHeader />
<AuthBody>
<h1>
{ _t("You're signed out") }
</h1>
<h1>{_t("You're signed out")}</h1>
<h2>{ _t("Sign in") }</h2>
<div>
{ this.renderSignInSection() }
</div>
<h2>{_t("Sign in")}</h2>
<div>{this.renderSignInSection()}</div>
<h2>{ _t("Clear personal data") }</h2>
<h2>{_t("Clear personal data")}</h2>
<p>
{ _t(
{_t(
"Warning: Your personal data (including encryption keys) is still stored " +
"in this session. Clear it if you're finished using this session, or want to sign " +
"in to another account.",
) }
"in this session. Clear it if you're finished using this session, or want to sign " +
"in to another account.",
)}
</p>
<div>
<AccessibleButton onClick={this.onClearAll} kind="danger">
{ _t("Clear all data") }
{_t("Clear all data")}
</AccessibleButton>
</div>
</AuthBody>

View file

@ -19,7 +19,7 @@ import React, { ReactNode } from "react";
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/element-icons/retry.svg";
import { _t } from '../../../../languageHandler';
import { _t } from "../../../../languageHandler";
import Tooltip, { Alignment } from "../../../views/elements/Tooltip";
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
import { ErrorMessage } from "../../ErrorMessage";
@ -49,50 +49,35 @@ export const CheckEmail: React.FC<CheckEmailProps> = ({
toggleTooltipVisible();
};
return <>
<EMailPromptIcon className="mx_AuthBody_emailPromptIcon--shifted" />
<h1>{ _t("Check your email to continue") }</h1>
<div className="mx_AuthBody_text">
<p>
{ _t(
"Follow the instructions sent to <b>%(email)s</b>",
{ email: email },
{ b: t => <b>{ t }</b> },
) }
</p>
return (
<>
<EMailPromptIcon className="mx_AuthBody_emailPromptIcon--shifted" />
<h1>{_t("Check your email to continue")}</h1>
<div className="mx_AuthBody_text">
<p>
{_t("Follow the instructions sent to <b>%(email)s</b>", { email: email }, { b: (t) => <b>{t}</b> })}
</p>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("Wrong email address?")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
{_t("Re-enter email address")}
</AccessibleButton>
</div>
</div>
{errorText && <ErrorMessage message={errorText} />}
<input onClick={onSubmitForm} type="button" className="mx_Login_submit" value={_t("Next")} />
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Wrong email address?") }</span>
<AccessibleButton
className="mx_AuthBody_resend-button"
kind="link"
onClick={onReEnterEmailClick}
>
{ _t("Re-enter email address") }
<span className="mx_VerifyEMailDialog_text-light">{_t("Did not receive it?")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
<RetryIcon className="mx_Icon mx_Icon_16" />
{_t("Resend")}
<Tooltip
label={_t("Verification link email resent!")}
alignment={Alignment.Top}
visible={tooltipVisible}
/>
</AccessibleButton>
</div>
</div>
{ errorText && <ErrorMessage message={errorText} /> }
<input
onClick={onSubmitForm}
type="button"
className="mx_Login_submit"
value={_t("Next")}
/>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Did not receive it?") }</span>
<AccessibleButton
className="mx_AuthBody_resend-button"
kind="link"
onClick={onResendClickFn}
>
<RetryIcon className="mx_Icon mx_Icon_16" />
{ _t("Resend") }
<Tooltip
label={_t("Verification link email resent!")}
alignment={Alignment.Top}
visible={tooltipVisible}
/>
</AccessibleButton>
</div>
</>;
</>
);
};

View file

@ -17,7 +17,7 @@ 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 { _t, _td } from "../../../../languageHandler";
import EmailField from "../../../views/auth/EmailField";
import { ErrorMessage } from "../../ErrorMessage";
import Spinner from "../../../views/elements/Spinner";
@ -46,9 +46,7 @@ export const EnterEmail: React.FC<EnterEmailProps> = ({
onLoginClick,
onSubmitForm,
}) => {
const submitButtonChild = loading
? <Spinner w={16} h={16} />
: _t("Send email");
const submitButtonChild = loading ? <Spinner w={16} h={16} /> : _t("Send email");
const emailFieldRef = useRef<Field>(null);
@ -62,49 +60,47 @@ export const EnterEmail: React.FC<EnterEmailProps> = ({
emailFieldRef.current?.validate({ allowEmpty: false, focused: true });
};
return <>
<EmailIcon className="mx_AuthBody_icon" />
<h1>{ _t("Enter your email to reset password") }</h1>
<p className="mx_AuthBody_text">
{
_t(
return (
<>
<EmailIcon className="mx_AuthBody_icon" />
<h1>{_t("Enter your email to reset password")}</h1>
<p className="mx_AuthBody_text">
{_t(
"<b>%(homeserver)s</b> will send you a verification link to let you reset your password.",
{ 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="Email address"
labelRequired={_td("The email address linked to your account must be entered.")}
labelInvalid={_td("The email address doesn't appear to be valid.")}
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={onLoginClick}>
{ _t("Sign in instead") }
</AccessibleButton>
</div>
</fieldset>
</form>
</>;
{ 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="Email address"
labelRequired={_td("The email address linked to your account must be entered.")}
labelInvalid={_td("The email address doesn't appear to be valid.")}
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={onLoginClick}
>
{_t("Sign in instead")}
</AccessibleButton>
</div>
</fieldset>
</form>
</>
);
};

View file

@ -46,55 +46,49 @@ export const VerifyEmailModal: React.FC<Props> = ({
toggleTooltipVisible();
};
return <>
<EmailPromptIcon className="mx_AuthBody_emailPromptIcon" />
<h1>{ _t("Verify your email to continue") }</h1>
<p>
{ _t(
`We need to know its you before resetting your password.
return (
<>
<EmailPromptIcon className="mx_AuthBody_emailPromptIcon" />
<h1>{_t("Verify your email to continue")}</h1>
<p>
{_t(
`We need to know its you before resetting your password.
Click the link in the email we just sent to <b>%(email)s</b>`,
{
email,
},
{
b: sub => <b>{ sub }</b>,
},
) }
</p>
{
email,
},
{
b: (sub) => <b>{sub}</b>,
},
)}
</p>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("Did not receive it?")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
<RetryIcon className="mx_Icon mx_Icon_16" />
{_t("Resend")}
<Tooltip
label={_t("Verification link email resent!")}
alignment={Alignment.Top}
visible={tooltipVisible}
/>
</AccessibleButton>
{errorText && <ErrorMessage message={errorText} />}
</div>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{_t("Wrong email address?")}</span>
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
{_t("Re-enter email address")}
</AccessibleButton>
</div>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Did not receive it?") }</span>
<AccessibleButton
className="mx_AuthBody_resend-button"
kind="link"
onClick={onResendClickFn}
>
<RetryIcon className="mx_Icon mx_Icon_16" />
{ _t("Resend") }
<Tooltip
label={_t("Verification link email resent!")}
alignment={Alignment.Top}
visible={tooltipVisible}
/>
</AccessibleButton>
{ errorText && <ErrorMessage message={errorText} /> }
</div>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Wrong email address?") }</span>
<AccessibleButton
className="mx_AuthBody_resend-button"
kind="link"
onClick={onReEnterEmailClick}
>
{ _t("Re-enter email address") }
</AccessibleButton>
</div>
<AccessibleButton
onClick={onCloseClick}
className="mx_Dialog_cancelButton"
aria-label={_t("Close dialog")}
/>
</>;
onClick={onCloseClick}
className="mx_Dialog_cancelButton"
aria-label={_t("Close dialog")}
/>
</>
);
};

View file

@ -32,10 +32,10 @@ export function AuthHeaderDisplay({ title, icon, serverPicker, children }: Props
const current = context.state.length ? context.state[0] : null;
return (
<Fragment>
{ current?.icon ?? icon }
<h1>{ current?.title ?? title }</h1>
{ children }
{ current?.hideServerPicker !== true && serverPicker }
{current?.icon ?? icon}
<h1>{current?.title ?? title}</h1>
{children}
{current?.hideServerPicker !== true && serverPicker}
</Fragment>
);
}

View file

@ -22,7 +22,7 @@ import { AuthHeaderModifier } from "./AuthHeaderModifier";
export enum AuthHeaderActionType {
Add,
Remove
Remove,
}
interface AuthHeaderAction {
@ -39,14 +39,10 @@ export function AuthHeaderProvider({ children }: PropsWithChildren<{}>) {
case AuthHeaderActionType.Add:
return [action.value, ...state];
case AuthHeaderActionType.Remove:
return (state.length && isEqual(state[0], action.value)) ? state.slice(1) : state;
return state.length && isEqual(state[0], action.value) ? state.slice(1) : state;
}
},
[] as ComponentProps<typeof AuthHeaderModifier>[],
);
return (
<AuthHeaderContext.Provider value={{ state, dispatch }}>
{ children }
</AuthHeaderContext.Provider>
);
return <AuthHeaderContext.Provider value={{ state, dispatch }}>{children}</AuthHeaderContext.Provider>;
}

View file

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

View file

@ -39,26 +39,24 @@ 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") }
</span>
<div className='mx_AudioPlayer_byline'>
<div className="mx_AudioPlayer_mediaInfo">
<span className="mx_AudioPlayer_mediaName">{this.props.mediaName || _t("Unnamed audio")}</span>
<div className="mx_AudioPlayer_byline">
<DurationClock playback={this.props.playback} />
&nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
&nbsp; {/* easiest way to introduce a gap between the components */}
{this.renderFileSize()}
</div>
</div>
</div>
<div className='mx_AudioPlayer_seek'>
<div className="mx_AudioPlayer_seek">
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar

View file

@ -55,7 +55,7 @@ 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 });
});
@ -95,9 +95,11 @@ export default abstract class AudioPlayerBase<T extends IProps = IProps> extends
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("Error downloading audio")}</div>}
</>
);
}
}

View file

@ -44,8 +44,10 @@ export default class Clock extends React.Component<Props> {
}
public render() {
return <span aria-live={this.props["aria-live"]} role={this.props.role} className='mx_Clock'>
{ this.props.formatFn(this.props.seconds) }
</span>;
return (
<span aria-live={this.props["aria-live"]} role={this.props.role} className="mx_Clock">
{this.props.formatFn(this.props.seconds)}
</span>
);
}
}

View file

@ -29,28 +29,25 @@ interface Props {
onDeviceSelect: (device: MediaDeviceInfo) => void;
}
export const DevicesContextMenu: React.FC<Props> = ({
containerRef,
currentDevice,
devices,
onDeviceSelect,
}) => {
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 (
<IconizedContextMenuRadio
key={d.deviceId}
active={d.deviceId === currentDevice?.deviceId}
onClick={() => onDeviceSelect(d)}
label={d.label}
/>
);
});
return <IconizedContextMenu
mountAsChild={false}
onFinished={() => {}}
{...toLeftOrRightOf(containerRef.current.getBoundingClientRect(), 0)}
>
<IconizedContextMenuOptionList>
{ deviceOptions }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
return (
<IconizedContextMenu
mountAsChild={false}
onFinished={() => {}}
{...toLeftOrRightOf(containerRef.current.getBoundingClientRect(), 0)}
>
<IconizedContextMenuOptionList>{deviceOptions}</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
};

View file

@ -52,19 +52,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-test-id="play-pause-button"
className={classes}
title={isPlaying ? _t("Pause") : _t("Play")}
onClick={this.onClick}
disabled={isDisabled}
{...restProps}
/>
);
}
}

View file

@ -74,9 +74,6 @@ export default class PlaybackClock extends React.PureComponent<IProps, IState> {
seconds = this.state.durationSeconds;
}
}
return <Clock
seconds={seconds}
role="timer"
/>;
return <Clock seconds={seconds} role="timer" />;
}
}

View file

@ -44,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
disabled={this.state.playbackPhase === PlaybackState.Decoding}
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 {
@ -84,7 +88,7 @@ export default class RecordingPlayback extends AudioPlayerBase<IProps> {
playbackPhase={this.state.playbackPhase}
ref={this.playPauseRef}
/>
{ body }
{body}
</div>
);
}

View file

@ -37,7 +37,7 @@ interface IState {
}
interface ISeekCSS extends CSSProperties {
'--fillTo': number;
"--fillTo": number;
}
const ARROW_SKIP_SECONDS = 5; // arbitrary
@ -48,7 +48,8 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
private animationFrameFn = new MarkedExecution(
() => this.doUpdate(),
() => requestAnimationFrame(() => this.animationFrameFn.trigger()));
() => requestAnimationFrame(() => this.animationFrameFn.trigger()),
);
public static defaultProps = {
tabIndex: 0,
@ -68,10 +69,7 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
private doUpdate() {
this.setState({
percentage: percentageOf(
this.props.playback.timeSeconds,
0,
this.props.playback.durationSeconds),
percentage: percentageOf(this.props.playback.timeSeconds, 0, this.props.playback.durationSeconds),
});
}
@ -101,18 +99,20 @@ export default class SeekBar extends React.PureComponent<IProps, IState> {
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}
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}
/>;
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}
/>
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import React, { createRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../languageHandler';
import { _t } from "../../../languageHandler";
const DIV_ID = 'mx_recaptcha';
const DIV_ID = "mx_recaptcha";
interface ICaptchaFormProps {
sitePublicKey: string;
@ -28,7 +28,6 @@ interface ICaptchaFormProps {
interface ICaptchaFormState {
errorText?: string;
}
/**
@ -58,10 +57,13 @@ 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);
}
@ -73,9 +75,11 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
// 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) {
@ -87,9 +91,7 @@ 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);
@ -123,20 +125,14 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
render() {
let error = null;
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("This homeserver would like to make sure you are not a robot.")}</p>
<div id={DIV_ID} />
{ error }
{error}
</div>
);
}

View file

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

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from '../../../phonenumber';
import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from "../../../phonenumber";
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
import Dropdown from "../elements/Dropdown";
@ -28,7 +28,7 @@ for (const c of COUNTRIES) {
function countryMatchesSearchQuery(query: string, country: PhoneNumberCountryDefinition): boolean {
// Remove '+' if present (when searching for a prefix)
if (query[0] === '+') {
if (query[0] === "+") {
query = query.slice(1);
}
@ -59,12 +59,12 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0];
const defaultCountryCode = SdkConfig.get("default_country_code");
if (defaultCountryCode) {
const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase());
const country = COUNTRIES.find((c) => c.iso2 === defaultCountryCode.toUpperCase());
if (country) defaultCountry = country;
}
this.state = {
searchQuery: '',
searchQuery: "",
defaultCountry,
};
}
@ -89,7 +89,7 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
};
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 => {
@ -98,24 +98,21 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
}
let countryPrefix;
if (this.props.showPrefix) {
countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix;
countryPrefix = "+" + COUNTRIES_BY_ISO2[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;
if (this.state.searchQuery) {
displayedCountries = COUNTRIES.filter(
countryMatchesSearchQuery.bind(this, this.state.searchQuery),
);
if (
this.state.searchQuery.length == 2 &&
COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()]
) {
displayedCountries = COUNTRIES.filter(countryMatchesSearchQuery.bind(this, this.state.searchQuery));
if (this.state.searchQuery.length == 2 && COUNTRIES_BY_ISO2[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()];
displayedCountries = displayedCountries.filter((c) => {
@ -128,29 +125,33 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
}
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)}
{_t(country.name)} (+{country.prefix})
</div>
);
});
// 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;
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("Country Dropdown")}
>
{options}
</Dropdown>
);
}
}

View file

@ -75,16 +75,18 @@ class EmailField extends PureComponent<IProps> {
};
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}
/>;
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}
/>
);
}
}

View file

@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from 'classnames';
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { AuthType, IAuthDict, IInputs, IStageStatus } from 'matrix-js-sdk/src/interactive-auth';
import { AuthType, IAuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth";
import { logger } from "matrix-js-sdk/src/logger";
import React, { ChangeEvent, createRef, FormEvent, Fragment, MouseEvent } from 'react';
import React, { ChangeEvent, createRef, FormEvent, Fragment, MouseEvent } from "react";
import EmailPromptIcon from '../../../../res/img/element-icons/email-prompt.svg';
import { _t } from '../../../languageHandler';
import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { LocalisedPolicy, Policies } from '../../../Terms';
import { LocalisedPolicy, Policies } from "../../../Terms";
import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier";
import AccessibleButton from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import Field from '../elements/Field';
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import { Alignment } from "../elements/Tooltip";
import CaptchaForm from "./CaptchaForm";
@ -138,7 +138,7 @@ export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswor
render() {
const passwordBoxClass = classNames({
"error": this.props.errorText,
error: this.props.errorText,
});
let submitButtonOrSpinner;
@ -146,7 +146,8 @@ export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswor
submitButtonOrSpinner = <Spinner />;
} else {
submitButtonOrSpinner = (
<input type="submit"
<input
type="submit"
className="mx_Dialog_primary"
disabled={!this.state.password}
value={_t("Continue")}
@ -158,28 +159,26 @@ export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswor
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
{this.props.errorText}
</div>
);
}
return (
<div>
<p>{ _t("Confirm your identity by entering your account password below.") }</p>
<p>{_t("Confirm your identity by entering your account password below.")}</p>
<form onSubmit={this.onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<Field
className={passwordBoxClass}
type="password"
name="passwordField"
label={_t('Password')}
label={_t("Password")}
autoFocus={true}
value={this.state.password}
onChange={this.onPasswordFieldChange}
/>
{ errorSection }
<div className="mx_button_row">
{ submitButtonOrSpinner }
</div>
{errorSection}
<div className="mx_button_row">{submitButtonOrSpinner}</div>
</form>
</div>
);
@ -210,9 +209,7 @@ export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps
render() {
if (this.props.busy) {
return (
<Spinner />
);
return <Spinner />;
}
let errorText = this.props.errorText;
@ -221,7 +218,7 @@ export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps
if (!this.props.stageParams || !this.props.stageParams.public_key) {
errorText = _t(
"Missing captcha public key in homeserver configuration. Please report " +
"this to your homeserver administrator.",
"this to your homeserver administrator.",
);
} else {
sitePublicKey = this.props.stageParams.public_key;
@ -231,17 +228,15 @@ export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps
if (errorText) {
errorSection = (
<div className="error" role="alert">
{ errorText }
{errorText}
</div>
);
}
return (
<div>
<CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this.onCaptchaResponse}
/>
{ errorSection }
<CaptchaForm sitePublicKey={sitePublicKey} onCaptchaResponse={this.onCaptchaResponse} />
{errorSection}
</div>
);
}
@ -305,7 +300,7 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
if (!langPolicy) langPolicy = policy["en"];
if (!langPolicy) {
// last resort
const firstLang = Object.keys(policy).find(e => e !== "version");
const firstLang = Object.keys(policy).find((e) => e !== "version");
langPolicy = policy[firstLang];
}
if (!langPolicy) throw new Error("Failed to find a policy to show the user");
@ -337,7 +332,7 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
newToggles[policy.id] = checked;
}
this.setState({ "toggledPolicies": newToggles });
this.setState({ toggledPolicies: newToggles });
}
private trySubmit = () => {
@ -356,9 +351,7 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
render() {
if (this.props.busy) {
return (
<Spinner />
);
return <Spinner />;
}
const checkboxes = [];
@ -371,7 +364,9 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
// XXX: replace with StyledCheckbox
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
<input type="checkbox" onChange={() => this.togglePolicy(policy.id)} checked={checked} />
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
<a href={policy.url} target="_blank" rel="noreferrer noopener">
{policy.name}
</a>
</label>,
);
}
@ -380,7 +375,7 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
if (this.props.errorText || this.state.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText || this.state.errorText }
{this.props.errorText || this.state.errorText}
</div>
);
}
@ -388,18 +383,23 @@ export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITerms
let submitButton;
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button
className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit}
disabled={!allChecked}>{ _t("Accept") }</button>;
submitButton = (
<button
className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this.trySubmit}
disabled={!allChecked}
>
{_t("Accept")}
</button>
);
}
return (
<div>
<p>{ _t("Please review and accept the policies of this homeserver:") }</p>
{ checkboxes }
{ errorSection }
{ submitButton }
<p>{_t("Please review and accept the policies of this homeserver:")}</p>
{checkboxes}
{errorSection}
{submitButton}
</div>
);
}
@ -419,8 +419,10 @@ interface IEmailIdentityAuthEntryState {
requesting: boolean;
}
export class EmailIdentityAuthEntry extends
React.Component<IEmailIdentityAuthEntryProps, IEmailIdentityAuthEntryState> {
export class EmailIdentityAuthEntry extends React.Component<
IEmailIdentityAuthEntryProps,
IEmailIdentityAuthEntryState
> {
static LOGIN_TYPE = AuthType.Email;
constructor(props: IEmailIdentityAuthEntryProps) {
@ -442,7 +444,7 @@ export class EmailIdentityAuthEntry extends
if (this.props.errorText && this.props.errorCode !== "M_UNAUTHORIZED") {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
{this.props.errorText}
</div>
);
}
@ -466,49 +468,65 @@ export class EmailIdentityAuthEntry extends
<div className="mx_InteractiveAuthEntryComponents_emailWrapper">
<AuthHeaderModifier
title={_t("Check your email to continue")}
icon={<img
src={EmailPromptIcon}
alt={_t("Unread email icon")}
width={16}
/>}
icon={<img src={EmailPromptIcon} alt={_t("Unread email icon")} width={16} />}
hideServerPicker={true}
/>
<p>{ _t("To create your account, open the link in the email we just sent to %(emailAddress)s.",
{ emailAddress: <b>{ this.props.inputs.emailAddress }</b> },
) }</p>
{ this.state.requesting ? (
<p className="secondary">{ _t("Did not receive it? <a>Resend it</a>", {}, {
a: (text: string) => <Fragment>
<AccessibleButton
kind='link_inline'
onClick={() => null}
disabled
>{ text } <Spinner w={14} h={14} /></AccessibleButton>
</Fragment>,
}) }</p>
) : <p className="secondary">{ _t("Did not receive it? <a>Resend it</a>", {}, {
a: (text: string) => <AccessibleTooltipButton
kind='link_inline'
title={this.state.requested
? _t("Resent!")
: _t("Resend")}
alignment={Alignment.Right}
onHideTooltip={this.state.requested
? () => this.setState({ requested: false })
: undefined}
onClick={async () => {
this.setState({ requesting: true });
try {
await this.props.requestEmailToken?.();
} catch (e) {
logger.warn("Email token request failed: ", e);
} finally {
this.setState({ requested: true, requesting: false });
}
}}
>{ text }</AccessibleTooltipButton>,
}) }</p> }
{ errorSection }
<p>
{_t("To create your account, open the link in the email we just sent to %(emailAddress)s.", {
emailAddress: <b>{this.props.inputs.emailAddress}</b>,
})}
</p>
{this.state.requesting ? (
<p className="secondary">
{_t(
"Did not receive it? <a>Resend it</a>",
{},
{
a: (text: string) => (
<Fragment>
<AccessibleButton kind="link_inline" onClick={() => null} disabled>
{text} <Spinner w={14} h={14} />
</AccessibleButton>
</Fragment>
),
},
)}
</p>
) : (
<p className="secondary">
{_t(
"Did not receive it? <a>Resend it</a>",
{},
{
a: (text: string) => (
<AccessibleTooltipButton
kind="link_inline"
title={this.state.requested ? _t("Resent!") : _t("Resend")}
alignment={Alignment.Right}
onHideTooltip={
this.state.requested
? () => this.setState({ requested: false })
: undefined
}
onClick={async () => {
this.setState({ requesting: true });
try {
await this.props.requestEmailToken?.();
} catch (e) {
logger.warn("Email token request failed: ", e);
} finally {
this.setState({ requested: true, requesting: false });
}
}}
>
{text}
</AccessibleTooltipButton>
),
},
)}
</p>
)}
{errorSection}
</div>
);
}
@ -541,9 +559,9 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
super(props);
this.state = {
token: '',
token: "",
requestingToken: false,
errorText: '',
errorText: "",
};
}
@ -551,27 +569,31 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
this.props.onPhaseChange(DEFAULT_PHASE);
this.setState({ requestingToken: true });
this.requestMsisdnToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({ requestingToken: false });
});
this.requestMsisdnToken()
.catch((e) => {
this.props.fail(e);
})
.finally(() => {
this.setState({ requestingToken: false });
});
}
/*
* Requests a verification token by SMS.
*/
private requestMsisdnToken(): Promise<void> {
return this.props.matrixClient.requestRegisterMsisdnToken(
this.props.inputs.phoneCountry,
this.props.inputs.phoneNumber,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
).then((result) => {
this.submitUrl = result.submit_url;
this.sid = result.sid;
this.msisdn = result.msisdn;
});
return this.props.matrixClient
.requestRegisterMsisdnToken(
this.props.inputs.phoneCountry,
this.props.inputs.phoneNumber,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
)
.then((result) => {
this.submitUrl = result.submit_url;
this.sid = result.sid;
this.msisdn = result.msisdn;
});
}
private onTokenChange = (e: ChangeEvent<HTMLInputElement>) => {
@ -582,7 +604,7 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
private onFormSubmit = async (e: FormEvent) => {
e.preventDefault();
if (this.state.token == '') return;
if (this.state.token == "") return;
this.setState({
errorText: null,
@ -592,7 +614,10 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
let result;
if (this.submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this.submitUrl, this.sid, this.props.clientSecret, this.state.token,
this.submitUrl,
this.sid,
this.props.clientSecret,
this.state.token,
);
} else {
throw new Error("The registration with MSISDN flow is misconfigured");
@ -623,9 +648,7 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
render() {
if (this.state.requestingToken) {
return (
<Spinner />
);
return <Spinner />;
} else {
const enableSubmit = Boolean(this.state.token);
const submitClasses = classNames({
@ -636,20 +659,18 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
if (this.state.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.state.errorText }
{this.state.errorText}
</div>
);
}
return (
<div>
<p>{ _t("A text message has been sent to %(msisdn)s",
{ msisdn: <i>{ this.msisdn }</i> },
) }
</p>
<p>{ _t("Please enter the code it contains:") }</p>
<p>{_t("A text message has been sent to %(msisdn)s", { msisdn: <i>{this.msisdn}</i> })}</p>
<p>{_t("Please enter the code it contains:")}</p>
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
<form onSubmit={this.onFormSubmit}>
<input type="text"
<input
type="text"
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token}
onChange={this.onTokenChange}
@ -663,7 +684,7 @@ export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsi
disabled={!enableSubmit}
/>
</form>
{ errorSection }
{errorSection}
</div>
</div>
);
@ -697,10 +718,7 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
// We actually send the user through fallback auth so we don't have to
// deal with a redirect back to us, losing application context.
this.ssoUrl = props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId,
);
this.ssoUrl = props.matrixClient.getFallbackAuthUrl(this.props.loginType, this.props.authSessionId);
this.popupWindow = null;
window.addEventListener("message", this.onReceiveMessage);
@ -757,22 +775,22 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
const cancelButton = (
<AccessibleButton
onClick={this.props.onCancel}
kind={this.props.continueKind ? (this.props.continueKind + '_outline') : 'primary_outline'}
>{ _t("Cancel") }</AccessibleButton>
kind={this.props.continueKind ? this.props.continueKind + "_outline" : "primary_outline"}
>
{_t("Cancel")}
</AccessibleButton>
);
if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) {
continueButton = (
<AccessibleButton
onClick={this.onStartAuthClick}
kind={this.props.continueKind || 'primary'}
>{ this.props.continueText || _t("Single Sign On") }</AccessibleButton>
<AccessibleButton onClick={this.onStartAuthClick} kind={this.props.continueKind || "primary"}>
{this.props.continueText || _t("Single Sign On")}
</AccessibleButton>
);
} else {
continueButton = (
<AccessibleButton
onClick={this.onConfirmClick}
kind={this.props.continueKind || 'primary'}
>{ this.props.continueText || _t("Confirm") }</AccessibleButton>
<AccessibleButton onClick={this.onConfirmClick} kind={this.props.continueKind || "primary"}>
{this.props.continueText || _t("Confirm")}
</AccessibleButton>
);
}
@ -780,23 +798,23 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
{this.props.errorText}
</div>
);
} else if (this.state.attemptFailed) {
errorSection = (
<div className="error" role="alert">
{ _t("Something went wrong in confirming your identity. Cancel and try again.") }
{_t("Something went wrong in confirming your identity. Cancel and try again.")}
</div>
);
}
return (
<Fragment>
{ errorSection }
{errorSection}
<div className="mx_InteractiveAuthEntryComponents_sso_buttons">
{ cancelButton }
{ continueButton }
{cancelButton}
{continueButton}
</div>
</Fragment>
);
@ -837,18 +855,12 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
e.preventDefault();
e.stopPropagation();
const url = this.props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId,
);
const url = this.props.matrixClient.getFallbackAuthUrl(this.props.loginType, this.props.authSessionId);
this.popupWindow = window.open(url, "_blank");
};
private onReceiveMessage = (event: MessageEvent) => {
if (
event.data === "authDone" &&
event.origin === this.props.matrixClient.getHomeserverUrl()
) {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
this.props.submitAuthDict({});
}
};
@ -858,16 +870,16 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
{this.props.errorText}
</div>
);
}
return (
<div>
<AccessibleButton kind='link' inputRef={this.fallbackButton} onClick={this.onShowFallbackClick}>{
_t("Start authentication")
}</AccessibleButton>
{ errorSection }
<AccessibleButton kind="link" inputRef={this.fallbackButton} onClick={this.onShowFallbackClick}>
{_t("Start authentication")}
</AccessibleButton>
{errorSection}
</div>
);
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import SdkConfig from "../../../SdkConfig";
import { getCurrentLanguage } from "../../../languageHandler";
@ -36,10 +36,12 @@ interface IProps {
export default function LanguageSelector({ disabled }: IProps): JSX.Element {
if (SdkConfig.get("disable_login_language_selector")) return <div />;
return <LanguageDropdown
className="mx_AuthBody_language"
onOptionChange={onChange}
value={getCurrentLanguage()}
disabled={disabled}
/>;
return (
<LanguageDropdown
className="mx_AuthBody_language"
onOptionChange={onChange}
value={getCurrentLanguage()}
disabled={disabled}
/>
);
}

View file

@ -14,16 +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, 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 { _t } from "../../../languageHandler";
import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth';
import LoginWithQRFlow from './LoginWithQRFlow';
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.
@ -117,7 +117,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 });
@ -145,7 +145,7 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
await this.state.rendezvous.verifyNewDeviceOnExistingDevice();
this.props.onFinished(true);
} catch (e) {
logger.error('Error whilst approving sign in', e);
logger.error("Error whilst approving sign in", e);
this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown });
}
};
@ -159,7 +159,9 @@ export default class LoginWithQR extends React.Component<IProps, IState> {
});
const channel = new MSC3903ECDHv1RendezvousChannel<MSC3906RendezvousPayload>(
transport, undefined, this.onFailure,
transport,
undefined,
this.onFailure,
);
rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure);
@ -171,7 +173,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;
}
@ -180,7 +182,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 });

View file

@ -14,18 +14,18 @@ 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 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 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, Phase } from './LoginWithQR';
import { Click, Phase } from "./LoginWithQR";
interface IProps {
phase: Phase;
@ -52,25 +52,25 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
};
};
private cancelButton = () => <AccessibleButton
data-testid="cancel-button"
kind="primary_outline"
onClick={this.handleClick(Click.Cancel)}
>
{ _t("Cancel") }
</AccessibleButton>;
private cancelButton = () => (
<AccessibleButton data-testid="cancel-button" kind="primary_outline" onClick={this.handleClick(Click.Cancel)}>
{_t("Cancel")}
</AccessibleButton>
);
private simpleSpinner = (description?: string): JSX.Element => {
return <div className="mx_LoginWithQR_spinner">
<div>
<Spinner />
{ description && <p>{ description }</p> }
return (
<div className="mx_LoginWithQR_spinner">
<div>
<Spinner />
{description && <p>{description}</p>}
</div>
</div>
</div>;
);
};
public render() {
let title = '';
let title = "";
let titleIcon: JSX.Element | undefined;
let main: JSX.Element | undefined;
let buttons: JSX.Element | undefined;
@ -116,67 +116,80 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
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("Try again") }
</AccessibleButton>
{ this.cancelButton() }
</>;
main = <p data-testid="cancellation-message">{cancellationMessage}</p>;
buttons = (
<>
<AccessibleButton
data-testid="try-again-button"
kind="primary"
onClick={this.handleClick(Click.TryAgain)}
>
{_t("Try again")}
</AccessibleButton>
{this.cancelButton()}
</>
);
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.props.confirmationDigits }
</div>
<div className="mx_LoginWithQR_confirmationAlert">
<div>
<InfoIcon />
main = (
<>
<p>{_t("Check that the code below matches with your other device:")}</p>
<div className="mx_LoginWithQR_confirmationDigits">{this.props.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>
<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.handleClick(Click.Decline)}
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
data-testid="approve-login-button"
kind="primary"
onClick={this.handleClick(Click.Approve)}
>
{ _t("Approve") }
</AccessibleButton>
</>;
buttons = (
<>
<AccessibleButton
data-testid="decline-login-button"
kind="primary_outline"
onClick={this.handleClick(Click.Decline)}
>
{_t("Cancel")}
</AccessibleButton>
<AccessibleButton
data-testid="approve-login-button"
kind="primary"
onClick={this.handleClick(Click.Approve)}
>
{_t("Approve")}
</AccessibleButton>
</>
);
break;
case Phase.ShowingQR:
title =_t("Sign in with QR code");
title = _t("Sign in with QR code");
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("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 }
</>;
const code = (
<div className="mx_LoginWithQR_qrWrapper">
<QRCode
data={[{ data: Buffer.from(this.props.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();
@ -203,7 +216,7 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
return (
<div data-testid="login-with-qr" className="mx_LoginWithQR">
<div className={centreTitle ? "mx_LoginWithQR_centreTitle" : ""}>
{ backButton ?
{backButton ? (
<AccessibleButton
data-testid="back-button"
className="mx_LoginWithQR_BackButton"
@ -212,15 +225,14 @@ export default class LoginWithQRFlow extends React.Component<IProps> {
>
<BackButtonIcon />
</AccessibleButton>
: null }
<h1>{ titleIcon }{ title }</h1>
</div>
<div className="mx_LoginWithQR_main">
{ main }
</div>
<div className="mx_LoginWithQR_buttons">
{ buttons }
) : null}
<h1>
{titleIcon}
{title}
</h1>
</div>
<div className="mx_LoginWithQR_main">{main}</div>
<div className="mx_LoginWithQR_buttons">{buttons}</div>
</div>
);
}

View file

@ -66,16 +66,18 @@ class PassphraseConfirmField extends PureComponent<IProps> {
};
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}
/>;
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}
/>
);
}
}

View file

@ -49,13 +49,13 @@ class PassphraseField extends PureComponent<IProps> {
};
public readonly validate = withValidation<this, zxcvbn.ZXCVBNResult>({
description: function(complexity) {
description: function (complexity) {
const score = complexity ? complexity.score : 0;
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
},
deriveData: async ({ value }) => {
if (!value) return null;
const { scorePassword } = await import('../../../utils/PasswordScorer');
const { scorePassword } = await import("../../../utils/PasswordScorer");
return scorePassword(value);
},
rules: [
@ -66,7 +66,7 @@ class PassphraseField extends PureComponent<IProps> {
},
{
key: "complexity",
test: async function({ value }, complexity) {
test: async function ({ value }, complexity) {
if (!value) {
return false;
}
@ -74,7 +74,7 @@ class PassphraseField extends PureComponent<IProps> {
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.
@ -83,7 +83,7 @@ class PassphraseField extends PureComponent<IProps> {
}
return _t(this.props.labelAllowedButUnsafe);
},
invalid: function(complexity) {
invalid: function (complexity) {
if (!complexity) {
return null;
}
@ -103,18 +103,20 @@ class PassphraseField extends PureComponent<IProps> {
};
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}
/>;
return (
<Field
id={this.props.id}
autoFocus={this.props.autoFocus}
className={classNames("mx_PassphraseField", this.props.className)}
ref={this.props.fieldRef}
type="password"
autoComplete="new-password"
label={_t(this.props.label)}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>
);
}
}

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import React from "react";
import classNames from "classnames";
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
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 Field from "../elements/Field";
@ -67,10 +67,10 @@ enum LoginField {
*/
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
static defaultProps = {
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
onUsernameChanged: function () {},
onUsernameBlur: function () {},
onPhoneCountryChanged: function () {},
onPhoneNumberChanged: function () {},
loginIncorrect: false,
disableSubmit: false,
};
@ -85,13 +85,13 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
};
}
private onForgotPasswordClick = ev => {
private onForgotPasswordClick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
};
private onSubmitForm = async ev => {
private onSubmitForm = async (ev) => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
@ -99,7 +99,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return;
}
let username = ''; // XXX: Synapse breaks if you send null here:
let username = ""; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
@ -117,29 +117,29 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
};
private onUsernameChanged = ev => {
private onUsernameChanged = (ev) => {
this.props.onUsernameChanged(ev.target.value);
};
private onUsernameBlur = ev => {
private onUsernameBlur = (ev) => {
this.props.onUsernameBlur(ev.target.value);
};
private onLoginTypeChange = ev => {
private onLoginTypeChange = (ev) => {
const loginType = ev.target.value;
this.setState({ loginType });
this.props.onUsernameChanged(""); // Reset because email and username use the same state
};
private onPhoneCountryChanged = country => {
private onPhoneCountryChanged = (country) => {
this.props.onPhoneCountryChanged(country.iso2);
};
private onPhoneNumberChanged = ev => {
private onPhoneNumberChanged = (ev) => {
this.props.onPhoneNumberChanged(ev.target.value);
};
private onPasswordChanged = ev => {
private onPasswordChanged = (ev) => {
this.setState({ password: ev.target.value });
};
@ -151,10 +151,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 +169,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;
@ -242,7 +239,8 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return allowEmpty || !!value;
},
invalid: () => _t("Enter phone number"),
}, {
},
{
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"),
@ -282,67 +280,75 @@ 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) => (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("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)}
/>
);
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("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)}
/>
);
}
}
}
@ -361,14 +367,16 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
let forgotPasswordJsx;
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("Forgot password?")}
</AccessibleButton>
);
}
const pwFieldClass = classNames({
@ -384,7 +392,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("Sign in with")}</label>
<Field
element="select"
value={this.state.loginType}
@ -392,16 +400,13 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
disabled={this.props.busy}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{ _t('Username') }
{_t("Username")}
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{ _t('Email address') }
<option key={LoginField.Email} value={LoginField.Email}>
{_t("Email address")}
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{ _t('Phone') }
{_t("Phone")}
</option>
</Field>
</div>
@ -411,28 +416,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("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("Sign in")}
disabled={this.props.disableSubmit}
/>
)}
</form>
</div>
);

View file

@ -15,26 +15,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixError } from 'matrix-js-sdk/src/matrix';
import { MatrixError } from "matrix-js-sdk/src/matrix";
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation, { IValidationResult } from '../elements/Validation';
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
import * as Email from "../../../email";
import { looksValid as phoneNumberLooksValid } from "../../../phonenumber";
import Modal from "../../../Modal";
import { _t, _td } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import { SAFE_LOCALPART_REGEX } from "../../../Registration";
import withValidation, { IValidationResult } from "../elements/Validation";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import EmailField from "./EmailField";
import PassphraseField from "./PassphraseField";
import Field from '../elements/Field';
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
import Field from "../elements/Field";
import RegistrationEmailPromptDialog from "../dialogs/RegistrationEmailPromptDialog";
import CountryDropdown from "./CountryDropdown";
import PassphraseConfirmField from "./PassphraseConfirmField";
import { PosthogAnalytics } from '../../../PosthogAnalytics';
import { PosthogAnalytics } from "../../../PosthogAnalytics";
enum RegistrationField {
Email = "field_email",
@ -115,7 +115,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
};
}
private onSubmit = async ev => {
private onSubmit = async (ev) => {
ev.preventDefault();
ev.persist();
@ -126,16 +126,19 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
return;
}
if (this.state.email === '') {
if (this.state.email === "") {
if (this.showEmail()) {
Modal.createDialog(RegistrationEmailPromptDialog, {
onFinished: async (confirmed: boolean, email?: string) => {
if (confirmed) {
this.setState({
email,
}, () => {
this.doSubmit(ev);
});
this.setState(
{
email,
},
() => {
this.doSubmit(ev);
},
);
}
},
});
@ -164,7 +167,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
if (promise) {
ev.target.disabled = true;
promise.finally(function() {
promise.finally(function () {
ev.target.disabled = false;
});
}
@ -202,7 +205,7 @@ export default class RegistrationForm 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;
@ -245,7 +248,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
});
}
private onEmailChange = ev => {
private onEmailChange = (ev) => {
this.setState({
email: ev.target.value.trim(),
});
@ -262,7 +265,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
{
key: "required",
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
return allowEmpty || !this.authStepIsRequired("m.login.email.identity") || !!value;
},
invalid: () => _t("Enter email address (required on this homeserver)"),
},
@ -274,17 +277,17 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
],
});
private onPasswordChange = ev => {
private onPasswordChange = (ev) => {
this.setState({
password: ev.target.value,
});
};
private onPasswordValidate = result => {
private onPasswordValidate = (result) => {
this.markFieldValid(RegistrationField.Password, result.valid);
};
private onPasswordConfirmChange = ev => {
private onPasswordConfirmChange = (ev) => {
this.setState({
passwordConfirm: ev.target.value,
});
@ -294,19 +297,19 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
};
private onPhoneCountryChange = newVal => {
private onPhoneCountryChange = (newVal) => {
this.setState({
phoneCountry: newVal.iso2,
});
};
private onPhoneNumberChange = ev => {
private onPhoneNumberChange = (ev) => {
this.setState({
phoneNumber: ev.target.value,
});
};
private onPhoneNumberValidate = async fieldState => {
private onPhoneNumberValidate = async (fieldState) => {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
return result;
@ -319,7 +322,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
{
key: "required",
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
return allowEmpty || !this.authStepIsRequired("m.login.msisdn") || !!value;
},
invalid: () => _t("Enter phone number (required on this homeserver)"),
},
@ -331,13 +334,13 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
],
});
private onUsernameChange = ev => {
private onUsernameChange = (ev) => {
this.setState({
username: ev.target.value,
});
};
private onUsernameValidate = async fieldState => {
private onUsernameValidate = async (fieldState) => {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(RegistrationField.Username, result.valid);
return result;
@ -373,8 +376,9 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
},
{
key: "safeLocalpart",
test: ({ value }, usernameAvailable) => (!value || SAFE_LOCALPART_REGEX.test(value))
&& usernameAvailable !== UsernameAvailableStatus.Invalid,
test: ({ value }, usernameAvailable) =>
(!value || SAFE_LOCALPART_REGEX.test(value)) &&
usernameAvailable !== UsernameAvailableStatus.Invalid,
invalid: () => _t("Some characters not allowed"),
},
{
@ -387,9 +391,10 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
return usernameAvailable === UsernameAvailableStatus.Available;
},
invalid: (usernameAvailable) => usernameAvailable === UsernameAvailableStatus.Error
? _t("Unable to check if username has been taken. Try again later.")
: _t("Someone already has that username. Try another or if it is you, sign in below."),
invalid: (usernameAvailable) =>
usernameAvailable === UsernameAvailableStatus.Error
? _t("Unable to check if username has been taken. Try again later.")
: _t("Someone already has that username. Try another or if it is you, sign in below."),
},
],
});
@ -419,7 +424,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
}
private showEmail() {
if (!this.authStepIsUsed('m.login.email.identity')) {
if (!this.authStepIsUsed("m.login.email.identity")) {
return false;
}
return true;
@ -427,7 +432,7 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
private showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) {
if (!threePidLogin || !this.authStepIsUsed("m.login.msisdn")) {
return false;
}
return true;
@ -437,78 +442,86 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
if (!this.showEmail()) {
return null;
}
const emailLabel = this.authStepIsRequired('m.login.email.identity') ?
_td("Email") :
_td("Email (optional)");
return <EmailField
fieldRef={field => this[RegistrationField.Email] = field}
label={emailLabel}
value={this.state.email}
validationRules={this.validateEmailRules.bind(this)}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
/>;
const emailLabel = this.authStepIsRequired("m.login.email.identity") ? _td("Email") : _td("Email (optional)");
return (
<EmailField
fieldRef={(field) => (this[RegistrationField.Email] = field)}
label={emailLabel}
value={this.state.email}
validationRules={this.validateEmailRules.bind(this)}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
/>
);
}
private renderPassword() {
return <PassphraseField
id="mx_RegistrationForm_password"
fieldRef={field => this[RegistrationField.Password] = field}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
/>;
return (
<PassphraseField
id="mx_RegistrationForm_password"
fieldRef={(field) => (this[RegistrationField.Password] = field)}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
/>
);
}
renderPasswordConfirm() {
return <PassphraseConfirmField
id="mx_RegistrationForm_passwordConfirm"
fieldRef={field => this[RegistrationField.PasswordConfirm] = field}
autoComplete="new-password"
value={this.state.passwordConfirm}
password={this.state.password}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
/>;
return (
<PassphraseConfirmField
id="mx_RegistrationForm_passwordConfirm"
fieldRef={(field) => (this[RegistrationField.PasswordConfirm] = field)}
autoComplete="new-password"
value={this.state.passwordConfirm}
password={this.state.password}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
/>
);
}
renderPhoneNumber() {
if (!this.showPhoneNumber()) {
return null;
}
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
ref={field => this[RegistrationField.PhoneNumber] = field}
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>;
const phoneLabel = this.authStepIsRequired("m.login.msisdn") ? _t("Phone") : _t("Phone (optional)");
const phoneCountry = (
<CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>
);
return (
<Field
ref={(field) => (this[RegistrationField.PhoneNumber] = field)}
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>
);
}
renderUsername() {
return <Field
id="mx_RegistrationForm_username"
ref={field => this[RegistrationField.Username] = field}
type="text"
autoFocus={true}
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
/>;
return (
<Field
id="mx_RegistrationForm_username"
ref={(field) => (this[RegistrationField.Username] = field)}
type="text"
autoFocus={true}
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
/>
);
}
render() {
@ -519,40 +532,36 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
let emailHelperText = null;
if (this.showEmail()) {
if (this.showPhoneNumber()) {
emailHelperText = <div>
{
_t("Add an email to be able to reset your password.")
} {
_t("Use email or phone to optionally be discoverable by existing contacts.")
}
</div>;
emailHelperText = (
<div>
{_t("Add an email to be able to reset your password.")}{" "}
{_t("Use email or phone to optionally be discoverable by existing contacts.")}
</div>
);
} else {
emailHelperText = <div>
{
_t("Add an email to be able to reset your password.")
} {
_t("Use email to optionally be discoverable by existing contacts.")
}
</div>;
emailHelperText = (
<div>
{_t("Add an email to be able to reset your password.")}{" "}
{_t("Use email to optionally be discoverable by existing contacts.")}
</div>
);
}
}
return (
<div>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">{this.renderUsername()}</div>
<div className="mx_AuthBody_fieldRow">
{ this.renderUsername() }
{this.renderPassword()}
{this.renderPasswordConfirm()}
</div>
<div className="mx_AuthBody_fieldRow">
{ this.renderPassword() }
{ this.renderPasswordConfirm() }
{this.renderEmail()}
{this.renderPhoneNumber()}
</div>
<div className="mx_AuthBody_fieldRow">
{ this.renderEmail() }
{ this.renderPhoneNumber() }
</div>
{ emailHelperText }
{ registerButton }
{emailHelperText}
{registerButton}
</form>
</div>
);

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import classNames from "classnames";
import SdkConfig from '../../../SdkConfig';
import SdkConfig from "../../../SdkConfig";
import AuthPage from "./AuthPage";
import { _td } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
@ -29,9 +29,7 @@ import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars";
// translatable strings for Welcome pages
_td("Sign in with SSO");
interface IProps {
}
interface IProps {}
export default class Welcome extends React.PureComponent<IProps> {
public render(): React.ReactNode {
@ -41,14 +39,16 @@ export default class Welcome extends React.PureComponent<IProps> {
pageUrl = pagesConfig.get("welcome_url");
}
if (!pageUrl) {
pageUrl = 'welcome.html';
pageUrl = "welcome.html";
}
return (
<AuthPage>
<div className={classNames("mx_Welcome", {
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
})}>
<div
className={classNames("mx_Welcome", {
mx_WelcomePage_registrationDisabled: !SettingsStore.getValue(UIFeature.Registration),
})}
>
<EmbeddedPage
className="mx_WelcomePage"
url={pageUrl}

View file

@ -17,19 +17,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import React, { useCallback, useContext, useEffect, useState } from "react";
import classNames from "classnames";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { ClientEvent } from "matrix-js-sdk/src/client";
import * as AvatarLogic from '../../../Avatar';
import * as AvatarLogic from "../../../Avatar";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
import AccessibleButton from "../elements/AccessibleButton";
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units";
import { _t } from '../../../languageHandler';
import { _t } from "../../../languageHandler";
interface IProps {
name: string; // The name (first initial used as default)
@ -70,14 +70,13 @@ const useImageUrl = ({ url, urls }): [string, () => void] => {
// Since this is a hot code path and the settings store can be slow, we
// use the cached lowBandwidth value from the room context if it exists
const roomContext = useContext(RoomContext);
const lowBandwidth = roomContext ?
roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
const lowBandwidth = roomContext ? roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
const [urlsIndex, setIndex] = useState<number>(0);
const onError = useCallback(() => {
setIndex(i => i + 1); // try the next one
setIndex((i) => i + 1); // try the next one
}, []);
useEffect(() => {
@ -131,7 +130,7 @@ const BaseAvatar = (props: IProps) => {
lineHeight: toPx(height),
}}
>
{ initialLetter }
{initialLetter}
</span>
);
const imgNode = (
@ -146,7 +145,8 @@ const BaseAvatar = (props: IProps) => {
height: toPx(height),
}}
aria-hidden="true"
data-testid="avatar-img" />
data-testid="avatar-img"
/>
);
if (onClick) {
@ -160,8 +160,8 @@ const BaseAvatar = (props: IProps) => {
onClick={onClick}
inputRef={inputRef}
>
{ textNode }
{ imgNode }
{textNode}
{imgNode}
</AccessibleButton>
);
} else {
@ -172,8 +172,8 @@ const BaseAvatar = (props: IProps) => {
{...otherProps}
role="presentation"
>
{ textNode }
{ imgNode }
{textNode}
{imgNode}
</span>
);
}
@ -183,7 +183,7 @@ const BaseAvatar = (props: IProps) => {
return (
<AccessibleButton
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
element='img'
element="img"
src={imageUrl}
onClick={onClick}
onError={onError}
@ -195,7 +195,8 @@ const BaseAvatar = (props: IProps) => {
alt={_t("Avatar")}
inputRef={inputRef}
data-testid="avatar-img"
{...otherProps} />
{...otherProps}
/>
);
} else {
return (
@ -211,7 +212,8 @@ const BaseAvatar = (props: IProps) => {
alt=""
ref={inputRef}
data-testid="avatar-img"
{...otherProps} />
{...otherProps}
/>
);
}
};

View file

@ -24,7 +24,7 @@ import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
import RoomAvatar from "./RoomAvatar";
import NotificationBadge from '../rooms/NotificationBadge';
import NotificationBadge from "../rooms/NotificationBadge";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import { isPresenceEnabled } from "../../../utils/presence";
@ -144,14 +144,14 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
let icon = Icon.None;
const isOnline = this.dmUser.currentlyActive || this.dmUser.presence === 'online';
const isOnline = this.dmUser.currentlyActive || this.dmUser.presence === "online";
if (BUSY_PRESENCE_NAME.matches(this.dmUser.presence)) {
icon = Icon.PresenceBusy;
} else if (isOnline) {
icon = Icon.PresenceOnline;
} else if (this.dmUser.presence === 'offline') {
} else if (this.dmUser.presence === "offline") {
icon = Icon.PresenceOffline;
} else if (this.dmUser.presence === 'unavailable') {
} else if (this.dmUser.presence === "unavailable") {
icon = Icon.PresenceAway;
}
@ -183,36 +183,42 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
public render(): React.ReactNode {
let badge: React.ReactNode;
if (this.props.displayBadge) {
badge = <NotificationBadge
notification={this.state.notificationState}
forceCount={this.props.forceCount}
roomId={this.props.room.roomId}
/>;
badge = (
<NotificationBadge
notification={this.state.notificationState}
forceCount={this.props.forceCount}
roomId={this.props.room.roomId}
/>
);
}
let icon;
if (this.state.icon !== Icon.None) {
icon = <TextWithTooltip
tooltip={tooltipText(this.state.icon)}
tooltipProps={this.props.tooltipProps}
class={`mx_DecoratedRoomAvatar_icon mx_DecoratedRoomAvatar_icon_${this.state.icon.toLowerCase()}`}
/>;
icon = (
<TextWithTooltip
tooltip={tooltipText(this.state.icon)}
tooltipProps={this.props.tooltipProps}
class={`mx_DecoratedRoomAvatar_icon mx_DecoratedRoomAvatar_icon_${this.state.icon.toLowerCase()}`}
/>
);
}
const classes = classNames("mx_DecoratedRoomAvatar", {
mx_DecoratedRoomAvatar_cutout: icon,
});
return <div className={classes}>
<RoomAvatar
room={this.props.room}
width={this.props.avatarSize}
height={this.props.avatarSize}
oobData={this.props.oobData}
viewAvatarOnClick={this.props.viewAvatarOnClick}
/>
{ icon }
{ badge }
</div>;
return (
<div className={classes}>
<RoomAvatar
room={this.props.room}
width={this.props.avatarSize}
height={this.props.avatarSize}
oobData={this.props.oobData}
viewAvatarOnClick={this.props.viewAvatarOnClick}
/>
{icon}
{badge}
</div>
);
}
}

View file

@ -15,17 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from 'react';
import React, { useContext } from "react";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import BaseAvatar from "./BaseAvatar";
import { mediaFromMxc } from "../../../customisations/Media";
import { CardContext } from '../right_panel/context';
import UserIdentifierCustomisations from '../../../customisations/UserIdentifier';
import { useRoomMemberProfile } from '../../../hooks/room/useRoomMemberProfile';
import { CardContext } from "../right_panel/context";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember | null;
@ -47,7 +47,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
export default function MemberAvatar({
width,
height,
resizeMethod = 'crop',
resizeMethod = "crop",
viewUserOnClick,
forceHistorical,
fallbackUserId,
@ -76,35 +76,40 @@ export default function MemberAvatar({
}
if (!title) {
title = UserIdentifierCustomisations.getDisplayUserIdentifier(
member?.userId ?? "", { roomId: member?.roomId ?? "" },
) ?? fallbackUserId;
title =
UserIdentifierCustomisations.getDisplayUserIdentifier(member?.userId ?? "", {
roomId: member?.roomId ?? "",
}) ?? fallbackUserId;
}
}
return <BaseAvatar
{...props}
width={width}
height={height}
resizeMethod={resizeMethod}
name={name ?? ""}
title={hideTitle ? undefined : title}
idName={member?.userId ?? fallbackUserId}
url={imageUrl}
onClick={viewUserOnClick ? () => {
dis.dispatch({
action: Action.ViewUser,
member: propsMember,
push: card.isCard,
});
} : props.onClick}
/>;
return (
<BaseAvatar
{...props}
width={width}
height={height}
resizeMethod={resizeMethod}
name={name ?? ""}
title={hideTitle ? undefined : title}
idName={member?.userId ?? fallbackUserId}
url={imageUrl}
onClick={
viewUserOnClick
? () => {
dis.dispatch({
action: Action.ViewUser,
member: propsMember,
push: card.isCard,
});
}
: props.onClick
}
/>
);
}
export class LegacyMemberAvatar extends React.Component<IProps> {
public render(): JSX.Element {
return <MemberAvatar {...this.props}>
{ this.props.children }
</MemberAvatar>;
return <MemberAvatar {...this.props}>{this.props.children}</MemberAvatar>;
}
}

View file

@ -14,21 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import React, { ComponentProps } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import classNames from "classnames";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar';
import BaseAvatar from "./BaseAvatar";
import ImageView from "../elements/ImageView";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import * as Avatar from "../../../Avatar";
import DMRoomMap from "../../../utils/DMRoomMap";
import { mediaFromMxc } from "../../../customisations/Media";
import { IOOBData } from '../../../stores/ThreepidInviteStore';
import { IOOBData } from "../../../stores/ThreepidInviteStore";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is,
@ -50,7 +50,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
public static defaultProps = {
width: 36,
height: 36,
resizeMethod: 'crop',
resizeMethod: "crop",
oobData: {},
};
@ -96,8 +96,8 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
return [
oobAvatar, // highest priority
RoomAvatar.getRoomAvatarUrl(props),
].filter(function(url) {
return (url !== null && url !== "");
].filter(function (url) {
return url !== null && url !== "";
});
}
@ -108,12 +108,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}
private onRoomAvatarClick = () => {
const avatarUrl = Avatar.avatarUrlForRoom(
this.props.room,
null,
null,
null,
);
const avatarUrl = Avatar.avatarUrlForRoom(this.props.room, null, null, null);
const params = {
src: avatarUrl,
name: this.props.room.name,
@ -128,7 +123,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
const roomName = room?.name ?? oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
const idName = room ? DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId : oobData.roomId;
return (
<BaseAvatar

View file

@ -32,22 +32,26 @@ export function SearchResultAvatar({ user, size }: SearchResultAvatarProps): JSX
// we cant show a real avatar here, but we try to create the exact same markup that a real avatar would have
// BaseAvatar makes the avatar, if it's not clickable but just for decoration, invisible to screenreaders by
// specifically setting an empty alt text, so we do the same.
return <img
className="mx_SearchResultAvatar mx_SearchResultAvatar_threepidAvatar"
alt=""
src={emailPillAvatar}
width={size}
height={size}
/>;
return (
<img
className="mx_SearchResultAvatar mx_SearchResultAvatar_threepidAvatar"
alt=""
src={emailPillAvatar}
width={size}
height={size}
/>
);
} else {
const avatarUrl = user.getMxcAvatarUrl();
return <BaseAvatar
className="mx_SearchResultAvatar"
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(size) : null}
name={user.name}
idName={user.userId}
width={size}
height={size}
/>;
return (
<BaseAvatar
className="mx_SearchResultAvatar"
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(size) : null}
name={user.name}
idName={user.userId}
width={size}
height={size}
/>
);
}
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ComponentProps } from 'react';
import classNames from 'classnames';
import React, { ComponentProps } from "react";
import classNames from "classnames";
import { IApp } from "../../../stores/WidgetStore";
import BaseAvatar, { BaseAvatarType } from "./BaseAvatar";

View file

@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLProps, useContext } from 'react';
import { Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix';
import { LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import React, { HTMLProps, useContext } from "react";
import { Beacon, BeaconEvent } from "matrix-js-sdk/src/matrix";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { humanizeTime } from '../../../utils/humanize';
import { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import BeaconStatus from './BeaconStatus';
import { BeaconDisplayStatus } from './displayStatus';
import StyledLiveBeaconIcon from './StyledLiveBeaconIcon';
import ShareLatestLocation from './ShareLatestLocation';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { humanizeTime } from "../../../utils/humanize";
import { preventDefaultWrapper } from "../../../utils/NativeEventUtils";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
import BeaconStatus from "./BeaconStatus";
import { BeaconDisplayStatus } from "./displayStatus";
import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon";
import ShareLatestLocation from "./ShareLatestLocation";
interface Props {
beacon: Beacon;
@ -47,38 +47,36 @@ const BeaconListItem: React.FC<Props & HTMLProps<HTMLLIElement>> = ({ beacon, ..
}
const isSelfLocation = beacon.beaconInfo.assetType === LocationAssetType.Self;
const beaconMember = isSelfLocation ?
room.getMember(beacon.beaconInfoOwner) :
undefined;
const beaconMember = isSelfLocation ? room.getMember(beacon.beaconInfoOwner) : undefined;
const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp);
return <li className='mx_BeaconListItem' {...rest}>
{ isSelfLocation ?
<MemberAvatar
className='mx_BeaconListItem_avatar'
member={beaconMember}
height={32}
width={32}
/> :
<StyledLiveBeaconIcon className='mx_BeaconListItem_avatarIcon' />
}
<div className='mx_BeaconListItem_info'>
<BeaconStatus
className='mx_BeaconListItem_status'
beacon={beacon}
label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner}
displayStatus={BeaconDisplayStatus.Active}
>
{ /* eat events from interactive share buttons
so parent click handlers are not triggered */ }
<div className='mx_BeaconListItem_interactions' onClick={preventDefaultWrapper(() => {})}>
<ShareLatestLocation latestLocationState={latestLocationState} />
</div>
</BeaconStatus>
<span className='mx_BeaconListItem_lastUpdated'>{ _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) }</span>
</div>
</li>;
return (
<li className="mx_BeaconListItem" {...rest}>
{isSelfLocation ? (
<MemberAvatar className="mx_BeaconListItem_avatar" member={beaconMember} height={32} width={32} />
) : (
<StyledLiveBeaconIcon className="mx_BeaconListItem_avatarIcon" />
)}
<div className="mx_BeaconListItem_info">
<BeaconStatus
className="mx_BeaconListItem_status"
beacon={beacon}
label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner}
displayStatus={BeaconDisplayStatus.Active}
>
{/* eat events from interactive share buttons
so parent click handlers are not triggered */}
<div className="mx_BeaconListItem_interactions" onClick={preventDefaultWrapper(() => {})}>
<ShareLatestLocation latestLocationState={latestLocationState} />
</div>
</BeaconStatus>
<span className="mx_BeaconListItem_lastUpdated">
{_t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime })}
</span>
</div>
</li>
);
};
export default BeaconListItem;

View file

@ -14,17 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useContext } from 'react';
import maplibregl from 'maplibre-gl';
import {
Beacon,
BeaconEvent,
} from 'matrix-js-sdk/src/matrix';
import { LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import React, { ReactNode, useContext } from "react";
import maplibregl from "maplibre-gl";
import { Beacon, BeaconEvent } from "matrix-js-sdk/src/matrix";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import SmartMarker from '../location/SmartMarker';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import SmartMarker from "../location/SmartMarker";
interface Props {
map: maplibregl.Map;
@ -50,18 +47,19 @@ const BeaconMarker: React.FC<Props> = ({ map, beacon, tooltip }) => {
const geoUri = latestLocationState?.uri;
const markerRoomMember = beacon.beaconInfo.assetType === LocationAssetType.Self ?
room.getMember(beacon.beaconInfoOwner) :
undefined;
const markerRoomMember =
beacon.beaconInfo.assetType === LocationAssetType.Self ? room.getMember(beacon.beaconInfoOwner) : undefined;
return <SmartMarker
map={map}
id={beacon.identifier}
geoUri={geoUri}
roomMember={markerRoomMember}
tooltip={tooltip}
useMemberColor
/>;
return (
<SmartMarker
map={map}
id={beacon.identifier}
geoUri={geoUri}
roomMember={markerRoomMember}
tooltip={tooltip}
useMemberColor
/>
);
};
export default BeaconMarker;

View file

@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { HTMLProps } from 'react';
import classNames from 'classnames';
import { Beacon } from 'matrix-js-sdk/src/matrix';
import React, { HTMLProps } from "react";
import classNames from "classnames";
import { Beacon } from "matrix-js-sdk/src/matrix";
import StyledLiveBeaconIcon from './StyledLiveBeaconIcon';
import { _t } from '../../../languageHandler';
import LiveTimeRemaining from './LiveTimeRemaining';
import { BeaconDisplayStatus } from './displayStatus';
import { getBeaconExpiryTimestamp } from '../../../utils/beacon';
import { formatTime } from '../../../DateUtils';
import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon";
import { _t } from "../../../languageHandler";
import LiveTimeRemaining from "./LiveTimeRemaining";
import { BeaconDisplayStatus } from "./displayStatus";
import { getBeaconExpiryTimestamp } from "../../../utils/beacon";
import { formatTime } from "../../../DateUtils";
interface Props {
displayStatus: BeaconDisplayStatus;
@ -35,56 +35,56 @@ interface Props {
const BeaconExpiryTime: React.FC<{ beacon: Beacon }> = ({ beacon }) => {
const expiryTime = formatTime(new Date(getBeaconExpiryTimestamp(beacon)));
return <span className='mx_BeaconStatus_expiryTime'>{ _t('Live until %(expiryTime)s', { expiryTime }) }</span>;
return <span className="mx_BeaconStatus_expiryTime">{_t("Live until %(expiryTime)s", { expiryTime })}</span>;
};
const BeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> =
({
beacon,
displayStatus,
displayLiveTimeRemaining,
label,
className,
children,
withIcon,
...rest
}) => {
const isIdle = displayStatus === BeaconDisplayStatus.Loading ||
displayStatus === BeaconDisplayStatus.Stopped;
const BeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
beacon,
displayStatus,
displayLiveTimeRemaining,
label,
className,
children,
withIcon,
...rest
}) => {
const isIdle = displayStatus === BeaconDisplayStatus.Loading || displayStatus === BeaconDisplayStatus.Stopped;
return <div
{...rest}
className={classNames('mx_BeaconStatus', `mx_BeaconStatus_${displayStatus}`, className)}
>
{ withIcon && <StyledLiveBeaconIcon
className='mx_BeaconStatus_icon'
withError={displayStatus === BeaconDisplayStatus.Error}
isIdle={isIdle}
/> }
<div className='mx_BeaconStatus_description'>
{ displayStatus === BeaconDisplayStatus.Loading &&
<span className="mx_BeaconStatus_description_status">{ _t('Loading live location...') }</span>
}
{ displayStatus === BeaconDisplayStatus.Stopped &&
<span className="mx_BeaconStatus_description_status">{ _t('Live location ended') }</span>
}
{ displayStatus === BeaconDisplayStatus.Error &&
<span className="mx_BeaconStatus_description_status">{ _t('Live location error') }</span>
}
{ displayStatus === BeaconDisplayStatus.Active && beacon && <>
return (
<div {...rest} className={classNames("mx_BeaconStatus", `mx_BeaconStatus_${displayStatus}`, className)}>
{withIcon && (
<StyledLiveBeaconIcon
className="mx_BeaconStatus_icon"
withError={displayStatus === BeaconDisplayStatus.Error}
isIdle={isIdle}
/>
)}
<div className="mx_BeaconStatus_description">
{displayStatus === BeaconDisplayStatus.Loading && (
<span className="mx_BeaconStatus_description_status">{_t("Loading live location...")}</span>
)}
{displayStatus === BeaconDisplayStatus.Stopped && (
<span className="mx_BeaconStatus_description_status">{_t("Live location ended")}</span>
)}
{displayStatus === BeaconDisplayStatus.Error && (
<span className="mx_BeaconStatus_description_status">{_t("Live location error")}</span>
)}
{displayStatus === BeaconDisplayStatus.Active && beacon && (
<>
<span className='mx_BeaconStatus_label'>{ label }</span>
{ displayLiveTimeRemaining ?
<LiveTimeRemaining beacon={beacon} /> :
<BeaconExpiryTime beacon={beacon} />
}
<>
<span className="mx_BeaconStatus_label">{label}</span>
{displayLiveTimeRemaining ? (
<LiveTimeRemaining beacon={beacon} />
) : (
<BeaconExpiryTime beacon={beacon} />
)}
</>
</>
</>
}
)}
</div>
{ children }
</div>;
};
{children}
</div>
);
};
export default BeaconStatus;

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from 'react';
import { Beacon } from 'matrix-js-sdk/src/matrix';
import { LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import React, { useContext } from "react";
import { Beacon } from "matrix-js-sdk/src/matrix";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import BeaconStatus from './BeaconStatus';
import { BeaconDisplayStatus } from './displayStatus';
import ShareLatestLocation from './ShareLatestLocation';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BeaconStatus from "./BeaconStatus";
import { BeaconDisplayStatus } from "./displayStatus";
import ShareLatestLocation from "./ShareLatestLocation";
interface Props {
beacon: Beacon;
@ -42,17 +42,19 @@ const useBeaconName = (beacon: Beacon): string => {
const BeaconStatusTooltip: React.FC<Props> = ({ beacon }) => {
const label = useBeaconName(beacon);
return <div className='mx_BeaconStatusTooltip'>
<BeaconStatus
beacon={beacon}
label={label}
displayStatus={BeaconDisplayStatus.Active}
displayLiveTimeRemaining
className='mx_BeaconStatusTooltip_inner'
>
<ShareLatestLocation latestLocationState={beacon.latestLocationState} />
</BeaconStatus>
</div>;
return (
<div className="mx_BeaconStatusTooltip">
<BeaconStatus
beacon={beacon}
label={label}
displayStatus={BeaconDisplayStatus.Active}
displayLiveTimeRemaining
className="mx_BeaconStatusTooltip_inner"
>
<ShareLatestLocation latestLocationState={beacon.latestLocationState} />
</BeaconStatus>
</div>
);
};
export default BeaconStatusTooltip;

View file

@ -14,35 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import {
Beacon,
Room,
} from 'matrix-js-sdk/src/matrix';
import maplibregl from 'maplibre-gl';
import React, { useState, useEffect } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Beacon, Room } from "matrix-js-sdk/src/matrix";
import maplibregl from "maplibre-gl";
import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg';
import { useLiveBeacons } from '../../../utils/beacon/useLiveBeacons';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import { Icon as LiveLocationIcon } from "../../../../res/img/location/live-location.svg";
import { useLiveBeacons } from "../../../utils/beacon/useLiveBeacons";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseDialog from "../dialogs/BaseDialog";
import { IDialogProps } from "../dialogs/IDialogProps";
import Map from '../location/Map';
import ZoomButtons from '../location/ZoomButtons';
import BeaconMarker from './BeaconMarker';
import { Bounds, getBeaconBounds } from '../../../utils/beacon/bounds';
import { getGeoUri } from '../../../utils/beacon';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import DialogSidebar from './DialogSidebar';
import DialogOwnBeaconStatus from './DialogOwnBeaconStatus';
import BeaconStatusTooltip from './BeaconStatusTooltip';
import MapFallback from '../location/MapFallback';
import { MapError } from '../location/MapError';
import { LocationShareError } from '../../../utils/location';
import Map from "../location/Map";
import ZoomButtons from "../location/ZoomButtons";
import BeaconMarker from "./BeaconMarker";
import { Bounds, getBeaconBounds } from "../../../utils/beacon/bounds";
import { getGeoUri } from "../../../utils/beacon";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import DialogSidebar from "./DialogSidebar";
import DialogOwnBeaconStatus from "./DialogOwnBeaconStatus";
import BeaconStatusTooltip from "./BeaconStatusTooltip";
import MapFallback from "../location/MapFallback";
import { MapError } from "../location/MapError";
import { LocationShareError } from "../../../utils/location";
interface IProps extends IDialogProps {
roomId: Room['roomId'];
roomId: Room["roomId"];
matrixClient: MatrixClient;
// open the map centered on this beacon's location
initialFocusedBeacon?: Beacon;
@ -68,13 +65,16 @@ const getBoundsCenter = (bounds: Bounds): string | undefined => {
});
};
const useMapPosition = (liveBeacons: Beacon[], { beacon, ts }: FocusedBeaconState): {
bounds?: Bounds; centerGeoUri: string;
const useMapPosition = (
liveBeacons: Beacon[],
{ beacon, ts }: FocusedBeaconState,
): {
bounds?: Bounds;
centerGeoUri: string;
} => {
const [bounds, setBounds] = useState<Bounds | undefined>(getBeaconBounds(liveBeacons));
const [centerGeoUri, setCenterGeoUri] = useState<string>(
beacon?.latestLocationState?.uri ||
getBoundsCenter(bounds),
beacon?.latestLocationState?.uri || getBoundsCenter(bounds),
);
useEffect(() => {
@ -101,15 +101,12 @@ const useMapPosition = (liveBeacons: Beacon[], { beacon, ts }: FocusedBeaconStat
/**
* Dialog to view live beacons maximised
*/
const BeaconViewDialog: React.FC<IProps> = ({
initialFocusedBeacon,
roomId,
matrixClient,
onFinished,
}) => {
const BeaconViewDialog: React.FC<IProps> = ({ initialFocusedBeacon, roomId, matrixClient, onFinished }) => {
const liveBeacons = useLiveBeacons(roomId, matrixClient);
const [focusedBeaconState, setFocusedBeaconState] =
useState<FocusedBeaconState>({ beacon: initialFocusedBeacon, ts: 0 });
const [focusedBeaconState, setFocusedBeaconState] = useState<FocusedBeaconState>({
beacon: initialFocusedBeacon,
ts: 0,
});
const [isSidebarOpen, setSidebarOpen] = useState(false);
@ -129,66 +126,63 @@ const BeaconViewDialog: React.FC<IProps> = ({
};
return (
<BaseDialog
className='mx_BeaconViewDialog'
onFinished={onFinished}
fixedWidth={false}
>
<BaseDialog className="mx_BeaconViewDialog" onFinished={onFinished} fixedWidth={false}>
<MatrixClientContext.Provider value={matrixClient}>
{ (centerGeoUri && !mapDisplayError) && <Map
id='mx_BeaconViewDialog'
bounds={bounds}
centerGeoUri={centerGeoUri}
interactive
onError={setMapDisplayError}
className="mx_BeaconViewDialog_map"
>
{
({ map }: { map: maplibregl.Map}) =>
{centerGeoUri && !mapDisplayError && (
<Map
id="mx_BeaconViewDialog"
bounds={bounds}
centerGeoUri={centerGeoUri}
interactive
onError={setMapDisplayError}
className="mx_BeaconViewDialog_map"
>
{({ map }: { map: maplibregl.Map }) => (
<>
{ liveBeacons.map(beacon => <BeaconMarker
key={beacon.identifier}
map={map}
beacon={beacon}
tooltip={<BeaconStatusTooltip beacon={beacon} />}
/>) }
{liveBeacons.map((beacon) => (
<BeaconMarker
key={beacon.identifier}
map={map}
beacon={beacon}
tooltip={<BeaconStatusTooltip beacon={beacon} />}
/>
))}
<ZoomButtons map={map} />
</>
}
</Map> }
{ mapDisplayError &&
<MapError
error={mapDisplayError.message as LocationShareError}
isMinimised
/>
}
{ !centerGeoUri && !mapDisplayError &&
<MapFallback
data-test-id='beacon-view-dialog-map-fallback'
className='mx_BeaconViewDialog_map'
>
<span className='mx_BeaconViewDialog_mapFallbackMessage'>{ _t('No live locations') }</span>
)}
</Map>
)}
{mapDisplayError && <MapError error={mapDisplayError.message as LocationShareError} isMinimised />}
{!centerGeoUri && !mapDisplayError && (
<MapFallback data-test-id="beacon-view-dialog-map-fallback" className="mx_BeaconViewDialog_map">
<span className="mx_BeaconViewDialog_mapFallbackMessage">{_t("No live locations")}</span>
<AccessibleButton
kind='primary'
kind="primary"
onClick={onFinished}
data-test-id='beacon-view-dialog-fallback-close'
data-test-id="beacon-view-dialog-fallback-close"
>
{ _t('Close') }
{_t("Close")}
</AccessibleButton>
</MapFallback>
}
{ isSidebarOpen ?
<DialogSidebar beacons={liveBeacons} onBeaconClick={onBeaconListItemClick} requestClose={() => setSidebarOpen(false)} /> :
)}
{isSidebarOpen ? (
<DialogSidebar
beacons={liveBeacons}
onBeaconClick={onBeaconListItemClick}
requestClose={() => setSidebarOpen(false)}
/>
) : (
<AccessibleButton
kind='primary'
kind="primary"
onClick={() => setSidebarOpen(true)}
data-test-id='beacon-view-dialog-open-sidebar'
className='mx_BeaconViewDialog_viewListButton'
data-test-id="beacon-view-dialog-open-sidebar"
className="mx_BeaconViewDialog_viewListButton"
>
<LiveLocationIcon height={12} />&nbsp;
{ _t('View list') }
<LiveLocationIcon height={12} />
&nbsp;
{_t("View list")}
</AccessibleButton>
}
)}
<DialogOwnBeaconStatus roomId={roomId} />
</MatrixClientContext.Provider>
</BaseDialog>

View file

@ -14,31 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from 'react';
import { Room, Beacon } from 'matrix-js-sdk/src/matrix';
import { LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import React, { useContext } from "react";
import { Room, Beacon } from "matrix-js-sdk/src/matrix";
import { LocationAssetType } from "matrix-js-sdk/src/@types/location";
import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore';
import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import OwnBeaconStatus from './OwnBeaconStatus';
import { BeaconDisplayStatus } from './displayStatus';
import MatrixClientContext from '../../../contexts/MatrixClientContext';
import MemberAvatar from '../avatars/MemberAvatar';
import StyledLiveBeaconIcon from './StyledLiveBeaconIcon';
import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import OwnBeaconStatus from "./OwnBeaconStatus";
import { BeaconDisplayStatus } from "./displayStatus";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import MemberAvatar from "../avatars/MemberAvatar";
import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon";
interface Props {
roomId: Room['roomId'];
roomId: Room["roomId"];
}
const useOwnBeacon = (roomId: Room['roomId']): Beacon | undefined => {
const ownBeacon = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.LivenessChange,
() => {
const [ownBeaconId] = OwnBeaconStore.instance.getLiveBeaconIds(roomId);
return OwnBeaconStore.instance.getBeaconById(ownBeaconId);
},
);
const useOwnBeacon = (roomId: Room["roomId"]): Beacon | undefined => {
const ownBeacon = useEventEmitterState(OwnBeaconStore.instance, OwnBeaconStoreEvent.LivenessChange, () => {
const [ownBeaconId] = OwnBeaconStore.instance.getLiveBeaconIds(roomId);
return OwnBeaconStore.instance.getBeaconById(ownBeaconId);
});
return ownBeacon;
};
@ -54,26 +50,27 @@ const DialogOwnBeaconStatus: React.FC<Props> = ({ roomId }) => {
}
const isSelfLocation = beacon.beaconInfo.assetType === LocationAssetType.Self;
const beaconMember = isSelfLocation ?
room.getMember(beacon.beaconInfoOwner) :
undefined;
const beaconMember = isSelfLocation ? room.getMember(beacon.beaconInfoOwner) : undefined;
return <div className='mx_DialogOwnBeaconStatus'>
{ isSelfLocation ?
<MemberAvatar
className='mx_DialogOwnBeaconStatus_avatar'
member={beaconMember}
height={32}
width={32}
/> :
<StyledLiveBeaconIcon className='mx_DialogOwnBeaconStatus_avatarIcon' />
}
<OwnBeaconStatus
className='mx_DialogOwnBeaconStatus_status'
beacon={beacon}
displayStatus={BeaconDisplayStatus.Active}
/>
</div>;
return (
<div className="mx_DialogOwnBeaconStatus">
{isSelfLocation ? (
<MemberAvatar
className="mx_DialogOwnBeaconStatus_avatar"
member={beaconMember}
height={32}
width={32}
/>
) : (
<StyledLiveBeaconIcon className="mx_DialogOwnBeaconStatus_avatarIcon" />
)}
<OwnBeaconStatus
className="mx_DialogOwnBeaconStatus_status"
beacon={beacon}
displayStatus={BeaconDisplayStatus.Active}
/>
</div>
);
};
export default DialogOwnBeaconStatus;

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { Beacon } from 'matrix-js-sdk/src/matrix';
import React from "react";
import { Beacon } from "matrix-js-sdk/src/matrix";
import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import Heading from '../typography/Heading';
import BeaconListItem from './BeaconListItem';
import { Icon as CloseIcon } from "../../../../res/img/image-view/close.svg";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import Heading from "../typography/Heading";
import BeaconListItem from "./BeaconListItem";
interface Props {
beacons: Beacon[];
@ -29,36 +29,31 @@ interface Props {
onBeaconClick: (beacon: Beacon) => void;
}
const DialogSidebar: React.FC<Props> = ({
beacons,
onBeaconClick,
requestClose,
}) => {
return <div className='mx_DialogSidebar'>
<div className='mx_DialogSidebar_header'>
<Heading size='h4'>{ _t('View List') }</Heading>
<AccessibleButton
className='mx_DialogSidebar_closeButton'
onClick={requestClose}
title={_t('Close sidebar')}
data-testid='dialog-sidebar-close'
>
<CloseIcon className='mx_DialogSidebar_closeButtonIcon' />
</AccessibleButton>
</div>
{ beacons?.length
? <ol className='mx_DialogSidebar_list'>
{ beacons.map((beacon) => <BeaconListItem
key={beacon.identifier}
beacon={beacon}
onClick={() => onBeaconClick(beacon)}
/>) }
</ol>
: <div className='mx_DialogSidebar_noResults'>
{ _t('No live locations') }
const DialogSidebar: React.FC<Props> = ({ beacons, onBeaconClick, requestClose }) => {
return (
<div className="mx_DialogSidebar">
<div className="mx_DialogSidebar_header">
<Heading size="h4">{_t("View List")}</Heading>
<AccessibleButton
className="mx_DialogSidebar_closeButton"
onClick={requestClose}
title={_t("Close sidebar")}
data-testid="dialog-sidebar-close"
>
<CloseIcon className="mx_DialogSidebar_closeButtonIcon" />
</AccessibleButton>
</div>
}
</div>;
{beacons?.length ? (
<ol className="mx_DialogSidebar_list">
{beacons.map((beacon) => (
<BeaconListItem key={beacon.identifier} beacon={beacon} onClick={() => onBeaconClick(beacon)} />
))}
</ol>
) : (
<div className="mx_DialogSidebar_noResults">{_t("No live locations")}</div>
)}
</div>
);
};
export default DialogSidebar;

View file

@ -14,18 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from 'classnames';
import React, { useEffect } from 'react';
import { Beacon, BeaconIdentifier } from 'matrix-js-sdk/src/matrix';
import classNames from "classnames";
import React, { useEffect } from "react";
import { Beacon, BeaconIdentifier } from "matrix-js-sdk/src/matrix";
import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { _t } from '../../../languageHandler';
import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore';
import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg';
import { ViewRoomPayload } from '../../../dispatcher/payloads/ViewRoomPayload';
import { Action } from '../../../dispatcher/actions';
import dispatcher from '../../../dispatcher/dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { _t } from "../../../languageHandler";
import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore";
import { Icon as LiveLocationIcon } from "../../../../res/img/location/live-location.svg";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import dispatcher from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
interface Props {
isMinimized?: boolean;
@ -52,12 +52,12 @@ const chooseBestBeacon = (
const getLabel = (hasStoppingErrors: boolean, hasLocationErrors: boolean): string => {
if (hasStoppingErrors) {
return _t('An error occurred while stopping your live location');
return _t("An error occurred while stopping your live location");
}
if (hasLocationErrors) {
return _t('An error occurred whilst sharing your live location');
return _t("An error occurred whilst sharing your live location");
}
return _t('You are sharing your live location');
return _t("You are sharing your live location");
};
const useLivenessMonitor = (liveBeaconIds: BeaconIdentifier[], beacons: Map<BeaconIdentifier, Beacon>): void => {
@ -66,8 +66,8 @@ const useLivenessMonitor = (liveBeaconIds: BeaconIdentifier[], beacons: Map<Beac
// for inactive tabs
// refresh beacon monitors when the tab becomes active again
const onPageVisibilityChanged = () => {
if (document.visibilityState === 'visible') {
liveBeaconIds.forEach(identifier => beacons.get(identifier)?.monitorLiveness());
if (document.visibilityState === "visible") {
liveBeaconIds.forEach((identifier) => beacons.get(identifier)?.monitorLiveness());
}
};
if (liveBeaconIds.length) {
@ -95,15 +95,14 @@ const LeftPanelLiveShareWarning: React.FC<Props> = ({ isMinimized }) => {
const beaconIdsWithStoppingError = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.BeaconUpdateError,
() => OwnBeaconStore.instance.getLiveBeaconIds().filter(
beaconId => OwnBeaconStore.instance.beaconUpdateErrors.has(beaconId),
),
() =>
OwnBeaconStore.instance
.getLiveBeaconIds()
.filter((beaconId) => OwnBeaconStore.instance.beaconUpdateErrors.has(beaconId)),
);
const liveBeaconIds = useEventEmitterState(
OwnBeaconStore.instance,
OwnBeaconStoreEvent.LivenessChange,
() => OwnBeaconStore.instance.getLiveBeaconIds(),
const liveBeaconIds = useEventEmitterState(OwnBeaconStore.instance, OwnBeaconStoreEvent.LivenessChange, () =>
OwnBeaconStore.instance.getLiveBeaconIds(),
);
const hasLocationPublishErrors = !!beaconIdsWithLocationPublishError.length;
@ -116,32 +115,38 @@ const LeftPanelLiveShareWarning: React.FC<Props> = ({ isMinimized }) => {
}
const relevantBeacon = chooseBestBeacon(
liveBeaconIds, beaconIdsWithStoppingError, beaconIdsWithLocationPublishError,
liveBeaconIds,
beaconIdsWithStoppingError,
beaconIdsWithLocationPublishError,
);
const onWarningClick = relevantBeacon ? () => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: relevantBeacon.roomId,
metricsTrigger: undefined,
event_id: relevantBeacon.beaconInfoId,
scroll_into_view: true,
highlighted: true,
});
} : undefined;
const onWarningClick = relevantBeacon
? () => {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: relevantBeacon.roomId,
metricsTrigger: undefined,
event_id: relevantBeacon.beaconInfoId,
scroll_into_view: true,
highlighted: true,
});
}
: undefined;
const label = getLabel(hasStoppingErrors, hasLocationPublishErrors);
return <AccessibleButton
className={classNames('mx_LeftPanelLiveShareWarning', {
'mx_LeftPanelLiveShareWarning__minimized': isMinimized,
'mx_LeftPanelLiveShareWarning__error': hasLocationPublishErrors || hasStoppingErrors,
})}
title={isMinimized ? label : undefined}
onClick={onWarningClick}
>
{ isMinimized ? <LiveLocationIcon height={10} /> : label }
</AccessibleButton>;
return (
<AccessibleButton
className={classNames("mx_LeftPanelLiveShareWarning", {
mx_LeftPanelLiveShareWarning__minimized: isMinimized,
mx_LeftPanelLiveShareWarning__error: hasLocationPublishErrors || hasStoppingErrors,
})}
title={isMinimized ? label : undefined}
onClick={onWarningClick}
>
{isMinimized ? <LiveLocationIcon height={10} /> : label}
</AccessibleButton>
);
};
export default LeftPanelLiveShareWarning;

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { BeaconEvent, Beacon } from 'matrix-js-sdk/src/matrix';
import React, { useCallback, useEffect, useState } from "react";
import { BeaconEvent, Beacon } from "matrix-js-sdk/src/matrix";
import { formatDuration } from '../../../DateUtils';
import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { useInterval } from '../../../hooks/useTimeout';
import { _t } from '../../../languageHandler';
import { getBeaconMsUntilExpiry } from '../../../utils/beacon';
import { formatDuration } from "../../../DateUtils";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { useInterval } from "../../../hooks/useTimeout";
import { _t } from "../../../languageHandler";
import { getBeaconMsUntilExpiry } from "../../../utils/beacon";
const MINUTE_MS = 60000;
const HOUR_MS = MINUTE_MS * 60;
@ -38,11 +38,7 @@ const getUpdateInterval = (ms: number) => {
return 1000;
};
const useMsRemaining = (beacon: Beacon): number => {
const beaconInfo = useEventEmitterState(
beacon,
BeaconEvent.Update,
() => beacon.beaconInfo,
);
const beaconInfo = useEventEmitterState(beacon, BeaconEvent.Update, () => beacon.beaconInfo);
const [msRemaining, setMsRemaining] = useState(() => getBeaconMsUntilExpiry(beaconInfo));
@ -66,10 +62,11 @@ const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => {
const timeRemaining = formatDuration(msRemaining);
const liveTimeRemaining = _t(`%(timeRemaining)s left`, { timeRemaining });
return <span
data-test-id='room-live-share-expiry'
className="mx_LiveTimeRemaining"
>{ liveTimeRemaining }</span>;
return (
<span data-test-id="room-live-share-expiry" className="mx_LiveTimeRemaining">
{liveTimeRemaining}
</span>
);
};
export default LiveTimeRemaining;

View file

@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Beacon } from 'matrix-js-sdk/src/matrix';
import React, { HTMLProps } from 'react';
import { Beacon } from "matrix-js-sdk/src/matrix";
import React, { HTMLProps } from "react";
import { _t } from '../../../languageHandler';
import { useOwnLiveBeacons } from '../../../utils/beacon';
import { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import BeaconStatus from './BeaconStatus';
import { BeaconDisplayStatus } from './displayStatus';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import { _t } from "../../../languageHandler";
import { useOwnLiveBeacons } from "../../../utils/beacon";
import { preventDefaultWrapper } from "../../../utils/NativeEventUtils";
import BeaconStatus from "./BeaconStatus";
import { BeaconDisplayStatus } from "./displayStatus";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
interface Props {
displayStatus: BeaconDisplayStatus;
@ -35,9 +35,7 @@ interface Props {
* Wraps BeaconStatus with more capabilities
* for errors and actions available for users own live beacons
*/
const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
beacon, displayStatus, ...rest
}) => {
const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({ beacon, displayStatus, ...rest }) => {
const {
hasLocationPublishError,
hasStopSharingError,
@ -47,51 +45,55 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
} = useOwnLiveBeacons([beacon?.identifier]);
// combine display status with errors that only occur for user's own beacons
const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ?
BeaconDisplayStatus.Error :
displayStatus;
const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ? BeaconDisplayStatus.Error : displayStatus;
return <BeaconStatus
beacon={beacon}
displayStatus={ownDisplayStatus}
label={_t('Live location enabled')}
displayLiveTimeRemaining
{...rest}
>
{ ownDisplayStatus === BeaconDisplayStatus.Active && <AccessibleButton
data-test-id='beacon-status-stop-beacon'
kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper<ButtonEvent>(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
disabled={stoppingInProgress}
return (
<BeaconStatus
beacon={beacon}
displayStatus={ownDisplayStatus}
label={_t("Live location enabled")}
displayLiveTimeRemaining
{...rest}
>
{ _t('Stop') }
</AccessibleButton>
}
{ hasLocationPublishError && <AccessibleButton
data-test-id='beacon-status-reset-wire-error'
kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onResetLocationPublishError)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
>
{ _t('Retry') }
</AccessibleButton>
}
{ hasStopSharingError && <AccessibleButton
data-test-id='beacon-status-stop-beacon-retry'
kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
>
{ _t('Retry') }
</AccessibleButton> }
</BeaconStatus>;
{ownDisplayStatus === BeaconDisplayStatus.Active && (
<AccessibleButton
data-test-id="beacon-status-stop-beacon"
kind="link"
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper<ButtonEvent>(onStopSharing)}
className="mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton"
disabled={stoppingInProgress}
>
{_t("Stop")}
</AccessibleButton>
)}
{hasLocationPublishError && (
<AccessibleButton
data-test-id="beacon-status-reset-wire-error"
kind="link"
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onResetLocationPublishError)}
className="mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton"
>
{_t("Retry")}
</AccessibleButton>
)}
{hasStopSharingError && (
<AccessibleButton
data-test-id="beacon-status-stop-beacon-retry"
kind="link"
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onStopSharing)}
className="mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton"
>
{_t("Retry")}
</AccessibleButton>
)}
</BeaconStatus>
);
};
export default OwnBeaconStatus;

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