Apply prettier formatting
This commit is contained in:
parent
1cac306093
commit
526645c791
1576 changed files with 65385 additions and 62478 deletions
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>,
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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>,
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
: <> </>
|
||||
}
|
||||
</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>
|
||||
) : (
|
||||
<> </>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 it’s 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 it’s 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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue