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.