Merge matrix-react-sdk into element-web
Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
70
src/components/structures/AutoHideScrollbar.tsx
Normal file
70
src/components/structures/AutoHideScrollbar.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { HTMLAttributes, ReactHTML, ReactNode, WheelEvent } from "react";
|
||||
|
||||
type DynamicHtmlElementProps<T extends keyof JSX.IntrinsicElements> =
|
||||
JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps<T> : DynamicElementProps<"div">;
|
||||
type DynamicElementProps<T extends keyof JSX.IntrinsicElements> = Partial<Omit<JSX.IntrinsicElements[T], "ref">>;
|
||||
|
||||
export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<DynamicHtmlElementProps<T>, "onScroll"> & {
|
||||
element: T;
|
||||
className?: string;
|
||||
onScroll?: (event: Event) => void;
|
||||
onWheel?: (event: WheelEvent) => void;
|
||||
style?: React.CSSProperties;
|
||||
tabIndex?: number;
|
||||
wrappedRef?: (ref: HTMLDivElement | null) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default class AutoHideScrollbar<T extends keyof JSX.IntrinsicElements> extends React.Component<IProps<T>> {
|
||||
public static defaultProps = {
|
||||
element: "div" as keyof ReactHTML,
|
||||
};
|
||||
|
||||
public readonly containerRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.containerRef.current && this.props.onScroll) {
|
||||
// Using the passive option to not block the main thread
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
||||
this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true });
|
||||
}
|
||||
|
||||
this.props.wrappedRef?.(this.containerRef.current);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
if (this.containerRef.current && this.props.onScroll) {
|
||||
this.containerRef.current.removeEventListener("scroll", this.props.onScroll);
|
||||
}
|
||||
|
||||
this.props.wrappedRef?.(null);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { element, className, onScroll, tabIndex, wrappedRef, children, ...otherProps } = this.props;
|
||||
|
||||
return React.createElement(
|
||||
element,
|
||||
{
|
||||
...otherProps,
|
||||
ref: this.containerRef,
|
||||
className: classNames("mx_AutoHideScrollbar", className),
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order by default.
|
||||
tabIndex: tabIndex ?? -1,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
}
|
231
src/components/structures/AutocompleteInput.tsx
Normal file
231
src/components/structures/AutocompleteInput.tsx
Normal file
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useState, ReactNode, ChangeEvent, KeyboardEvent, useRef, ReactElement } from "react";
|
||||
import classNames from "classnames";
|
||||
import { SearchIcon, CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import Autocompleter from "../../autocomplete/AutocompleteProvider";
|
||||
import { Key } from "../../Keyboard";
|
||||
import { ICompletion } from "../../autocomplete/Autocompleter";
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import useFocus from "../../hooks/useFocus";
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
provider: Autocompleter;
|
||||
placeholder: string;
|
||||
selection: ICompletion[];
|
||||
onSelectionChange: (selection: ICompletion[]) => void;
|
||||
maxSuggestions?: number;
|
||||
renderSuggestion?: (s: ICompletion) => ReactElement;
|
||||
renderSelection?: (m: ICompletion) => ReactElement;
|
||||
additionalFilter?: (suggestion: ICompletion) => boolean;
|
||||
}
|
||||
|
||||
export const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
|
||||
provider,
|
||||
renderSuggestion,
|
||||
renderSelection,
|
||||
maxSuggestions = 5,
|
||||
placeholder,
|
||||
onSelectionChange,
|
||||
selection,
|
||||
additionalFilter,
|
||||
}) => {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [suggestions, setSuggestions] = useState<ICompletion[]>([]);
|
||||
const [isFocused, onFocusChangeHandlerFunctions] = useFocus();
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const focusEditor = (): void => {
|
||||
editorRef?.current?.focus();
|
||||
};
|
||||
|
||||
const onQueryChange = async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const value = e.target.value.trim();
|
||||
setQuery(value);
|
||||
|
||||
let matches = await provider.getCompletions(
|
||||
query,
|
||||
{ start: query.length, end: query.length },
|
||||
true,
|
||||
maxSuggestions,
|
||||
);
|
||||
|
||||
if (additionalFilter) {
|
||||
matches = matches.filter(additionalFilter);
|
||||
}
|
||||
|
||||
setSuggestions(matches);
|
||||
};
|
||||
|
||||
const onClickInputArea = (): void => {
|
||||
focusEditor();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
|
||||
|
||||
// when the field is empty and the user hits backspace remove the right-most target
|
||||
if (!query && selection.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
|
||||
removeSelection(selection[selection.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelection = (completion: ICompletion): void => {
|
||||
const newSelection = [...selection];
|
||||
const index = selection.findIndex((selection) => selection.completionId === completion.completionId);
|
||||
|
||||
if (index >= 0) {
|
||||
newSelection.splice(index, 1);
|
||||
} else {
|
||||
newSelection.push(completion);
|
||||
}
|
||||
|
||||
onSelectionChange(newSelection);
|
||||
focusEditor();
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const removeSelection = (completion: ICompletion): void => {
|
||||
const newSelection = [...selection];
|
||||
const index = selection.findIndex((selection) => selection.completionId === completion.completionId);
|
||||
|
||||
if (index >= 0) {
|
||||
newSelection.splice(index, 1);
|
||||
onSelectionChange(newSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const hasPlaceholder = (): boolean => selection.length === 0 && query.length === 0;
|
||||
|
||||
return (
|
||||
<div className="mx_AutocompleteInput">
|
||||
<div
|
||||
ref={editorContainerRef}
|
||||
className={classNames({
|
||||
"mx_AutocompleteInput_editor": true,
|
||||
"mx_AutocompleteInput_editor--focused": isFocused,
|
||||
"mx_AutocompleteInput_editor--has-suggestions": suggestions.length > 0,
|
||||
})}
|
||||
onClick={onClickInputArea}
|
||||
data-testid="autocomplete-editor"
|
||||
>
|
||||
<SearchIcon className="mx_AutocompleteInput_search_icon" width="18px" height="18px" />
|
||||
{selection.map((item) => (
|
||||
<SelectionItem
|
||||
key={item.completionId}
|
||||
item={item}
|
||||
onClick={removeSelection}
|
||||
render={renderSelection}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
ref={editorRef}
|
||||
type="text"
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={onQueryChange}
|
||||
value={query}
|
||||
autoComplete="off"
|
||||
placeholder={hasPlaceholder() ? placeholder : undefined}
|
||||
data-testid="autocomplete-input"
|
||||
{...onFocusChangeHandlerFunctions}
|
||||
/>
|
||||
</div>
|
||||
{isFocused && suggestions.length ? (
|
||||
<div
|
||||
className="mx_AutocompleteInput_matches"
|
||||
style={{ top: editorContainerRef.current?.clientHeight }}
|
||||
data-testid="autocomplete-matches"
|
||||
>
|
||||
{suggestions.map((item) => (
|
||||
<SuggestionItem
|
||||
key={item.completionId}
|
||||
item={item}
|
||||
selection={selection}
|
||||
onClick={toggleSelection}
|
||||
render={renderSuggestion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SelectionItemProps = {
|
||||
item: ICompletion;
|
||||
onClick: (completion: ICompletion) => void;
|
||||
render?: (completion: ICompletion) => ReactElement;
|
||||
};
|
||||
|
||||
const SelectionItem: React.FC<SelectionItemProps> = ({ item, onClick, render }) => {
|
||||
const withContainer = (children: ReactNode): ReactElement => (
|
||||
<span
|
||||
className="mx_AutocompleteInput_editor_selection"
|
||||
data-testid={`autocomplete-selection-item-${item.completionId}`}
|
||||
>
|
||||
<span className="mx_AutocompleteInput_editor_selection_pill">{children}</span>
|
||||
<AccessibleButton
|
||||
className="mx_AutocompleteInput_editor_selection_remove_button"
|
||||
onClick={() => onClick(item)}
|
||||
data-testid={`autocomplete-selection-remove-button-${item.completionId}`}
|
||||
>
|
||||
<CloseIcon width="16px" height="16px" />
|
||||
</AccessibleButton>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (render) {
|
||||
return withContainer(render(item));
|
||||
}
|
||||
|
||||
return withContainer(<span className="mx_AutocompleteInput_editor_selection_text">{item.completion}</span>);
|
||||
};
|
||||
|
||||
type SuggestionItemProps = {
|
||||
item: ICompletion;
|
||||
selection: ICompletion[];
|
||||
onClick: (completion: ICompletion) => void;
|
||||
render?: (completion: ICompletion) => ReactElement;
|
||||
};
|
||||
|
||||
const SuggestionItem: React.FC<SuggestionItemProps> = ({ item, selection, onClick, render }) => {
|
||||
const isSelected = selection.some((selection) => selection.completionId === item.completionId);
|
||||
const classes = classNames({
|
||||
"mx_AutocompleteInput_suggestion": true,
|
||||
"mx_AutocompleteInput_suggestion--selected": isSelected,
|
||||
});
|
||||
|
||||
const withContainer = (children: ReactNode): ReactElement => (
|
||||
<div
|
||||
className={classes}
|
||||
// `onClick` cannot be used here as it would lead to focus loss and closing the suggestion list.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onClick(item);
|
||||
}}
|
||||
data-testid={`autocomplete-suggestion-item-${item.completionId}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (render) {
|
||||
return withContainer(render(item));
|
||||
}
|
||||
|
||||
return withContainer(
|
||||
<>
|
||||
<span className="mx_AutocompleteInput_suggestion_title">{item.completion}</span>
|
||||
<span className="mx_AutocompleteInput_suggestion_description">{item.completionId}</span>
|
||||
</>,
|
||||
);
|
||||
};
|
34
src/components/structures/BackdropPanel.tsx
Normal file
34
src/components/structures/BackdropPanel.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
Copyright 2021-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties } from "react";
|
||||
|
||||
interface IProps {
|
||||
backgroundImage?: string;
|
||||
blurMultiplier?: number;
|
||||
}
|
||||
|
||||
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
|
||||
if (!backgroundImage) return null;
|
||||
|
||||
const styles: CSSProperties = {};
|
||||
if (blurMultiplier) {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
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>
|
||||
);
|
||||
};
|
||||
export default BackdropPanel;
|
650
src/components/structures/ContextMenu.tsx
Normal file
650
src/components/structures/ContextMenu.tsx
Normal file
|
@ -0,0 +1,650 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { Writeable } from "../../@types/common";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
|
||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import Modal, { ModalManagerEvent } from "../../Modal";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
// pass in a custom control as the actual body.
|
||||
|
||||
const WINDOW_PADDING = 10;
|
||||
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
|
||||
|
||||
function getOrCreateContainer(): HTMLDivElement {
|
||||
let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = ContextualMenuContainerId;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export interface IPosition {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
rightAligned?: boolean;
|
||||
bottomAligned?: boolean;
|
||||
}
|
||||
|
||||
export enum ChevronFace {
|
||||
Top = "top",
|
||||
Bottom = "bottom",
|
||||
Left = "left",
|
||||
Right = "right",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
export interface MenuProps extends IPosition {
|
||||
menuWidth?: number;
|
||||
menuHeight?: number;
|
||||
|
||||
chevronOffset?: number;
|
||||
chevronFace?: ChevronFace;
|
||||
|
||||
menuPaddingTop?: number;
|
||||
menuPaddingBottom?: number;
|
||||
menuPaddingLeft?: number;
|
||||
menuPaddingRight?: number;
|
||||
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface IProps extends MenuProps {
|
||||
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
|
||||
hasBackground?: boolean;
|
||||
// whether this context menu should be focus managed. If false it must handle itself
|
||||
managed?: boolean;
|
||||
wrapperClassName?: string;
|
||||
menuClassName?: string;
|
||||
|
||||
// If true, this context menu will be mounted as a child to the parent container. Otherwise
|
||||
// it will be mounted to a container at the root of the DOM.
|
||||
mountAsChild?: boolean;
|
||||
|
||||
// If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered
|
||||
// within an existing FocusLock e.g inside a modal.
|
||||
focusLock?: boolean;
|
||||
|
||||
// call onFinished on any interaction with the menu
|
||||
closeOnInteraction?: boolean;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished(): void;
|
||||
// on resize callback
|
||||
windowResize?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contextMenuElem?: HTMLDivElement;
|
||||
}
|
||||
|
||||
// Generic ContextMenu Portal wrapper
|
||||
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
|
||||
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
|
||||
export default class ContextMenu extends React.PureComponent<React.PropsWithChildren<IProps>, IState> {
|
||||
private readonly initialFocus: HTMLElement;
|
||||
|
||||
public static defaultProps = {
|
||||
hasBackground: true,
|
||||
managed: true,
|
||||
};
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
|
||||
// persist what had focus when we got initialized so we can return it after
|
||||
this.initialFocus = document.activeElement as HTMLElement;
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
Modal.on(ModalManagerEvent.Opened, this.onModalOpen);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
Modal.off(ModalManagerEvent.Opened, this.onModalOpen);
|
||||
// return focus to the thing which had it before us
|
||||
this.initialFocus.focus();
|
||||
}
|
||||
|
||||
private onModalOpen = (): void => {
|
||||
this.props.onFinished?.();
|
||||
};
|
||||
|
||||
private collectContextMenuRect = (element: HTMLDivElement): void => {
|
||||
// We don't need to clean up when unmounting, so ignore
|
||||
if (!element) return;
|
||||
|
||||
const first =
|
||||
element.querySelector<HTMLElement>('[role^="menuitem"]') ||
|
||||
element.querySelector<HTMLElement>("[tabindex]");
|
||||
|
||||
if (first) {
|
||||
first.focus();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
contextMenuElem: element,
|
||||
});
|
||||
};
|
||||
|
||||
private onContextMenu = (e: React.MouseEvent): void => {
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
// XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst
|
||||
// a context menu and its click-guard are up without completely rewriting how the context menus work.
|
||||
setTimeout(() => {
|
||||
const clickEvent = new MouseEvent("contextmenu", {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
button: 0, // Left
|
||||
relatedTarget: null,
|
||||
});
|
||||
document.elementFromPoint(x, y)?.dispatchEvent(clickEvent);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
private onContextMenuPreventBubbling = (e: React.MouseEvent): void => {
|
||||
// stop propagation so that any context menu handlers don't leak out of this context menu
|
||||
// but do not inhibit the default browser menu
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Prevent clicks on the background from going through to the component which opened the menu.
|
||||
private onFinished = (ev: React.MouseEvent): void => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished?.();
|
||||
};
|
||||
|
||||
private onClick = (ev: React.MouseEvent): void => {
|
||||
// Don't allow clicks to escape the context menu wrapper
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.props.closeOnInteraction) {
|
||||
this.props.onFinished?.();
|
||||
}
|
||||
};
|
||||
|
||||
// We now only handle closing the ContextMenu in this keyDown handler.
|
||||
// All of the item/option navigation is delegated to RovingTabIndex.
|
||||
private onKeyDown = (ev: React.KeyboardEvent): void => {
|
||||
ev.stopPropagation(); // prevent keyboard propagating out of the context menu, we're focus-locked
|
||||
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
|
||||
// If someone is managing their own focus, we will only exit for them with Escape.
|
||||
// They are probably using props.focusLock along with this option as well.
|
||||
if (!this.props.managed) {
|
||||
if (action === KeyBindingAction.Escape) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When an <input> is focused, only handle the Escape key
|
||||
if (checkInputableElement(ev.target as HTMLElement) && action !== KeyBindingAction.Escape) {
|
||||
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!)
|
||||
) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
};
|
||||
|
||||
protected renderMenu(hasBackground = this.props.hasBackground): JSX.Element {
|
||||
const position: Partial<Writeable<DOMRect>> = {};
|
||||
const {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
bottomAligned,
|
||||
rightAligned,
|
||||
menuClassName,
|
||||
menuHeight,
|
||||
menuWidth,
|
||||
menuPaddingLeft,
|
||||
menuPaddingRight,
|
||||
menuPaddingBottom,
|
||||
menuPaddingTop,
|
||||
zIndex,
|
||||
children,
|
||||
focusLock,
|
||||
managed,
|
||||
wrapperClassName,
|
||||
chevronFace: propsChevronFace,
|
||||
chevronOffset: propsChevronOffset,
|
||||
mountAsChild,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
if (top) {
|
||||
position.top = top;
|
||||
} else {
|
||||
position.bottom = bottom;
|
||||
}
|
||||
|
||||
let chevronFace: ChevronFace;
|
||||
if (left) {
|
||||
position.left = left;
|
||||
chevronFace = ChevronFace.Left;
|
||||
} else {
|
||||
position.right = right;
|
||||
chevronFace = ChevronFace.Right;
|
||||
}
|
||||
|
||||
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
|
||||
|
||||
const chevronOffset: CSSProperties = {};
|
||||
if (propsChevronFace) {
|
||||
chevronFace = propsChevronFace;
|
||||
}
|
||||
const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
|
||||
|
||||
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
|
||||
chevronOffset.left = propsChevronOffset;
|
||||
} else {
|
||||
chevronOffset.top = propsChevronOffset;
|
||||
}
|
||||
|
||||
// If we know the dimensions of the context menu, adjust its position to
|
||||
// keep it within the bounds of the (padded) window
|
||||
const { windowWidth, windowHeight } = UIStore.instance;
|
||||
if (contextMenuRect) {
|
||||
if (position.top !== undefined) {
|
||||
let maxTop = windowHeight - WINDOW_PADDING;
|
||||
if (!bottomAligned) {
|
||||
maxTop -= contextMenuRect.height;
|
||||
}
|
||||
position.top = Math.min(position.top, maxTop);
|
||||
// Adjust the chevron if necessary
|
||||
if (chevronOffset.top !== undefined) {
|
||||
chevronOffset.top = propsChevronOffset! + top! - position.top;
|
||||
}
|
||||
} else if (position.bottom !== undefined) {
|
||||
position.bottom = Math.min(position.bottom, windowHeight - contextMenuRect.height - WINDOW_PADDING);
|
||||
if (chevronOffset.top !== undefined) {
|
||||
chevronOffset.top = propsChevronOffset! + position.bottom - bottom!;
|
||||
}
|
||||
}
|
||||
if (position.left !== undefined) {
|
||||
let maxLeft = windowWidth - WINDOW_PADDING;
|
||||
if (!rightAligned) {
|
||||
maxLeft -= contextMenuRect.width;
|
||||
}
|
||||
position.left = Math.min(position.left, maxLeft);
|
||||
if (chevronOffset.left !== undefined) {
|
||||
chevronOffset.left = propsChevronOffset! + left! - position.left;
|
||||
}
|
||||
} else if (position.right !== undefined) {
|
||||
position.right = Math.min(position.right, windowWidth - contextMenuRect.width - WINDOW_PADDING);
|
||||
if (chevronOffset.left !== undefined) {
|
||||
chevronOffset.left = propsChevronOffset! + position.right - right!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let chevron;
|
||||
if (hasChevron) {
|
||||
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 menuStyle: CSSProperties = {};
|
||||
if (menuWidth) {
|
||||
menuStyle.width = menuWidth;
|
||||
}
|
||||
|
||||
if (menuHeight) {
|
||||
menuStyle.height = menuHeight;
|
||||
}
|
||||
|
||||
if (!isNaN(Number(menuPaddingTop))) {
|
||||
menuStyle["paddingTop"] = menuPaddingTop;
|
||||
}
|
||||
if (!isNaN(Number(menuPaddingLeft))) {
|
||||
menuStyle["paddingLeft"] = menuPaddingLeft;
|
||||
}
|
||||
if (!isNaN(Number(menuPaddingBottom))) {
|
||||
menuStyle["paddingBottom"] = menuPaddingBottom;
|
||||
}
|
||||
if (!isNaN(Number(menuPaddingRight))) {
|
||||
menuStyle["paddingRight"] = menuPaddingRight;
|
||||
}
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
if (!isNaN(Number(zIndex))) {
|
||||
menuStyle["zIndex"] = zIndex! + 1;
|
||||
wrapperStyle["zIndex"] = zIndex;
|
||||
}
|
||||
|
||||
let background: JSX.Element;
|
||||
if (hasBackground) {
|
||||
background = (
|
||||
<div
|
||||
className="mx_ContextualMenu_background"
|
||||
style={wrapperStyle}
|
||||
onClick={this.onFinished}
|
||||
onContextMenu={this.onContextMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let body = (
|
||||
<>
|
||||
{chevron}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
if (focusLock) {
|
||||
body = <FocusLock>{body}</FocusLock>;
|
||||
}
|
||||
|
||||
// filter props that are invalid for DOM elements
|
||||
const {
|
||||
hasBackground: _hasBackground, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onFinished: _onFinished, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
...divProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.onKeyDown}>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<div
|
||||
className={classNames("mx_ContextualMenu_wrapper", wrapperClassName)}
|
||||
style={{ ...position, ...wrapperStyle }}
|
||||
onClick={this.onClick}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
>
|
||||
{background}
|
||||
<div
|
||||
className={menuClasses}
|
||||
style={menuStyle}
|
||||
ref={this.collectContextMenuRect}
|
||||
role={managed ? "menu" : undefined}
|
||||
{...divProps}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactChild {
|
||||
if (this.props.mountAsChild) {
|
||||
// Render as a child of the current parent
|
||||
return this.renderMenu();
|
||||
} else {
|
||||
// Render as a child of a container at the root of the DOM
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ToRightOf = {
|
||||
left: number;
|
||||
top: number;
|
||||
chevronOffset: number;
|
||||
};
|
||||
|
||||
// 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;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
return { left, top, chevronOffset };
|
||||
};
|
||||
|
||||
export type ToLeftOf = {
|
||||
chevronOffset: number;
|
||||
right: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to left of elementRect with chevronOffset
|
||||
export const toLeftOf = (elementRect: DOMRect, chevronOffset = 12): ToLeftOf => {
|
||||
const right = UIStore.instance.windowWidth - elementRect.left + window.scrollX - 3;
|
||||
let top = elementRect.top + elementRect.height / 2 + window.scrollY;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
return { right, top, chevronOffset };
|
||||
};
|
||||
|
||||
/**
|
||||
* Placement method for <ContextMenu /> to position context menu of or right of elementRect
|
||||
* depending on which side has more space.
|
||||
*/
|
||||
export const toLeftOrRightOf = (elementRect: DOMRect, chevronOffset = 12): ToRightOf | ToLeftOf => {
|
||||
const spaceToTheLeft = elementRect.left;
|
||||
const spaceToTheRight = UIStore.instance.windowWidth - elementRect.right;
|
||||
|
||||
if (spaceToTheLeft > spaceToTheRight) {
|
||||
return toLeftOf(elementRect, chevronOffset);
|
||||
}
|
||||
|
||||
return toRightOf(elementRect, chevronOffset);
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
|
||||
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
|
||||
export const aboveLeftOf = (
|
||||
elementRect: Pick<DOMRect, "right" | "top" | "bottom">,
|
||||
chevronFace = ChevronFace.None,
|
||||
vPadding = 0,
|
||||
): MenuProps => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.scrollX;
|
||||
const buttonBottom = elementRect.bottom + window.scrollY;
|
||||
const buttonTop = elementRect.top + window.scrollY;
|
||||
// Align the right edge of the menu to the right edge of the button
|
||||
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
|
||||
// Align the menu vertically on whichever side of the button has more space available.
|
||||
if (buttonBottom < UIStore.instance.windowHeight / 2) {
|
||||
menuOptions.top = buttonBottom + vPadding;
|
||||
} else {
|
||||
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
|
||||
}
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect,
|
||||
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowRightOf?)
|
||||
export const aboveRightOf = (
|
||||
elementRect: Pick<DOMRect, "left" | "top" | "bottom">,
|
||||
chevronFace = ChevronFace.None,
|
||||
vPadding = 0,
|
||||
): MenuProps => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonLeft = elementRect.left + window.scrollX;
|
||||
const buttonBottom = elementRect.bottom + window.scrollY;
|
||||
const buttonTop = elementRect.top + window.scrollY;
|
||||
// Align the left edge of the menu to the left edge of the button
|
||||
menuOptions.left = buttonLeft;
|
||||
// Align the menu vertically on whichever side of the button has more space available.
|
||||
if (buttonBottom < UIStore.instance.windowHeight / 2) {
|
||||
menuOptions.top = buttonBottom + vPadding;
|
||||
} else {
|
||||
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
|
||||
}
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
||||
// and always above elementRect
|
||||
export const alwaysMenuProps = (
|
||||
elementRect: Pick<DOMRect, "right" | "bottom" | "top">,
|
||||
chevronFace = ChevronFace.None,
|
||||
vPadding = 0,
|
||||
): IPosition & { chevronFace: ChevronFace } => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.scrollX;
|
||||
const buttonTop = elementRect.top + window.scrollY;
|
||||
// Align the right edge of the menu to the right edge of the button
|
||||
menuOptions.right = UIStore.instance.windowWidth - buttonRight;
|
||||
// Align the menu vertically above the menu
|
||||
menuOptions.bottom = UIStore.instance.windowHeight - buttonTop + vPadding;
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the right of elementRect
|
||||
// and always above elementRect
|
||||
export const alwaysAboveRightOf = (
|
||||
elementRect: Pick<DOMRect, "left" | "top">,
|
||||
chevronFace = ChevronFace.None,
|
||||
vPadding = 0,
|
||||
): IPosition & { chevronFace: ChevronFace } => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonLeft = elementRect.left + window.scrollX;
|
||||
const buttonTop = elementRect.top + window.scrollY;
|
||||
// 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;
|
||||
|
||||
return menuOptions;
|
||||
};
|
||||
|
||||
type ContextMenuTuple<T> = [
|
||||
boolean,
|
||||
RefObject<T>,
|
||||
(ev?: SyntheticEvent) => void,
|
||||
(ev?: SyntheticEvent) => void,
|
||||
(val: boolean) => void,
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
|
||||
export const useContextMenu = <T extends any = HTMLElement>(inputRef?: RefObject<T>): ContextMenuTuple<T> => {
|
||||
let button = useRef<T>(null);
|
||||
if (inputRef) {
|
||||
// if we are given a ref, use it instead of ours
|
||||
button = inputRef;
|
||||
}
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const open = (ev?: SyntheticEvent): void => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(true);
|
||||
};
|
||||
const close = (ev?: SyntheticEvent): void => {
|
||||
ev?.preventDefault();
|
||||
ev?.stopPropagation();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return [button.current ? isOpen : false, button, open, close, setIsOpen];
|
||||
};
|
||||
|
||||
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
|
||||
export function createMenu(
|
||||
ElementClass: typeof React.Component,
|
||||
props: Record<string, any>,
|
||||
): { close: (...args: any[]) => void } {
|
||||
const onFinished = function (...args: any[]): void {
|
||||
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
|
||||
props?.onFinished?.apply(null, args);
|
||||
};
|
||||
|
||||
const menu = (
|
||||
<TooltipProvider>
|
||||
<ContextMenu
|
||||
{...props}
|
||||
mountAsChild={true}
|
||||
hasBackground={false}
|
||||
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
|
||||
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
|
||||
>
|
||||
<ElementClass {...props} onFinished={onFinished} />
|
||||
</ContextMenu>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
ReactDOM.render(menu, getOrCreateContainer());
|
||||
|
||||
return { close: onFinished };
|
||||
}
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
||||
export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton";
|
||||
export { MenuItem } from "../../accessibility/context_menu/MenuItem";
|
||||
export { MenuItemCheckbox } from "../../accessibility/context_menu/MenuItemCheckbox";
|
||||
export { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
|
||||
export { StyledMenuItemCheckbox } from "../../accessibility/context_menu/StyledMenuItemCheckbox";
|
||||
export { StyledMenuItemRadio } from "../../accessibility/context_menu/StyledMenuItemRadio";
|
131
src/components/structures/EmbeddedPage.tsx
Normal file
131
src/components/structures/EmbeddedPage.tsx
Normal file
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
Copyright 2019-2024 New Vector Ltd.
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import classnames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t, TranslationKey } from "../../languageHandler";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
|
||||
interface IProps {
|
||||
// URL to request embedded page content from
|
||||
url?: string;
|
||||
// Class name prefix to apply for a given instance
|
||||
className?: string;
|
||||
// Whether to wrap the page in a scrollbar
|
||||
scrollbar?: boolean;
|
||||
// Map of keys to replace with values, e.g {$placeholder: "value"}
|
||||
replaceMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
page: string;
|
||||
}
|
||||
|
||||
export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
private unmounted = false;
|
||||
private dispatcherRef: string | null = null;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
page: "",
|
||||
};
|
||||
}
|
||||
|
||||
private translate(s: TranslationKey): string {
|
||||
return sanitizeHtml(_t(s));
|
||||
}
|
||||
|
||||
private async fetchEmbed(): Promise<void> {
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
res = await fetch(this.props.url!, { method: "GET" });
|
||||
} catch (err) {
|
||||
if (this.unmounted) return;
|
||||
logger.warn(`Error loading page: ${err}`);
|
||||
this.setState({ page: _t("cant_load_page") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn(`Error loading page: ${res.status}`);
|
||||
this.setState({ page: _t("cant_load_page") });
|
||||
return;
|
||||
}
|
||||
|
||||
let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/gm, (match, g1) => this.translate(g1));
|
||||
|
||||
if (this.props.replaceMap) {
|
||||
Object.keys(this.props.replaceMap).forEach((key) => {
|
||||
body = body.split(key).join(this.props.replaceMap![key]);
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ page: body });
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
|
||||
if (!this.props.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We use fetch to inline the page into the react component
|
||||
// so that it can inherit CSS and theming easily rather than mess around
|
||||
// with iframes and trying to synchronise document.stylesheets.
|
||||
this.fetchEmbed();
|
||||
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
if (this.dispatcherRef !== null) dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
// HACK: Workaround for the context's MatrixClient not being set up at render time.
|
||||
if (payload.action === "client_started") {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
// HACK: Workaround for the context's MatrixClient not updating.
|
||||
const client = this.context || MatrixClientPeg.get();
|
||||
const isGuest = client ? client.isGuest() : true;
|
||||
const className = this.props.className;
|
||||
const classes = classnames(className, {
|
||||
[`${className}_guest`]: isGuest,
|
||||
[`${className}_loggedIn`]: !!client,
|
||||
});
|
||||
|
||||
const content = <div className={`${className}_body`} dangerouslySetInnerHTML={{ __html: this.state.page }} />;
|
||||
|
||||
if (this.props.scrollbar) {
|
||||
return <AutoHideScrollbar className={classes}>{content}</AutoHideScrollbar>;
|
||||
} else {
|
||||
return <div className={classes}>{content}</div>;
|
||||
}
|
||||
}
|
||||
}
|
29
src/components/structures/ErrorMessage.tsx
Normal file
29
src/components/structures/ErrorMessage.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string | ReactNode | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error message component.
|
||||
* Reserves two lines to display errors to prevent layout shifts when the error pops up.
|
||||
*/
|
||||
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
|
||||
const icon = message ? <WarningIcon className="mx_Icon mx_Icon_16" /> : null;
|
||||
|
||||
return (
|
||||
<div className="mx_ErrorMessage">
|
||||
{icon}
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
122
src/components/structures/FileDropTarget.tsx
Normal file
122
src/components/structures/FileDropTarget.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
interface IProps {
|
||||
parent: HTMLElement | null;
|
||||
onFileDrop(dataTransfer: DataTransfer): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
dragging: boolean;
|
||||
counter: number;
|
||||
}
|
||||
|
||||
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
|
||||
const [state, setState] = useState<IState>({
|
||||
dragging: false,
|
||||
counter: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!parent || parent.ondrop) return;
|
||||
|
||||
const onDragEnter = (ev: DragEvent): void => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
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,
|
||||
}));
|
||||
};
|
||||
|
||||
const onDragLeave = (ev: DragEvent): void => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
setState((state) => ({
|
||||
counter: state.counter - 1,
|
||||
dragging: state.counter <= 1 ? false : state.dragging,
|
||||
}));
|
||||
};
|
||||
|
||||
const onDragOver = (ev: DragEvent): void => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
ev.dataTransfer.dropEffect = "none";
|
||||
|
||||
// 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
|
||||
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
|
||||
ev.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (ev: DragEvent): void => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!ev.dataTransfer) return;
|
||||
onFileDrop(ev.dataTransfer);
|
||||
|
||||
setState((state) => ({
|
||||
dragging: false,
|
||||
counter: state.counter - 1,
|
||||
}));
|
||||
};
|
||||
|
||||
parent?.addEventListener("drop", onDrop);
|
||||
parent?.addEventListener("dragover", onDragOver);
|
||||
parent?.addEventListener("dragenter", onDragEnter);
|
||||
parent?.addEventListener("dragleave", onDragLeave);
|
||||
|
||||
return () => {
|
||||
// disconnect the D&D event listeners from the room view. This
|
||||
// is really just for hygiene - we're going to be
|
||||
// deleted anyway, so it doesn't matter if the event listeners
|
||||
// don't get cleaned up.
|
||||
parent?.removeEventListener("drop", onDrop);
|
||||
parent?.removeEventListener("dragover", onDragOver);
|
||||
parent?.removeEventListener("dragenter", onDragEnter);
|
||||
parent?.removeEventListener("dragleave", onDragLeave);
|
||||
};
|
||||
}, [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("room|drop_file_prompt")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default FileDropTarget;
|
324
src/components/structures/FilePanel.tsx
Normal file
324
src/components/structures/FilePanel.tsx
Normal file
|
@ -0,0 +1,324 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import {
|
||||
Filter,
|
||||
EventTimelineSet,
|
||||
IRoomTimelineData,
|
||||
Direction,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
TimelineWindow,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import FilesIcon from "@vector-im/compound-design-tokens/assets/web/icons/files";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import EventIndexPeg from "../../indexing/EventIndexPeg";
|
||||
import { _t } from "../../languageHandler";
|
||||
import SearchWarning, { WarningKind } from "../views/elements/SearchWarning";
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
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 EmptyState from "../views/right_panel/EmptyState";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
timelineSet: EventTimelineSet | null;
|
||||
narrow: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* Component which shows the filtered file using a TimelinePanel
|
||||
*/
|
||||
class FilePanel extends React.Component<IProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
public declare context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
// This is used to track if a decrypted event was a live event and should be
|
||||
// added to the timeline.
|
||||
private decryptingEvents = new Set<string>();
|
||||
public noRoom = false;
|
||||
private card = createRef<HTMLDivElement>();
|
||||
|
||||
public state: IState = {
|
||||
timelineSet: null,
|
||||
narrow: false,
|
||||
};
|
||||
|
||||
private onRoomTimeline = (
|
||||
ev: MatrixEvent,
|
||||
room: Room | undefined,
|
||||
toStartOfTimeline: boolean | undefined,
|
||||
removed: boolean,
|
||||
data: IRoomTimelineData,
|
||||
): void => {
|
||||
if (room?.roomId !== this.props.roomId) return;
|
||||
if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
|
||||
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
client.decryptEventIfNeeded(ev);
|
||||
|
||||
if (ev.isBeingDecrypted()) {
|
||||
this.decryptingEvents.add(ev.getId()!);
|
||||
} else {
|
||||
this.addEncryptedLiveEvent(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private onEventDecrypted = (ev: MatrixEvent, err?: any): void => {
|
||||
if (ev.getRoomId() !== this.props.roomId) return;
|
||||
const eventId = ev.getId()!;
|
||||
|
||||
if (!this.decryptingEvents.delete(eventId)) return;
|
||||
if (err) return;
|
||||
|
||||
this.addEncryptedLiveEvent(ev);
|
||||
};
|
||||
|
||||
public addEncryptedLiveEvent(ev: MatrixEvent): void {
|
||||
if (!this.state.timelineSet) return;
|
||||
|
||||
const timeline = this.state.timelineSet.getLiveTimeline();
|
||||
if (ev.getType() !== "m.room.message") return;
|
||||
if (!["m.file", "m.image", "m.video", "m.audio"].includes(ev.getContent().msgtype!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.timelineSet.eventIdToTimeline(ev.getId()!)) {
|
||||
this.state.timelineSet.addEventToTimeline(ev, timeline, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async componentDidMount(): Promise<void> {
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
|
||||
await this.updateTimelineSet(this.props.roomId);
|
||||
|
||||
if (!client.isRoomEncrypted(this.props.roomId)) return;
|
||||
|
||||
// The timelineSets filter makes sure that encrypted events that contain
|
||||
// URLs never get added to the timeline, even if they are live events.
|
||||
// These methods are here to manually listen for such events and add
|
||||
// them despite the filter's best efforts.
|
||||
//
|
||||
// We do this only for encrypted rooms and if an event index exists,
|
||||
// this could be made more general in the future or the filter logic
|
||||
// could be fixed.
|
||||
if (EventIndexPeg.get() !== null) {
|
||||
client.on(RoomEvent.Timeline, this.onRoomTimeline);
|
||||
client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client === null) return;
|
||||
|
||||
if (!client.isRoomEncrypted(this.props.roomId)) return;
|
||||
|
||||
if (EventIndexPeg.get() !== null) {
|
||||
client.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
|
||||
client.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchFileEventsServer(room: Room): Promise<EventTimelineSet> {
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
|
||||
const filter = new Filter(client.getSafeUserId());
|
||||
filter.setDefinition({
|
||||
room: {
|
||||
timeline: {
|
||||
contains_url: true,
|
||||
types: ["m.room.message"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
filter.filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter);
|
||||
return room.getOrCreateFilteredTimelineSet(filter);
|
||||
}
|
||||
|
||||
private onPaginationRequest = (
|
||||
timelineWindow: TimelineWindow,
|
||||
direction: Direction,
|
||||
limit: number,
|
||||
): Promise<boolean> => {
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
const roomId = this.props.roomId;
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
|
||||
// We override the pagination request for encrypted rooms so that we ask
|
||||
// the event index to fulfill the pagination request. Asking the server
|
||||
// to paginate won't ever work since the server can't correctly filter
|
||||
// out events containing URLs
|
||||
if (room && client.isRoomEncrypted(roomId) && eventIndex !== null) {
|
||||
return eventIndex.paginateTimelineWindow(room, timelineWindow, direction, limit);
|
||||
} else {
|
||||
return timelineWindow.paginate(direction, limit);
|
||||
}
|
||||
};
|
||||
|
||||
private onMeasurement = (narrow: boolean): void => {
|
||||
this.setState({ narrow });
|
||||
};
|
||||
|
||||
public async updateTimelineSet(roomId: string): Promise<void> {
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const room = client.getRoom(roomId);
|
||||
const eventIndex = EventIndexPeg.get();
|
||||
|
||||
this.noRoom = !room;
|
||||
|
||||
if (room) {
|
||||
let timelineSet;
|
||||
|
||||
try {
|
||||
timelineSet = await this.fetchFileEventsServer(room);
|
||||
|
||||
// If this room is encrypted the file panel won't be populated
|
||||
// correctly since the defined filter doesn't support encrypted
|
||||
// events and the server can't check if encrypted events contain
|
||||
// URLs.
|
||||
//
|
||||
// This is where our event index comes into place, we ask the
|
||||
// event index to populate the timelineSet for us. This call
|
||||
// will add 10 events to the live timeline of the set. More can
|
||||
// be requested using pagination.
|
||||
if (client.isRoomEncrypted(roomId) && eventIndex !== null) {
|
||||
const timeline = timelineSet.getLiveTimeline();
|
||||
await eventIndex.populateFileTimeline(timelineSet, timeline, room, 10);
|
||||
}
|
||||
|
||||
this.setState({ timelineSet: timelineSet });
|
||||
} catch (error) {
|
||||
logger.error("Failed to get or create file panel filter", error);
|
||||
}
|
||||
} else {
|
||||
logger.error("Failed to add filtered timelineSet for FilePanel as no room!");
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (MatrixClientPeg.safeGet().isGuest()) {
|
||||
return (
|
||||
<BaseCard
|
||||
className="mx_FilePanel mx_RoomView_messageListWrapper"
|
||||
onClose={this.props.onClose}
|
||||
header={_t("right_panel|files_button")}
|
||||
>
|
||||
<div className="mx_RoomView_empty">
|
||||
{_t(
|
||||
"file_panel|guest_note",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<a href="#/register" key="sub">
|
||||
{sub}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
} else if (this.noRoom) {
|
||||
return (
|
||||
<BaseCard
|
||||
className="mx_FilePanel mx_RoomView_messageListWrapper"
|
||||
onClose={this.props.onClose}
|
||||
header={_t("right_panel|files_button")}
|
||||
>
|
||||
<div className="mx_RoomView_empty">{_t("file_panel|peek_note")}</div>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
|
||||
const emptyState = (
|
||||
<EmptyState
|
||||
Icon={FilesIcon}
|
||||
title={_t("file_panel|empty_heading")}
|
||||
description={_t("file_panel|empty_description")}
|
||||
/>
|
||||
);
|
||||
|
||||
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.safeGet().isRoomEncrypted(this.props.roomId);
|
||||
|
||||
if (this.state.timelineSet) {
|
||||
return (
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
...this.context,
|
||||
timelineRenderingType: TimelineRenderingType.File,
|
||||
narrow: this.state.narrow,
|
||||
}}
|
||||
>
|
||||
<BaseCard
|
||||
className="mx_FilePanel"
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer
|
||||
ref={this.card}
|
||||
header={_t("right_panel|files_button")}
|
||||
>
|
||||
{this.card.current && (
|
||||
<Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />
|
||||
)}
|
||||
<SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={this.state.timelineSet}
|
||||
showUrlPreview={false}
|
||||
onPaginationRequest={this.onPaginationRequest}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
empty={emptyState}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
...this.context,
|
||||
timelineRenderingType: TimelineRenderingType.File,
|
||||
}}
|
||||
>
|
||||
<BaseCard
|
||||
className="mx_FilePanel"
|
||||
onClose={this.props.onClose}
|
||||
header={_t("right_panel|files_button")}
|
||||
>
|
||||
<Spinner />
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FilePanel;
|
204
src/components/structures/GenericDropdownMenu.tsx
Normal file
204
src/components/structures/GenericDropdownMenu.tsx
Normal file
|
@ -0,0 +1,204 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { FunctionComponent, Key, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
|
||||
import { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import ContextMenu, { aboveLeftOf, ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
|
||||
export type GenericDropdownMenuOption<T> = {
|
||||
key: T;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
adornment?: ReactNode;
|
||||
};
|
||||
|
||||
export type GenericDropdownMenuGroup<T> = GenericDropdownMenuOption<T> & {
|
||||
options: GenericDropdownMenuOption<T>[];
|
||||
};
|
||||
|
||||
export type GenericDropdownMenuItem<T> = GenericDropdownMenuGroup<T> | GenericDropdownMenuOption<T>;
|
||||
|
||||
export function GenericDropdownMenuOption<T extends Key>({
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
isSelected,
|
||||
adornment,
|
||||
}: GenericDropdownMenuOption<T> & {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenericDropdownMenuGroup<T extends Key>({
|
||||
label,
|
||||
description,
|
||||
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>
|
||||
</div>
|
||||
{adornment}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isGenericDropdownMenuGroupArray<T>(
|
||||
items: readonly GenericDropdownMenuItem<T>[],
|
||||
): items is GenericDropdownMenuGroup<T>[] {
|
||||
return isGenericDropdownMenuGroup(items[0]);
|
||||
}
|
||||
|
||||
function isGenericDropdownMenuGroup<T>(item: GenericDropdownMenuItem<T>): item is GenericDropdownMenuGroup<T> {
|
||||
return "options" in item;
|
||||
}
|
||||
|
||||
type WithKeyFunction<T> = T extends Key
|
||||
? {
|
||||
toKey?: (key: T) => Key;
|
||||
}
|
||||
: {
|
||||
toKey: (key: T) => Key;
|
||||
};
|
||||
|
||||
export interface AdditionalOptionsProps {
|
||||
menuDisplayed: boolean;
|
||||
closeMenu: () => void;
|
||||
openMenu: () => void;
|
||||
}
|
||||
|
||||
type IProps<T> = WithKeyFunction<T> & {
|
||||
value: T;
|
||||
options: readonly GenericDropdownMenuOption<T>[] | readonly GenericDropdownMenuGroup<T>[];
|
||||
onChange: (option: T) => void;
|
||||
selectedLabel: (option: GenericDropdownMenuItem<T> | null | undefined) => ReactNode;
|
||||
onOpen?: (ev: ButtonEvent) => void;
|
||||
onClose?: (ev: ButtonEvent) => void;
|
||||
className?: string;
|
||||
AdditionalOptions?: FunctionComponent<AdditionalOptionsProps>;
|
||||
};
|
||||
|
||||
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> | undefined = options
|
||||
.flatMap((it) => (isGenericDropdownMenuGroup(it) ? [it, ...it.options] : [it]))
|
||||
.find((option) => (toKey ? toKey(option.key) === toKey(value) : option.key === value));
|
||||
let contextMenuOptions: JSX.Element;
|
||||
if (options && isGenericDropdownMenuGroupArray(options)) {
|
||||
contextMenuOptions = (
|
||||
<>
|
||||
{options.map((group) => (
|
||||
<GenericDropdownMenuGroup
|
||||
key={toKey?.(group.key) ?? (group.key as Key)}
|
||||
label={group.label}
|
||||
description={group.description}
|
||||
adornment={group.adornment}
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<GenericDropdownMenuOption
|
||||
key={toKey?.(option.key) ?? (option.key as Key)}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
onChange(option.key);
|
||||
closeMenu();
|
||||
onClose?.(ev);
|
||||
}}
|
||||
adornment={option.adornment}
|
||||
isSelected={option === selected}
|
||||
/>
|
||||
))}
|
||||
</GenericDropdownMenuGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
contextMenuOptions = (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
<GenericDropdownMenuOption
|
||||
key={toKey?.(option.key) ?? (option.key as Key)}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
onChange(option.key);
|
||||
closeMenu();
|
||||
onClose?.(ev);
|
||||
}}
|
||||
adornment={option.adornment}
|
||||
isSelected={option === selected}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const contextMenu =
|
||||
menuDisplayed && button.current ? (
|
||||
<ContextMenu
|
||||
onFinished={closeMenu}
|
||||
chevronFace={ChevronFace.Top}
|
||||
wrapperClassName={classNames("mx_GenericDropdownMenu_wrapper", className)}
|
||||
{...aboveLeftOf(button.current.getBoundingClientRect())}
|
||||
>
|
||||
{contextMenuOptions}
|
||||
{AdditionalOptions && (
|
||||
<AdditionalOptions menuDisplayed={menuDisplayed} openMenu={openMenu} closeMenu={closeMenu} />
|
||||
)}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
return (
|
||||
<>
|
||||
<ContextMenuButton
|
||||
className="mx_GenericDropdownMenu_button"
|
||||
ref={button}
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
openMenu();
|
||||
onOpen?.(ev);
|
||||
}}
|
||||
>
|
||||
{selectedLabel(selected)}
|
||||
</ContextMenuButton>
|
||||
{contextMenu}
|
||||
</>
|
||||
);
|
||||
}
|
134
src/components/structures/HomePage.tsx
Normal file
134
src/components/structures/HomePage.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { getHomePageUrl } from "../../utils/pages";
|
||||
import { _tDom } from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { useEventEmitter } from "../../hooks/useEventEmitter";
|
||||
import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext";
|
||||
import MiniAvatarUploader, { AVATAR_SIZE } from "../views/elements/MiniAvatarUploader";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import EmbeddedPage from "./EmbeddedPage";
|
||||
|
||||
const onClickSendDm = (ev: ButtonEvent): void => {
|
||||
PosthogTrackers.trackInteraction("WebHomeCreateChatButton", ev);
|
||||
dis.dispatch({ action: "view_create_chat" });
|
||||
};
|
||||
|
||||
const onClickExplore = (ev: ButtonEvent): void => {
|
||||
PosthogTrackers.trackInteraction("WebHomeExploreRoomsButton", ev);
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
const onClickNewRoom = (ev: ButtonEvent): void => {
|
||||
PosthogTrackers.trackInteraction("WebHomeCreateRoomButton", ev);
|
||||
dis.dispatch({ action: "view_create_room" });
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
justRegistered?: boolean;
|
||||
}
|
||||
|
||||
const getOwnProfile = (
|
||||
userId: string,
|
||||
): {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
} => ({
|
||||
displayName: OwnProfileStore.instance.displayName || userId,
|
||||
avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(parseInt(AVATAR_SIZE, 10)) ?? undefined,
|
||||
});
|
||||
|
||||
const UserWelcomeTop: React.FC = () => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const userId = cli.getUserId()!;
|
||||
const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId));
|
||||
useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => {
|
||||
setOwnProfile(getOwnProfile(userId));
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MiniAvatarUploader
|
||||
hasAvatar={!!ownProfile.avatarUrl}
|
||||
hasAvatarLabel={_tDom("onboarding|has_avatar_label")}
|
||||
noAvatarLabel={_tDom("onboarding|no_avatar_label")}
|
||||
setAvatarUrl={(url) => cli.setAvatarUrl(url)}
|
||||
isUserAvatar
|
||||
onClick={(ev) => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)}
|
||||
>
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={ownProfile.displayName}
|
||||
url={ownProfile.avatarUrl}
|
||||
size={AVATAR_SIZE}
|
||||
/>
|
||||
</MiniAvatarUploader>
|
||||
|
||||
<h1>{_tDom("onboarding|welcome_user", { name: ownProfile.displayName })}</h1>
|
||||
<h2>{_tDom("onboarding|welcome_detail")}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
|
||||
const cli = useMatrixClientContext();
|
||||
const config = SdkConfig.get();
|
||||
const pageUrl = getHomePageUrl(config, cli);
|
||||
|
||||
if (pageUrl) {
|
||||
return <EmbeddedPage className="mx_HomePage" url={pageUrl} scrollbar={true} />;
|
||||
}
|
||||
|
||||
let introSection: JSX.Element;
|
||||
if (justRegistered || !OwnProfileStore.instance.getHttpAvatarUrl(parseInt(AVATAR_SIZE, 10))) {
|
||||
introSection = <UserWelcomeTop />;
|
||||
} else {
|
||||
const brandingConfig = SdkConfig.getObject("branding");
|
||||
const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg";
|
||||
|
||||
introSection = (
|
||||
<React.Fragment>
|
||||
<img src={logoUrl} alt={config.brand} />
|
||||
<h1>{_tDom("onboarding|intro_welcome", { appName: config.brand })}</h1>
|
||||
<h2>{_tDom("onboarding|intro_byline")}</h2>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoHideScrollbar className="mx_HomePage mx_HomePage_default" element="main">
|
||||
<div className="mx_HomePage_default_wrapper">
|
||||
{introSection}
|
||||
<div className="mx_HomePage_default_buttons">
|
||||
<AccessibleButton onClick={onClickSendDm} className="mx_HomePage_button_sendDm">
|
||||
{_tDom("onboarding|send_dm")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={onClickExplore} className="mx_HomePage_button_explore">
|
||||
{_tDom("onboarding|explore_rooms")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={onClickNewRoom} className="mx_HomePage_button_createGroup">
|
||||
{_tDom("onboarding|create_room")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
199
src/components/structures/IndicatorScrollbar.tsx
Normal file
199
src/components/structures/IndicatorScrollbar.tsx
Normal file
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
Copyright 2018-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
|
||||
import AutoHideScrollbar, { IProps as AutoHideScrollbarProps } from "./AutoHideScrollbar";
|
||||
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
||||
|
||||
export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<AutoHideScrollbarProps<T>, "onWheel" | "element"> & {
|
||||
element?: T;
|
||||
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
|
||||
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
|
||||
// by the parent element.
|
||||
trackHorizontalOverflow?: boolean;
|
||||
|
||||
// If true, when the user tries to use their mouse wheel in the component it will
|
||||
// scroll horizontally rather than vertically. This should only be used on components
|
||||
// with no vertical scroll opportunity.
|
||||
verticalScrollsHorizontally?: boolean;
|
||||
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
interface IState {
|
||||
leftIndicatorOffset: string;
|
||||
rightIndicatorOffset: string;
|
||||
}
|
||||
|
||||
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 = null;
|
||||
private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
|
||||
|
||||
public constructor(props: IProps<T>) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
leftIndicatorOffset: "0",
|
||||
rightIndicatorOffset: "0",
|
||||
};
|
||||
}
|
||||
|
||||
private collectScroller = (scroller: HTMLDivElement): void => {
|
||||
this.props.wrappedRef?.(scroller);
|
||||
if (scroller && !this.scrollElement) {
|
||||
this.scrollElement = scroller;
|
||||
// Using the passive option to not block the main thread
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
||||
this.scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
|
||||
this.checkOverflow();
|
||||
}
|
||||
};
|
||||
|
||||
public componentDidUpdate(prevProps: IProps<T>): void {
|
||||
const prevLen = React.Children.count(prevProps.children);
|
||||
const curLen = React.Children.count(this.props.children);
|
||||
// check overflow only if amount of children changes.
|
||||
// if we don't guard here, we end up with an infinite
|
||||
// render > componentDidUpdate > checkOverflow > setState > render loop
|
||||
if (prevLen !== curLen) {
|
||||
this.checkOverflow();
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.checkOverflow();
|
||||
UIStore.instance.on(UI_EVENTS.Resize, this.checkOverflow);
|
||||
}
|
||||
|
||||
private checkOverflow = (): void => {
|
||||
if (!this.scrollElement) return;
|
||||
const hasTopOverflow = this.scrollElement.scrollTop > 0;
|
||||
const hasBottomOverflow =
|
||||
this.scrollElement.scrollHeight > this.scrollElement.scrollTop + this.scrollElement.clientHeight;
|
||||
const hasLeftOverflow = this.scrollElement.scrollLeft > 0;
|
||||
const hasRightOverflow =
|
||||
this.scrollElement.scrollWidth > this.scrollElement.scrollLeft + this.scrollElement.clientWidth;
|
||||
|
||||
if (hasTopOverflow) {
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
|
||||
} else {
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
|
||||
}
|
||||
if (hasBottomOverflow) {
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
|
||||
} else {
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
|
||||
}
|
||||
if (hasLeftOverflow) {
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
|
||||
} else {
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
|
||||
}
|
||||
if (hasRightOverflow) {
|
||||
this.scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
|
||||
} else {
|
||||
this.scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
|
||||
}
|
||||
|
||||
if (this.props.trackHorizontalOverflow) {
|
||||
this.setState({
|
||||
// Offset from absolute position of the container
|
||||
leftIndicatorOffset: hasLeftOverflow ? `${this.scrollElement.scrollLeft}px` : "0",
|
||||
|
||||
// Negative because we're coming from the right
|
||||
rightIndicatorOffset: hasRightOverflow ? `-${this.scrollElement.scrollLeft}px` : "0",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.scrollElement?.removeEventListener("scroll", this.checkOverflow);
|
||||
UIStore.instance.off(UI_EVENTS.Resize, this.checkOverflow);
|
||||
}
|
||||
|
||||
private onMouseWheel = (e: React.WheelEvent): void => {
|
||||
if (this.props.verticalScrollsHorizontally && this.scrollElement) {
|
||||
// xyThreshold is the amount of horizontal motion required for the component to
|
||||
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in
|
||||
// strange ways. Should be positive.
|
||||
const xyThreshold = 0;
|
||||
|
||||
// yRetention is the factor multiplied by the vertical delta to try and reduce
|
||||
// the harshness of the scroll behaviour. Should be a value between 0 and 1.
|
||||
const yRetention = 1.0;
|
||||
|
||||
// whenever we see horizontal scrolling, assume the user is on a trackpad
|
||||
// for at least the next 1 minute.
|
||||
const now = new Date().getTime();
|
||||
if (Math.abs(e.deltaX) > 0) {
|
||||
this.likelyTrackpadUser = true;
|
||||
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
|
||||
if (this.likelyTrackpadUser && now >= this.checkAgainForTrackpad) {
|
||||
this.likelyTrackpadUser = false;
|
||||
}
|
||||
}
|
||||
|
||||
// don't mess with the horizontal scroll for trackpad users
|
||||
// See https://github.com/vector-im/element-web/issues/10005
|
||||
if (this.likelyTrackpadUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
// movement - not what people expect. We pick arbitrary values for when to apply more
|
||||
// scroll, and how much to apply. On Windows 10, Chrome scrolls 100 units whereas
|
||||
// Firefox scrolls just 3 due to smooth scrolling.
|
||||
|
||||
const additionalScroll = e.deltaY < 0 ? -50 : 50;
|
||||
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
const val = Math.abs(e.deltaY) < 25 ? e.deltaY + additionalScroll : e.deltaY;
|
||||
this.scrollElement.scrollLeft += val * yRetention;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props;
|
||||
|
||||
const leftIndicatorStyle = { left: this.state.leftIndicatorOffset };
|
||||
const rightIndicatorStyle = { right: this.state.rightIndicatorOffset };
|
||||
const leftOverflowIndicator = trackHorizontalOverflow ? (
|
||||
<div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} />
|
||||
) : null;
|
||||
const rightOverflowIndicator = trackHorizontalOverflow ? (
|
||||
<div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<AutoHideScrollbar
|
||||
{...otherProps}
|
||||
ref={this.autoHideScrollbar}
|
||||
wrappedRef={this.collectScroller}
|
||||
onWheel={this.onMouseWheel}
|
||||
>
|
||||
{leftOverflowIndicator}
|
||||
{children}
|
||||
{rightOverflowIndicator}
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
298
src/components/structures/InteractiveAuth.tsx
Normal file
298
src/components/structures/InteractiveAuth.tsx
Normal file
|
@ -0,0 +1,298 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import {
|
||||
AuthType,
|
||||
IAuthData,
|
||||
AuthDict,
|
||||
IInputs,
|
||||
InteractiveAuth,
|
||||
IStageStatus,
|
||||
} from "matrix-js-sdk/src/interactive-auth";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import getEntryComponentForLoginType, {
|
||||
ContinueKind,
|
||||
CustomAuthType,
|
||||
IStageComponent,
|
||||
} from "../views/auth/InteractiveAuthEntryComponents";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
|
||||
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
|
||||
|
||||
type InteractiveAuthCallbackSuccess<T> = (
|
||||
success: true,
|
||||
response: T,
|
||||
extra?: { emailSid?: string; clientSecret?: string },
|
||||
) => Promise<void>;
|
||||
type InteractiveAuthCallbackFailure = (success: false, response: IAuthData | Error) => Promise<void>;
|
||||
export type InteractiveAuthCallback<T> = InteractiveAuthCallbackSuccess<T> & InteractiveAuthCallbackFailure;
|
||||
|
||||
export interface InteractiveAuthProps<T> {
|
||||
// matrix client to use for UI auth requests
|
||||
matrixClient: MatrixClient;
|
||||
// response from initial request. If not supplied, will do a request on mount.
|
||||
authData?: IAuthData;
|
||||
// Inputs provided by the user to the auth process
|
||||
// and used by various stages. As passed to js-sdk
|
||||
// interactive-auth
|
||||
inputs?: IInputs;
|
||||
sessionId?: string;
|
||||
clientSecret?: string;
|
||||
emailSid?: string;
|
||||
// If true, poll to see if the auth flow has been completed out-of-band
|
||||
poll?: boolean;
|
||||
// If true, components will be told that the 'Continue' button
|
||||
// is managed by some other party and should not be managed by
|
||||
// the component itself.
|
||||
continueIsManaged?: boolean;
|
||||
// continueText and continueKind are passed straight through to the AuthEntryComponent.
|
||||
continueText?: string;
|
||||
continueKind?: ContinueKind;
|
||||
// callback
|
||||
makeRequest(auth: AuthDict | null): Promise<T>;
|
||||
// callback called when the auth process has finished,
|
||||
// successfully or unsuccessfully.
|
||||
// @param {boolean} status True if the operation requiring
|
||||
// auth was completed successfully, false if canceled.
|
||||
// @param {object} result The result of the authenticated call
|
||||
// if successful, otherwise the error object.
|
||||
// @param {object} extra Additional information about the UI Auth
|
||||
// process:
|
||||
// * emailSid {string} If email auth was performed, the sid of
|
||||
// the auth session.
|
||||
// * clientSecret {string} The client secret used in auth
|
||||
// sessions with the ID server.
|
||||
onAuthFinished: InteractiveAuthCallback<T>;
|
||||
// As js-sdk interactive-auth
|
||||
requestEmailToken?(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
|
||||
// Called when the stage changes, or the stage's phase changes. First
|
||||
// argument is the stage, second is the phase. Some stages do not have
|
||||
// phases and will be counted as 0 (numeric).
|
||||
onStagePhaseChange?(stage: AuthType | CustomAuthType | null, phase: number): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
authStage?: CustomAuthType | AuthType;
|
||||
stageState?: IStageStatus;
|
||||
busy: boolean;
|
||||
errorText?: string;
|
||||
errorCode?: string;
|
||||
submitButtonEnabled: boolean;
|
||||
}
|
||||
|
||||
export default class InteractiveAuthComponent<T> extends React.Component<InteractiveAuthProps<T>, IState> {
|
||||
private readonly authLogic: InteractiveAuth<T>;
|
||||
private readonly intervalId: number | null = null;
|
||||
private readonly stageComponent = createRef<IStageComponent>();
|
||||
|
||||
private unmounted = false;
|
||||
|
||||
public constructor(props: InteractiveAuthProps<T>) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
submitButtonEnabled: false,
|
||||
};
|
||||
|
||||
this.authLogic = new InteractiveAuth<T>({
|
||||
authData: this.props.authData,
|
||||
doRequest: this.requestCallback,
|
||||
busyChanged: this.onBusyChanged,
|
||||
inputs: this.props.inputs,
|
||||
stateUpdated: this.authStateUpdated,
|
||||
matrixClient: this.props.matrixClient,
|
||||
sessionId: this.props.sessionId,
|
||||
clientSecret: this.props.clientSecret,
|
||||
emailSid: this.props.emailSid,
|
||||
requestEmailToken: this.requestEmailToken,
|
||||
supportedStages: [
|
||||
AuthType.Password,
|
||||
AuthType.Recaptcha,
|
||||
AuthType.Email,
|
||||
AuthType.Msisdn,
|
||||
AuthType.Terms,
|
||||
AuthType.RegistrationToken,
|
||||
AuthType.UnstableRegistrationToken,
|
||||
AuthType.Sso,
|
||||
AuthType.SsoUnstable,
|
||||
],
|
||||
});
|
||||
|
||||
if (this.props.poll) {
|
||||
this.intervalId = window.setInterval(() => {
|
||||
this.authLogic.poll();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.authLogic
|
||||
.attemptAuth()
|
||||
.then(async (result) => {
|
||||
const extra = {
|
||||
emailSid: this.authLogic.getEmailSid(),
|
||||
clientSecret: this.authLogic.getClientSecret(),
|
||||
};
|
||||
await this.props.onAuthFinished(true, result, extra);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
await this.props.onAuthFinished(false, error);
|
||||
logger.error("Error during user-interactive auth:", error);
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = error.message || error.toString();
|
||||
this.setState({
|
||||
errorText: msg,
|
||||
errorCode: error.errcode,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
private requestEmailToken = async (
|
||||
email: string,
|
||||
secret: string,
|
||||
attempt: number,
|
||||
session: string,
|
||||
): Promise<{ sid: string }> => {
|
||||
this.setState({
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
// We know this method only gets called on flows where requestEmailToken is passed but types don't
|
||||
return await this.props.requestEmailToken!(email, secret, attempt, session);
|
||||
} finally {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
private requestCallback = (auth: AuthDict | null, background: boolean): Promise<T> => {
|
||||
// This wrapper just exists because the js-sdk passes a second
|
||||
// 'busy' param for backwards compat. This throws the tests off
|
||||
// so discard it here.
|
||||
return this.props.makeRequest(auth);
|
||||
};
|
||||
|
||||
private onBusyChanged = (busy: boolean): void => {
|
||||
// if we've started doing stuff, reset the error messages
|
||||
// The JS SDK eagerly reports itself as "not busy" right after any
|
||||
// immediate work has completed, but that's not really what we want at
|
||||
// the UI layer, so we ignore this signal and show a spinner until
|
||||
// there's a new screen to show the user. This is implemented by setting
|
||||
// `busy: false` in `authStateUpdated`.
|
||||
// See also https://github.com/vector-im/element-web/issues/12546
|
||||
if (busy) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: undefined,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// authStateUpdated is not called during sso flows
|
||||
if (!busy && (this.state.authStage === AuthType.Sso || this.state.authStage === AuthType.SsoUnstable)) {
|
||||
this.setState({ busy });
|
||||
}
|
||||
};
|
||||
|
||||
private setFocus(): void {
|
||||
this.stageComponent.current?.focus?.();
|
||||
}
|
||||
|
||||
private submitAuthDict = (authData: AuthDict): void => {
|
||||
this.authLogic.submitAuthDict(authData);
|
||||
};
|
||||
|
||||
private onPhaseChange = (newPhase: number): void => {
|
||||
this.props.onStagePhaseChange?.(this.state.authStage ?? null, newPhase || 0);
|
||||
};
|
||||
|
||||
private onStageCancel = async (): Promise<void> => {
|
||||
await this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
|
||||
};
|
||||
|
||||
private onAuthStageFailed = async (e: Error): Promise<void> => {
|
||||
await this.props.onAuthFinished(false, e);
|
||||
};
|
||||
|
||||
private setEmailSid = (sid: string): void => {
|
||||
this.authLogic.setEmailSid(sid);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const stage = this.state.authStage;
|
||||
if (!stage) {
|
||||
if (this.state.busy) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const StageComponent = getEntryComponentForLoginType(stage);
|
||||
return (
|
||||
<StageComponent
|
||||
ref={this.stageComponent as any}
|
||||
loginType={stage}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authSessionId={this.authLogic.getSessionId()}
|
||||
clientSecret={this.authLogic.getClientSecret()}
|
||||
stageParams={this.authLogic.getStageParams(stage)}
|
||||
submitAuthDict={this.submitAuthDict}
|
||||
errorText={this.state.errorText}
|
||||
errorCode={this.state.errorCode}
|
||||
busy={this.state.busy}
|
||||
inputs={this.props.inputs}
|
||||
stageState={this.state.stageState}
|
||||
fail={this.onAuthStageFailed}
|
||||
setEmailSid={this.setEmailSid}
|
||||
showContinue={!this.props.continueIsManaged}
|
||||
onPhaseChange={this.onPhaseChange}
|
||||
requestEmailToken={this.authLogic.requestEmailToken}
|
||||
continueText={this.props.continueText}
|
||||
continueKind={this.props.continueKind}
|
||||
onCancel={this.onStageCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
27
src/components/structures/LargeLoader.tsx
Normal file
27
src/components/structures/LargeLoader.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
|
||||
interface LargeLoaderProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader component that displays a (almost centered) spinner and loading message.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
};
|
420
src/components/structures/LeftPanel.tsx
Normal file
420
src/components/structures/LeftPanel.tsx
Normal file
|
@ -0,0 +1,420 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList from "../views/rooms/RoomList";
|
||||
import LegacyCallHandler from "../../LegacyCallHandler";
|
||||
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import RoomSearch from "./RoomSearch";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import SpaceStore from "../../stores/spaces/SpaceStore";
|
||||
import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
import { IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
|
||||
import RoomListHeader from "../views/rooms/RoomListHeader";
|
||||
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import IndicatorScrollbar from "./IndicatorScrollbar";
|
||||
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
|
||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
|
||||
import { UIComponent } from "../../settings/UIFeature";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import PageType from "../../PageTypes";
|
||||
import { UserOnboardingButton } from "../views/user-onboarding/UserOnboardingButton";
|
||||
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
pageType: PageType;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
enum BreadcrumbsMode {
|
||||
Disabled,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
showBreadcrumbs: BreadcrumbsMode;
|
||||
activeSpace: SpaceKey;
|
||||
}
|
||||
|
||||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private listContainerRef = createRef<HTMLDivElement>();
|
||||
private roomListRef = createRef<RoomList>();
|
||||
private focusedElement: Element | null = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
activeSpace: SpaceStore.instance.activeSpace,
|
||||
showBreadcrumbs: LeftPanel.breadcrumbsMode,
|
||||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
}
|
||||
|
||||
private static get breadcrumbsMode(): BreadcrumbsMode {
|
||||
return !BreadcrumbsStore.instance.visible ? BreadcrumbsMode.Disabled : BreadcrumbsMode.Legacy;
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.listContainerRef.current) {
|
||||
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
|
||||
// Using the passive option to not block the main thread
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
|
||||
this.listContainerRef.current.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
}
|
||||
UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
|
||||
UIStore.instance.stopTrackingElementDimensions("ListContainer");
|
||||
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
|
||||
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
|
||||
if (prevState.activeSpace !== this.state.activeSpace) {
|
||||
this.refreshStickyHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
private updateActiveSpace = (activeSpace: SpaceKey): void => {
|
||||
this.setState({ activeSpace });
|
||||
};
|
||||
|
||||
private onDialPad = (): void => {
|
||||
dis.fire(Action.OpenDialPad);
|
||||
};
|
||||
|
||||
private onExplore = (ev: ButtonEvent): void => {
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
|
||||
};
|
||||
|
||||
private refreshStickyHeaders = (): void => {
|
||||
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
|
||||
this.handleStickyHeaders(this.listContainerRef.current);
|
||||
};
|
||||
|
||||
private onBreadcrumbsUpdate = (): void => {
|
||||
const newVal = LeftPanel.breadcrumbsMode;
|
||||
if (newVal !== this.state.showBreadcrumbs) {
|
||||
this.setState({ showBreadcrumbs: newVal });
|
||||
|
||||
// Update the sticky headers too as the breadcrumbs will be popping in or out.
|
||||
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
|
||||
this.handleStickyHeaders(this.listContainerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
private handleStickyHeaders(list: HTMLDivElement): void {
|
||||
if (this.isDoingStickyHeaders) return;
|
||||
this.isDoingStickyHeaders = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
this.doStickyHeaders(list);
|
||||
this.isDoingStickyHeaders = false;
|
||||
});
|
||||
}
|
||||
|
||||
private doStickyHeaders(list: HTMLDivElement): void {
|
||||
if (!list.parentElement) return;
|
||||
const topEdge = list.scrollTop;
|
||||
const bottomEdge = list.offsetHeight + list.scrollTop;
|
||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
|
||||
|
||||
// We track which styles we want on a target before making the changes to avoid
|
||||
// excessive layout updates.
|
||||
const targetStyles = new Map<
|
||||
HTMLDivElement,
|
||||
{
|
||||
stickyTop?: boolean;
|
||||
stickyBottom?: boolean;
|
||||
makeInvisible?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
let lastTopHeader: HTMLDivElement | undefined;
|
||||
let firstBottomHeader: HTMLDivElement | undefined;
|
||||
for (const sublist of sublists) {
|
||||
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist_stickable");
|
||||
if (!header) continue; // this should never occur
|
||||
header.style.removeProperty("display"); // always clear display:none first
|
||||
|
||||
// When an element is <=40% off screen, make it take over
|
||||
const offScreenFactor = 0.4;
|
||||
const isOffTop = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT <= topEdge;
|
||||
const isOffBottom = sublist.offsetTop + offScreenFactor * HEADER_HEIGHT >= bottomEdge;
|
||||
|
||||
if (isOffTop || sublist === sublists[0]) {
|
||||
targetStyles.set(header, { stickyTop: true });
|
||||
if (lastTopHeader) {
|
||||
lastTopHeader.style.display = "none";
|
||||
targetStyles.set(lastTopHeader, { makeInvisible: true });
|
||||
}
|
||||
lastTopHeader = header;
|
||||
} else if (isOffBottom && !firstBottomHeader) {
|
||||
targetStyles.set(header, { stickyBottom: true });
|
||||
firstBottomHeader = header;
|
||||
} else {
|
||||
targetStyles.set(header, {}); // nothing == clear
|
||||
}
|
||||
}
|
||||
|
||||
// Run over the style changes and make them reality. We check to see if we're about to
|
||||
// cause a no-op update, as adding/removing properties that are/aren't there cause
|
||||
// layout updates.
|
||||
for (const header of targetStyles.keys()) {
|
||||
const style = targetStyles.get(header)!;
|
||||
|
||||
if (style.makeInvisible) {
|
||||
// we will have already removed the 'display: none', so add it back.
|
||||
header.style.display = "none";
|
||||
continue; // nothing else to do, even if sticky somehow
|
||||
}
|
||||
|
||||
if (style.stickyTop) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_stickyTop");
|
||||
}
|
||||
|
||||
const newTop = `${list.parentElement.offsetTop}px`;
|
||||
if (header.style.top !== newTop) {
|
||||
header.style.top = newTop;
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyTop")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyTop");
|
||||
}
|
||||
if (header.style.top) {
|
||||
header.style.removeProperty("top");
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
|
||||
const offset =
|
||||
UIStore.instance.windowHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight);
|
||||
const newBottom = `${offset}px`;
|
||||
if (header.style.bottom !== newBottom) {
|
||||
header.style.bottom = newBottom;
|
||||
}
|
||||
} else {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom");
|
||||
}
|
||||
if (header.style.bottom) {
|
||||
header.style.removeProperty("bottom");
|
||||
}
|
||||
}
|
||||
|
||||
if (style.stickyTop || style.stickyBottom) {
|
||||
if (!header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
|
||||
header.classList.add("mx_RoomSublist_headerContainer_sticky");
|
||||
}
|
||||
|
||||
const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
|
||||
if (listDimensions) {
|
||||
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
|
||||
const headerStickyWidth = listDimensions.width - headerRightMargin;
|
||||
const newWidth = `${headerStickyWidth}px`;
|
||||
if (header.style.width !== newWidth) {
|
||||
header.style.width = newWidth;
|
||||
}
|
||||
}
|
||||
} else if (!style.stickyTop && !style.stickyBottom) {
|
||||
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
|
||||
header.classList.remove("mx_RoomSublist_headerContainer_sticky");
|
||||
}
|
||||
|
||||
if (header.style.width) {
|
||||
header.style.removeProperty("width");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add appropriate sticky classes to wrapper so it has
|
||||
// the necessary top/bottom padding to put the sticky header in
|
||||
const listWrapper = list.parentElement; // .mx_LeftPanel_roomListWrapper
|
||||
if (!listWrapper) return;
|
||||
if (lastTopHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyTop");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyTop");
|
||||
}
|
||||
if (firstBottomHeader) {
|
||||
listWrapper.classList.add("mx_LeftPanel_roomListWrapper_stickyBottom");
|
||||
} else {
|
||||
listWrapper.classList.remove("mx_LeftPanel_roomListWrapper_stickyBottom");
|
||||
}
|
||||
}
|
||||
|
||||
private onScroll = (ev: Event): void => {
|
||||
const list = ev.target as HTMLDivElement;
|
||||
this.handleStickyHeaders(list);
|
||||
};
|
||||
|
||||
private onFocus = (ev: React.FocusEvent): void => {
|
||||
this.focusedElement = ev.target;
|
||||
};
|
||||
|
||||
private onBlur = (): void => {
|
||||
this.focusedElement = null;
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState): void => {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
const action = getKeyBindingsManager().getRoomListAction(ev);
|
||||
switch (action) {
|
||||
case KeyBindingAction.NextRoom:
|
||||
if (!state) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.roomListRef.current?.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const navAction = getKeyBindingsManager().getNavigationAction(ev);
|
||||
if (navAction === KeyBindingAction.PreviousLandmark || navAction === KeyBindingAction.NextLandmark) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
LandmarkNavigation.findAndFocusNextLandmark(
|
||||
Landmark.ROOM_SEARCH,
|
||||
navAction === KeyBindingAction.PreviousLandmark,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private renderBreadcrumbs(): React.ReactNode {
|
||||
if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) {
|
||||
return (
|
||||
<IndicatorScrollbar
|
||||
role="navigation"
|
||||
aria-label={_t("a11y|recent_rooms")}
|
||||
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||
verticalScrollsHorizontally={true}
|
||||
>
|
||||
<RoomBreadcrumbs />
|
||||
</IndicatorScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSearchDialExplore(): React.ReactNode {
|
||||
let dialPadButton: JSX.Element | undefined;
|
||||
|
||||
// If we have dialer support, show a button to bring up the dial pad
|
||||
// to start a new call
|
||||
if (LegacyCallHandler.instance.getSupportsPstnProtocol()) {
|
||||
dialPadButton = (
|
||||
<AccessibleButton
|
||||
className={classNames("mx_LeftPanel_dialPadButton", {})}
|
||||
onClick={this.onDialPad}
|
||||
title={_t("left_panel|open_dial_pad")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let rightButton: JSX.Element | undefined;
|
||||
if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) {
|
||||
rightButton = (
|
||||
<AccessibleButton
|
||||
className="mx_LeftPanel_exploreButton"
|
||||
onClick={this.onExplore}
|
||||
title={_t("action|explore_rooms")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx_LeftPanel_filterContainer"
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
role="search"
|
||||
>
|
||||
<RoomSearch isMinimized={this.props.isMinimized} />
|
||||
|
||||
{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 containerClasses = classNames({
|
||||
mx_LeftPanel: true,
|
||||
mx_LeftPanel_minimized: this.props.isMinimized,
|
||||
});
|
||||
|
||||
const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar");
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="mx_LeftPanel_roomListContainer">
|
||||
{shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()}
|
||||
{this.renderBreadcrumbs()}
|
||||
{!this.props.isMinimized && <RoomListHeader onVisibilityChange={this.refreshStickyHeaders} />}
|
||||
<UserOnboardingButton
|
||||
selected={this.props.pageType === PageType.HomePage}
|
||||
minimized={this.props.isMinimized}
|
||||
/>
|
||||
<nav className="mx_LeftPanel_roomListWrapper" aria-label={_t("common|rooms")}>
|
||||
<div
|
||||
className={roomListClasses}
|
||||
ref={this.listContainerRef}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{roomList}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
206
src/components/structures/LegacyCallEventGrouper.ts
Normal file
206
src/components/structures/LegacyCallEventGrouper.ts
Normal file
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
|
||||
export enum LegacyCallEventGrouperEvent {
|
||||
StateChanged = "state_changed",
|
||||
SilencedChanged = "silenced_changed",
|
||||
LengthChanged = "length_changed",
|
||||
}
|
||||
|
||||
const CONNECTING_STATES = [
|
||||
CallState.Connecting,
|
||||
CallState.WaitLocalMedia,
|
||||
CallState.CreateOffer,
|
||||
CallState.CreateAnswer,
|
||||
];
|
||||
|
||||
const SUPPORTED_STATES = [CallState.Connected, CallState.Ringing, CallState.Ended];
|
||||
|
||||
const isCallEventType = (eventType: string): boolean =>
|
||||
eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call.");
|
||||
|
||||
export const isCallEvent = (event: MatrixEvent): boolean => isCallEventType(event.getType());
|
||||
|
||||
export function buildLegacyCallEventGroupers(
|
||||
callEventGroupers: Map<string, LegacyCallEventGrouper>,
|
||||
events?: MatrixEvent[],
|
||||
): Map<string, LegacyCallEventGrouper> {
|
||||
const newCallEventGroupers = new Map();
|
||||
events?.forEach((ev) => {
|
||||
if (!isCallEvent(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callId = ev.getContent().call_id;
|
||||
if (!newCallEventGroupers.has(callId)) {
|
||||
if (callEventGroupers.has(callId)) {
|
||||
// reuse the LegacyCallEventGrouper object where possible
|
||||
newCallEventGroupers.set(callId, callEventGroupers.get(callId));
|
||||
} else {
|
||||
newCallEventGroupers.set(callId, new LegacyCallEventGrouper());
|
||||
}
|
||||
}
|
||||
newCallEventGroupers.get(callId).add(ev);
|
||||
});
|
||||
return newCallEventGroupers;
|
||||
}
|
||||
|
||||
export default class LegacyCallEventGrouper extends EventEmitter {
|
||||
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
|
||||
private call: MatrixCall | null = null;
|
||||
public state?: CallState;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallsChanged, this.setCall);
|
||||
LegacyCallHandler.instance.addListener(
|
||||
LegacyCallHandlerEvent.SilencedCallsChanged,
|
||||
this.onSilencedCallsChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private get invite(): MatrixEvent | undefined {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallInvite);
|
||||
}
|
||||
|
||||
private get hangup(): MatrixEvent | undefined {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallHangup);
|
||||
}
|
||||
|
||||
private get reject(): MatrixEvent | undefined {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallReject);
|
||||
}
|
||||
|
||||
private get selectAnswer(): MatrixEvent | undefined {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
|
||||
}
|
||||
|
||||
public get isVoice(): boolean | undefined {
|
||||
const invite = this.invite;
|
||||
if (!invite) return undefined;
|
||||
|
||||
// FIXME: Find a better way to determine this from the event?
|
||||
if (invite.getContent()?.offer?.sdp?.indexOf("m=video") !== -1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public get hangupReason(): string | null {
|
||||
return this.call?.hangupReason ?? this.hangup?.getContent()?.reason ?? null;
|
||||
}
|
||||
|
||||
public get rejectParty(): string | undefined {
|
||||
return this.reject?.getSender();
|
||||
}
|
||||
|
||||
public get gotRejected(): boolean {
|
||||
return Boolean(this.reject);
|
||||
}
|
||||
|
||||
public get duration(): number | null {
|
||||
if (!this.hangup?.getDate() || !this.selectAnswer?.getDate()) return null;
|
||||
return this.hangup.getDate()!.getTime() - this.selectAnswer.getDate()!.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are only events from the other side - we missed the call
|
||||
*/
|
||||
public get callWasMissed(): boolean {
|
||||
return (
|
||||
this.state === CallState.Ended &&
|
||||
![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.safeGet().getUserId())
|
||||
);
|
||||
}
|
||||
|
||||
private get callId(): string | undefined {
|
||||
return [...this.events][0]?.getContent()?.call_id;
|
||||
}
|
||||
|
||||
private get roomId(): string | undefined {
|
||||
return [...this.events][0]?.getRoomId();
|
||||
}
|
||||
|
||||
private onSilencedCallsChanged = (): void => {
|
||||
const newState = LegacyCallHandler.instance.isCallSilenced(this.callId);
|
||||
this.emit(LegacyCallEventGrouperEvent.SilencedChanged, newState);
|
||||
};
|
||||
|
||||
private onLengthChanged = (length: number): void => {
|
||||
this.emit(LegacyCallEventGrouperEvent.LengthChanged, length);
|
||||
};
|
||||
|
||||
public answerCall = (): void => {
|
||||
const roomId = this.roomId;
|
||||
if (!roomId) return;
|
||||
LegacyCallHandler.instance.answerCall(roomId);
|
||||
};
|
||||
|
||||
public rejectCall = (): void => {
|
||||
const roomId = this.roomId;
|
||||
if (!roomId) return;
|
||||
LegacyCallHandler.instance.hangupOrReject(roomId, true);
|
||||
};
|
||||
|
||||
public callBack = (): void => {
|
||||
const roomId = this.roomId;
|
||||
if (!roomId) return;
|
||||
LegacyCallHandler.instance.placeCall(roomId, this.isVoice ? CallType.Voice : CallType.Video);
|
||||
};
|
||||
|
||||
public toggleSilenced = (): void => {
|
||||
const silenced = LegacyCallHandler.instance.isCallSilenced(this.callId);
|
||||
silenced
|
||||
? LegacyCallHandler.instance.unSilenceCall(this.callId)
|
||||
: LegacyCallHandler.instance.silenceCall(this.callId);
|
||||
};
|
||||
|
||||
private setCallListeners(): void {
|
||||
if (!this.call) return;
|
||||
this.call.addListener(CallEvent.State, this.setState);
|
||||
this.call.addListener(CallEvent.LengthChanged, this.onLengthChanged);
|
||||
}
|
||||
|
||||
private setState = (): void => {
|
||||
if (this.call && CONNECTING_STATES.includes(this.call.state)) {
|
||||
this.state = CallState.Connecting;
|
||||
} else if (this.call && SUPPORTED_STATES.includes(this.call.state)) {
|
||||
this.state = this.call.state;
|
||||
} else {
|
||||
if (this.reject) {
|
||||
this.state = CallState.Ended;
|
||||
} else if (this.hangup) {
|
||||
this.state = CallState.Ended;
|
||||
} else if (this.invite && this.call) {
|
||||
this.state = CallState.Connecting;
|
||||
}
|
||||
}
|
||||
this.emit(LegacyCallEventGrouperEvent.StateChanged, this.state);
|
||||
};
|
||||
|
||||
private setCall = (): void => {
|
||||
const callId = this.callId;
|
||||
if (!callId || this.call) return;
|
||||
|
||||
this.call = LegacyCallHandler.instance.getCallById(callId);
|
||||
this.setCallListeners();
|
||||
this.setState();
|
||||
};
|
||||
|
||||
public add(event: MatrixEvent): void {
|
||||
if (this.events.has(event)) return; // nothing to do
|
||||
this.events.add(event);
|
||||
this.setCall();
|
||||
}
|
||||
}
|
750
src/components/structures/LoggedInView.tsx
Normal file
750
src/components/structures/LoggedInView.tsx
Normal file
|
@ -0,0 +1,750 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ClipboardEvent } from "react";
|
||||
import {
|
||||
ClientEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
RoomStateEvent,
|
||||
MatrixError,
|
||||
IUsageLimit,
|
||||
SyncStateData,
|
||||
SyncState,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { isOnlyCtrlOrCmdKeyEvent, Key } from "../../Keyboard";
|
||||
import PageTypes from "../../PageTypes";
|
||||
import MediaDeviceHandler from "../../MediaDeviceHandler";
|
||||
import { fixupColorFonts } from "../../utils/FontManager";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../settings/SettingLevel";
|
||||
import ResizeHandle from "../views/elements/ResizeHandle";
|
||||
import { CollapseDistributor, Resizer } from "../../resizer";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
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 { 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 { CollapseItem, ICollapseConfig } from "../../resizer/distributors/collapse";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import { IOpts } from "../../createRoom";
|
||||
import SpacePanel from "../views/spaces/SpacePanel";
|
||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
||||
import AudioFeedArrayForLegacyCall from "../views/voip/AudioFeedArrayForLegacyCall";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
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 { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
|
||||
import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning";
|
||||
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
|
||||
import { PipContainer } from "./PipContainer";
|
||||
import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules";
|
||||
import { ConfigOptions } from "../../SdkConfig";
|
||||
import { MatrixClientContextProvider } from "./MatrixClientContextProvider";
|
||||
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
|
||||
|
||||
// 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.
|
||||
// NB. this is just for server notices rather than pinned messages in general.
|
||||
const MAX_PINNED_NOTICES_PER_ROOM = 2;
|
||||
|
||||
// Used to find the closest inputable thing. Because of how our composer works,
|
||||
// your caret might be within a paragraph/font/div/whatever within the
|
||||
// contenteditable rather than directly in something inputable.
|
||||
function getInputableElement(el: HTMLElement): HTMLElement | null {
|
||||
return el.closest("input, textarea, select, [contenteditable=true]");
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
matrixClient: MatrixClient;
|
||||
// Called with the credentials of a registered user (if they were a ROU that
|
||||
// transitioned to PWLU)
|
||||
onRegistered: (credentials: IMatrixClientCreds) => Promise<MatrixClient>;
|
||||
hideToSRUsers: boolean;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
// eslint-disable-next-line camelcase
|
||||
page_type?: string;
|
||||
autoJoin?: boolean;
|
||||
threepidInvite?: IThreepidInvite;
|
||||
roomOobData?: IOOBData;
|
||||
currentRoomId: string | null;
|
||||
collapseLhs: boolean;
|
||||
config: ConfigOptions;
|
||||
currentUserId: string | null;
|
||||
justRegistered?: boolean;
|
||||
roomJustCreatedOpts?: IOpts;
|
||||
forceTimeline?: boolean; // see props on MatrixChat
|
||||
}
|
||||
|
||||
interface IState {
|
||||
syncErrorData?: SyncStateData;
|
||||
usageLimitDismissed: boolean;
|
||||
usageLimitEventContent?: IUsageLimit;
|
||||
usageLimitEventTs?: number;
|
||||
useCompactLayout: boolean;
|
||||
activeCalls: Array<MatrixCall>;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is what our MatrixChat shows when we are logged in. The precise view is
|
||||
* determined by the page_type property.
|
||||
*
|
||||
* Currently, it's very tightly coupled with MatrixChat. We should try to do
|
||||
* something about that.
|
||||
*
|
||||
* Components mounted below us can access the matrix client via the react context.
|
||||
*/
|
||||
class LoggedInView extends React.Component<IProps, IState> {
|
||||
public static displayName = "LoggedInView";
|
||||
|
||||
protected readonly _matrixClient: MatrixClient;
|
||||
protected readonly _roomView: React.RefObject<RoomViewType>;
|
||||
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
|
||||
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
|
||||
protected layoutWatcherRef?: string;
|
||||
protected compactLayoutWatcherRef?: string;
|
||||
protected backgroundImageWatcherRef?: string;
|
||||
protected timezoneProfileUpdateRef?: string[];
|
||||
protected resizer?: Resizer<ICollapseConfig, CollapseItem>;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
syncErrorData: undefined,
|
||||
// use compact timeline view
|
||||
useCompactLayout: SettingsStore.getValue("useCompactLayout"),
|
||||
usageLimitDismissed: false,
|
||||
activeCalls: LegacyCallHandler.instance.getAllActiveCalls(),
|
||||
};
|
||||
|
||||
// stash the MatrixClient in case we log out before we are unmounted
|
||||
this._matrixClient = this.props.matrixClient;
|
||||
|
||||
MediaDeviceHandler.loadDevices();
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = React.createRef();
|
||||
this._resizeContainer = React.createRef();
|
||||
this.resizeHandler = React.createRef();
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
document.addEventListener("keydown", this.onNativeKeyDown, false);
|
||||
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.onCallState);
|
||||
|
||||
this.updateServerNoticeEvents();
|
||||
|
||||
this._matrixClient.on(ClientEvent.AccountData, this.onAccountData);
|
||||
// check push rules on start up as well
|
||||
monitorSyncedPushRules(this._matrixClient.getAccountData("m.push_rules"), this._matrixClient);
|
||||
this._matrixClient.on(ClientEvent.Sync, this.onSync);
|
||||
// Call `onSync` with the current state as well
|
||||
this.onSync(this._matrixClient.getSyncState(), null, this._matrixClient.getSyncStateData() ?? undefined);
|
||||
this._matrixClient.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
|
||||
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onCompactLayoutChanged);
|
||||
this.compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout",
|
||||
null,
|
||||
this.onCompactLayoutChanged,
|
||||
);
|
||||
this.backgroundImageWatcherRef = SettingsStore.watchSetting(
|
||||
"RoomList.backgroundImage",
|
||||
null,
|
||||
this.refreshBackgroundImage,
|
||||
);
|
||||
|
||||
this.timezoneProfileUpdateRef = [
|
||||
SettingsStore.watchSetting("userTimezonePublish", null, this.onTimezoneUpdate),
|
||||
SettingsStore.watchSetting("userTimezone", null, this.onTimezoneUpdate),
|
||||
];
|
||||
|
||||
this.resizer = this.createResizer();
|
||||
this.resizer.attach();
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage);
|
||||
this.loadResizerPreferences();
|
||||
this.refreshBackgroundImage();
|
||||
}
|
||||
|
||||
private onTimezoneUpdate = async (): Promise<void> => {
|
||||
if (!SettingsStore.getValue("userTimezonePublish")) {
|
||||
// Ensure it's deleted
|
||||
try {
|
||||
await this._matrixClient.deleteExtendedProfileProperty("us.cloke.msc4175.tz");
|
||||
} catch (ex) {
|
||||
console.warn("Failed to delete timezone from user profile", ex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentTimezone =
|
||||
SettingsStore.getValue("userTimezone") ||
|
||||
// If the timezone is empty, then use the browser timezone.
|
||||
// eslint-disable-next-line new-cap
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (!currentTimezone || typeof currentTimezone !== "string") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._matrixClient.setExtendedProfileProperty("us.cloke.msc4175.tz", currentTimezone);
|
||||
} catch (ex) {
|
||||
console.warn("Failed to update user profile with current timezone", ex);
|
||||
}
|
||||
};
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
document.removeEventListener("keydown", this.onNativeKeyDown, false);
|
||||
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
|
||||
this._matrixClient.removeListener(ClientEvent.AccountData, this.onAccountData);
|
||||
this._matrixClient.removeListener(ClientEvent.Sync, this.onSync);
|
||||
this._matrixClient.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage);
|
||||
if (this.layoutWatcherRef) SettingsStore.unwatchSetting(this.layoutWatcherRef);
|
||||
if (this.compactLayoutWatcherRef) SettingsStore.unwatchSetting(this.compactLayoutWatcherRef);
|
||||
if (this.backgroundImageWatcherRef) SettingsStore.unwatchSetting(this.backgroundImageWatcherRef);
|
||||
this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s));
|
||||
this.resizer?.detach();
|
||||
}
|
||||
|
||||
private onCallState = (): void => {
|
||||
const activeCalls = LegacyCallHandler.instance.getAllActiveCalls();
|
||||
if (activeCalls === this.state.activeCalls) return;
|
||||
this.setState({ activeCalls });
|
||||
};
|
||||
|
||||
private refreshBackgroundImage = async (): Promise<void> => {
|
||||
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
|
||||
if (backgroundImage) {
|
||||
// convert to http before going much further
|
||||
backgroundImage = mediaFromMxc(backgroundImage).srcHttp;
|
||||
} else {
|
||||
backgroundImage = OwnProfileStore.instance.getHttpAvatarUrl();
|
||||
}
|
||||
this.setState({ backgroundImage });
|
||||
};
|
||||
|
||||
public canResetTimelineInRoom = (roomId: string): boolean => {
|
||||
if (!this._roomView.current) {
|
||||
return true;
|
||||
}
|
||||
return this._roomView.current.canResetTimeline();
|
||||
};
|
||||
|
||||
private createResizer(): Resizer<ICollapseConfig, CollapseItem> {
|
||||
let panelSize: number | null;
|
||||
let panelCollapsed: boolean;
|
||||
const collapseConfig: ICollapseConfig = {
|
||||
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
|
||||
toggleSize: 206 - 50,
|
||||
onCollapsed: (collapsed) => {
|
||||
panelCollapsed = collapsed;
|
||||
if (collapsed) {
|
||||
dis.dispatch({ action: "hide_left_panel" });
|
||||
window.localStorage.setItem("mx_lhs_size", "0");
|
||||
} else {
|
||||
dis.dispatch({ action: "show_left_panel" });
|
||||
}
|
||||
},
|
||||
onResized: (size) => {
|
||||
panelSize = size;
|
||||
this.props.resizeNotifier.notifyLeftHandleResized();
|
||||
},
|
||||
onResizeStart: () => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
},
|
||||
onResizeStop: () => {
|
||||
if (!panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize);
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
},
|
||||
isItemCollapsed: (domNode) => {
|
||||
return domNode.classList.contains("mx_LeftPanel_minimized");
|
||||
},
|
||||
handler: this.resizeHandler.current ?? undefined,
|
||||
};
|
||||
const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig);
|
||||
resizer.setClassNames({
|
||||
handle: "mx_ResizeHandle",
|
||||
vertical: "mx_ResizeHandle--vertical",
|
||||
reverse: "mx_ResizeHandle_reverse",
|
||||
});
|
||||
return resizer;
|
||||
}
|
||||
|
||||
private loadResizerPreferences(): void {
|
||||
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size")!, 10);
|
||||
if (isNaN(lhsSize)) {
|
||||
lhsSize = 350;
|
||||
}
|
||||
this.resizer?.forHandleWithId("lp-resizer")?.resize(lhsSize);
|
||||
}
|
||||
|
||||
private onAccountData = (event: MatrixEvent): void => {
|
||||
if (event.getType() === "m.ignored_user_list") {
|
||||
dis.dispatch({ action: "ignore_state_changed" });
|
||||
}
|
||||
monitorSyncedPushRules(event, this._matrixClient);
|
||||
};
|
||||
|
||||
private onCompactLayoutChanged = (): void => {
|
||||
this.setState({
|
||||
useCompactLayout: SettingsStore.getValue("useCompactLayout"),
|
||||
});
|
||||
};
|
||||
|
||||
private onSync = (syncState: SyncState | null, oldSyncState: SyncState | null, data?: SyncStateData): void => {
|
||||
const oldErrCode = (this.state.syncErrorData?.error as MatrixError)?.errcode;
|
||||
const newErrCode = (data?.error as MatrixError)?.errcode;
|
||||
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
|
||||
|
||||
this.setState({
|
||||
syncErrorData: syncState === SyncState.Error ? data : undefined,
|
||||
});
|
||||
|
||||
if (oldSyncState === SyncState.Prepared && syncState === SyncState.Syncing) {
|
||||
this.updateServerNoticeEvents();
|
||||
} else {
|
||||
this.calculateServerLimitToast(this.state.syncErrorData, this.state.usageLimitEventContent);
|
||||
}
|
||||
};
|
||||
|
||||
private onRoomStateEvents = (ev: MatrixEvent): void => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (serverNoticeList?.some((r) => r.roomId === ev.getRoomId())) {
|
||||
this.updateServerNoticeEvents();
|
||||
}
|
||||
};
|
||||
|
||||
private onUsageLimitDismissed = (): void => {
|
||||
this.setState({
|
||||
usageLimitDismissed: true,
|
||||
});
|
||||
};
|
||||
|
||||
private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit): void {
|
||||
const error = (syncError?.error as MatrixError)?.errcode === "M_RESOURCE_LIMIT_EXCEEDED";
|
||||
if (error) {
|
||||
usageLimitEventContent = (syncError?.error as MatrixError).data as IUsageLimit;
|
||||
}
|
||||
|
||||
// usageLimitDismissed is true when the user has explicitly hidden the toast
|
||||
// and it will be reset to false if a *new* usage alert comes in.
|
||||
if (usageLimitEventContent && this.state.usageLimitDismissed) {
|
||||
showServerLimitToast(
|
||||
usageLimitEventContent.limit_type,
|
||||
this.onUsageLimitDismissed,
|
||||
usageLimitEventContent.admin_contact,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
hideServerLimitToast();
|
||||
}
|
||||
}
|
||||
|
||||
private updateServerNoticeEvents = async (): Promise<void> => {
|
||||
const serverNoticeList = RoomListStore.instance.orderedLists[DefaultTagID.ServerNotice];
|
||||
if (!serverNoticeList) return;
|
||||
|
||||
const events: MatrixEvent[] = [];
|
||||
let pinnedEventTs = 0;
|
||||
for (const room of serverNoticeList) {
|
||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||
|
||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||
pinnedEventTs = pinStateEvent.getTs();
|
||||
|
||||
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);
|
||||
if (event) events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (pinnedEventTs && this.state.usageLimitEventTs && this.state.usageLimitEventTs > pinnedEventTs) {
|
||||
// We've processed a newer event than this one, so ignore it.
|
||||
return;
|
||||
}
|
||||
|
||||
const usageLimitEvent = events.find((e) => {
|
||||
return (
|
||||
e &&
|
||||
e.getType() === "m.room.message" &&
|
||||
e.getContent()["server_notice_type"] === "m.server_notice.usage_limit_reached"
|
||||
);
|
||||
});
|
||||
const usageLimitEventContent = usageLimitEvent?.getContent<IUsageLimit>();
|
||||
this.calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent);
|
||||
this.setState({
|
||||
usageLimitEventContent,
|
||||
usageLimitEventTs: pinnedEventTs,
|
||||
// This is a fresh toast, we can show toasts again
|
||||
usageLimitDismissed: false,
|
||||
});
|
||||
};
|
||||
|
||||
private onPaste = (ev: ClipboardEvent): void => {
|
||||
const element = ev.target as HTMLElement;
|
||||
const inputableElement = getInputableElement(element);
|
||||
if (inputableElement === document.activeElement) return; // nothing to do
|
||||
|
||||
if (inputableElement?.focus) {
|
||||
inputableElement.focus();
|
||||
} else {
|
||||
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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
SOME HACKERY BELOW:
|
||||
React optimizes event handlers, by always attaching only 1 handler to the document for a given type.
|
||||
It then internally determines the order in which React event handlers should be called,
|
||||
emulating the capture and bubbling phases the DOM also has.
|
||||
|
||||
But, as the native handler for React is always attached on the document,
|
||||
it will always run last for bubbling (first for capturing) handlers,
|
||||
and thus React basically has its own event phases, and will always run
|
||||
after (before for capturing) any native other event handlers (as they tend to be attached last).
|
||||
|
||||
So ideally one wouldn't mix React and native event handlers to have bubbling working as expected,
|
||||
but we do need a native event handler here on the document,
|
||||
to get keydown events when there is no focused element (target=body).
|
||||
|
||||
We also do need bubbling here to give child components a chance to call `stopPropagation()`,
|
||||
for keydown events it can handle itself, and shouldn't be redirected to the composer.
|
||||
|
||||
So we listen with React on this component to get any events on focused elements, and get bubbling working as expected.
|
||||
We also listen with a native listener on the document to get keydown events when no element is focused.
|
||||
Bubbling is irrelevant here as the target is the body element.
|
||||
*/
|
||||
private onReactKeyDown = (ev: React.KeyboardEvent): void => {
|
||||
// events caught while bubbling up on the root element
|
||||
// of this component, so something must be focused.
|
||||
this.onKeyDown(ev);
|
||||
};
|
||||
|
||||
private onNativeKeyDown = (ev: KeyboardEvent): void => {
|
||||
// only pass this if there is no focused element.
|
||||
// if there is, onKeyDown will be called by the
|
||||
// react keydown handler that respects the react bubbling order.
|
||||
if (ev.target === document.body) {
|
||||
this.onKeyDown(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent | KeyboardEvent): void => {
|
||||
let handled = false;
|
||||
|
||||
const roomAction = getKeyBindingsManager().getRoomAction(ev);
|
||||
switch (roomAction) {
|
||||
case KeyBindingAction.ScrollUp:
|
||||
case KeyBindingAction.ScrollDown:
|
||||
case KeyBindingAction.JumpToFirstMessage:
|
||||
case KeyBindingAction.JumpToLatestMessage:
|
||||
// pass the event down to the scroll panel
|
||||
this.onScrollKeyPressed(ev);
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.SearchInRoom:
|
||||
dis.fire(Action.FocusMessageSearch);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const navAction = getKeyBindingsManager().getNavigationAction(ev);
|
||||
switch (navAction) {
|
||||
case KeyBindingAction.NextLandmark:
|
||||
case KeyBindingAction.PreviousLandmark:
|
||||
LandmarkNavigation.findAndFocusNextLandmark(
|
||||
Landmark.MESSAGE_COMPOSER_OR_HOME,
|
||||
navAction === KeyBindingAction.PreviousLandmark,
|
||||
);
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.FilterRooms:
|
||||
dis.dispatch({
|
||||
action: "focus_room_filter",
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.ToggleUserMenu:
|
||||
dis.fire(Action.ToggleUserMenu);
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.ShowKeyboardSettings:
|
||||
dis.dispatch<OpenToTabPayload>({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Keyboard,
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.GoToHome:
|
||||
// even if we cancel because there are modals open, we still
|
||||
// handled it: nothing else should happen.
|
||||
handled = true;
|
||||
if (Modal.hasDialogs()) {
|
||||
return;
|
||||
}
|
||||
dis.dispatch({
|
||||
action: Action.ViewHomePage,
|
||||
});
|
||||
break;
|
||||
case KeyBindingAction.ToggleSpacePanel:
|
||||
dis.fire(Action.ToggleSpacePanel);
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.ToggleRoomSidePanel:
|
||||
if (this.props.page_type === "room_view") {
|
||||
RightPanelStore.instance.togglePanel(null);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
case KeyBindingAction.SelectPrevRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: false,
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.SelectNextRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: false,
|
||||
});
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.SelectPrevUnreadRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: true,
|
||||
});
|
||||
break;
|
||||
case KeyBindingAction.SelectNextUnreadRoom:
|
||||
dis.dispatch<ViewRoomDeltaPayload>({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: true,
|
||||
});
|
||||
break;
|
||||
case KeyBindingAction.PreviousVisitedRoomOrSpace:
|
||||
PlatformPeg.get()?.navigateForwardBack(true);
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.NextVisitedRoomOrSpace:
|
||||
PlatformPeg.get()?.navigateForwardBack(false);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle labs actions here, as they apply within the same scope
|
||||
if (!handled) {
|
||||
const labsAction = getKeyBindingsManager().getLabsAction(ev);
|
||||
switch (labsAction) {
|
||||
case KeyBindingAction.ToggleHiddenEventVisibility: {
|
||||
const hiddenEventVisibility = SettingsStore.getValueAt(
|
||||
SettingLevel.DEVICE,
|
||||
"showHiddenEventsInTimeline",
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
SettingsStore.setValue(
|
||||
"showHiddenEventsInTimeline",
|
||||
null,
|
||||
SettingLevel.DEVICE,
|
||||
!hiddenEventVisibility,
|
||||
);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!handled &&
|
||||
PlatformPeg.get()?.overrideBrowserShortcuts() &&
|
||||
ev.code.startsWith("Digit") &&
|
||||
ev.code !== "Digit0" && // this is the shortcut for reset zoom, don't override it
|
||||
isOnlyCtrlOrCmdKeyEvent(ev)
|
||||
) {
|
||||
dis.dispatch<SwitchSpacePayload>({
|
||||
action: Action.SwitchSpace,
|
||||
num: parseInt(ev.code.slice(5), 10), // Cut off the first 5 characters - "Digit"
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
|
||||
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);
|
||||
|
||||
// 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
|
||||
// move focus to the composer so the user can re-type the dead key correctly.
|
||||
const isPrintable = ev.key.length === 1 || ev.key === "Dead";
|
||||
|
||||
// If the user is entering a printable character outside of an input field
|
||||
// redirect it to the composer for them.
|
||||
if (!isClickShortcut && isPrintable && !getInputableElement(ev.target as HTMLElement)) {
|
||||
const inThread = !!document.activeElement?.closest(".mx_ThreadView");
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* dispatch a page-up/page-down/etc to the appropriate component
|
||||
* @param {Object} ev The key event
|
||||
*/
|
||||
private onScrollKeyPressed = (ev: React.KeyboardEvent | KeyboardEvent): void => {
|
||||
this._roomView.current?.handleScrollKey(ev);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let pageElement;
|
||||
|
||||
switch (this.props.page_type) {
|
||||
case PageTypes.RoomView:
|
||||
pageElement = (
|
||||
<RoomView
|
||||
ref={this._roomView}
|
||||
onRegistered={this.props.onRegistered}
|
||||
threepidInvite={this.props.threepidInvite}
|
||||
oobData={this.props.roomOobData}
|
||||
key={this.props.currentRoomId || "roomview"}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
justCreatedOpts={this.props.roomJustCreatedOpts}
|
||||
forceTimeline={this.props.forceTimeline}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case PageTypes.HomePage:
|
||||
pageElement = <UserOnboardingPage justRegistered={this.props.justRegistered} />;
|
||||
break;
|
||||
|
||||
case PageTypes.UserView:
|
||||
if (!!this.props.currentUserId) {
|
||||
pageElement = (
|
||||
<UserView userId={this.props.currentUserId} resizeNotifier={this.props.resizeNotifier} />
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const wrapperClasses = classNames({
|
||||
mx_MatrixChat_wrapper: true,
|
||||
mx_MatrixChat_useCompactLayout: this.state.useCompactLayout,
|
||||
});
|
||||
const bodyClasses = classNames({
|
||||
"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 (
|
||||
<MatrixClientContextProvider client={this._matrixClient}>
|
||||
<div
|
||||
onPaste={this.onPaste}
|
||||
onKeyDown={this.onReactKeyDown}
|
||||
className={wrapperClasses}
|
||||
aria-hidden={this.props.hideToSRUsers}
|
||||
>
|
||||
<ToastContainer />
|
||||
<div className={bodyClasses}>
|
||||
<div className="mx_LeftPanel_outerWrapper">
|
||||
<LeftPanelLiveShareWarning isMinimized={this.props.collapseLhs || false} />
|
||||
<div className="mx_LeftPanel_wrapper">
|
||||
<BackdropPanel blurMultiplier={0.5} backgroundImage={this.state.backgroundImage} />
|
||||
<SpacePanel />
|
||||
<BackdropPanel backgroundImage={this.state.backgroundImage} />
|
||||
<div
|
||||
className="mx_LeftPanel_wrapper--user"
|
||||
ref={this._resizeContainer}
|
||||
data-collapsed={this.props.collapseLhs ? true : undefined}
|
||||
>
|
||||
<LeftPanel
|
||||
pageType={this.props.page_type as PageTypes}
|
||||
isMinimized={this.props.collapseLhs || false}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ResizeHandle passRef={this.resizeHandler} id="lp-resizer" />
|
||||
<div className="mx_RoomView_wrapper">{pageElement}</div>
|
||||
</div>
|
||||
</div>
|
||||
<PipContainer />
|
||||
<NonUrgentToastContainer />
|
||||
{audioFeedArraysForCalls}
|
||||
</MatrixClientContextProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LoggedInView;
|
132
src/components/structures/MainSplit.tsx
Normal file
132
src/components/structures/MainSplit.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { NumberSize, Resizable } from "re-resizable";
|
||||
import { Direction } from "re-resizable/lib/resizer";
|
||||
import { WebPanelResize } from "@matrix-org/analytics-events/types/typescript/WebPanelResize";
|
||||
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import { PosthogAnalytics } from "../../PosthogAnalytics.ts";
|
||||
|
||||
interface IProps {
|
||||
resizeNotifier: ResizeNotifier;
|
||||
collapsedRhs?: boolean;
|
||||
panel?: JSX.Element;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* A unique identifier for this panel split.
|
||||
*
|
||||
* This is appended to the key used to store the panel size in localStorage, allowing the widths of different
|
||||
* panels to be stored.
|
||||
*/
|
||||
sizeKey?: string;
|
||||
/**
|
||||
* The size to use for the panel component if one isn't persisted in storage. Defaults to 320.
|
||||
*/
|
||||
defaultSize: number;
|
||||
|
||||
analyticsRoomType: WebPanelResize["roomType"];
|
||||
}
|
||||
|
||||
export default class MainSplit extends React.Component<IProps> {
|
||||
public static defaultProps = {
|
||||
defaultSize: 320,
|
||||
};
|
||||
|
||||
private onResizeStart = (): void => {
|
||||
this.props.resizeNotifier.startResizing();
|
||||
};
|
||||
|
||||
private onResize = (): void => {
|
||||
this.props.resizeNotifier.notifyRightHandleResized();
|
||||
};
|
||||
|
||||
private get sizeSettingStorageKey(): string {
|
||||
let key = "mx_rhs_size";
|
||||
if (!!this.props.sizeKey) {
|
||||
key += `_${this.props.sizeKey}`;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private onResizeStop = (
|
||||
event: MouseEvent | TouchEvent,
|
||||
direction: Direction,
|
||||
elementRef: HTMLElement,
|
||||
delta: NumberSize,
|
||||
): void => {
|
||||
const newSize = this.loadSidePanelSize().width + delta.width;
|
||||
this.props.resizeNotifier.stopResizing();
|
||||
window.localStorage.setItem(this.sizeSettingStorageKey, newSize.toString());
|
||||
|
||||
PosthogAnalytics.instance.trackEvent<WebPanelResize>({
|
||||
eventName: "WebPanelResize",
|
||||
panel: "right",
|
||||
roomType: this.props.analyticsRoomType,
|
||||
size: newSize,
|
||||
});
|
||||
};
|
||||
|
||||
private loadSidePanelSize(): { height: string | number; width: number } {
|
||||
let rhsSize = parseInt(window.localStorage.getItem(this.sizeSettingStorageKey)!, 10);
|
||||
|
||||
if (isNaN(rhsSize)) {
|
||||
rhsSize = this.props.defaultSize;
|
||||
}
|
||||
|
||||
return {
|
||||
height: "100%",
|
||||
width: rhsSize,
|
||||
};
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const bodyView = React.Children.only(this.props.children);
|
||||
const panelView = this.props.panel;
|
||||
|
||||
const hasResizer = !this.props.collapsedRhs && panelView;
|
||||
|
||||
let children;
|
||||
if (hasResizer) {
|
||||
children = (
|
||||
<Resizable
|
||||
key={this.props.sizeKey}
|
||||
defaultSize={this.loadSidePanelSize()}
|
||||
minWidth={264}
|
||||
maxWidth="50%"
|
||||
enable={{
|
||||
top: false,
|
||||
right: false,
|
||||
bottom: false,
|
||||
left: true,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
onResizeStart={this.onResizeStart}
|
||||
onResize={this.onResize}
|
||||
onResizeStop={this.onResizeStop}
|
||||
className="mx_RightPanel_ResizeWrapper"
|
||||
handleClasses={{ left: "mx_ResizeHandle--horizontal" }}
|
||||
>
|
||||
{panelView}
|
||||
</Resizable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_MainSplit">
|
||||
{bodyView}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
2191
src/components/structures/MatrixChat.tsx
Normal file
2191
src/components/structures/MatrixChat.tsx
Normal file
File diff suppressed because it is too large
Load diff
96
src/components/structures/MatrixClientContextProvider.tsx
Normal file
96
src/components/structures/MatrixClientContextProvider.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren, useEffect, useState } from "react";
|
||||
import { CryptoEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter } from "../../hooks/useEventEmitter";
|
||||
import { LocalDeviceVerificationStateContext } from "../../contexts/LocalDeviceVerificationStateContext";
|
||||
|
||||
/**
|
||||
* A React hook whose value is whether the local device has been "verified".
|
||||
*
|
||||
* Figuring out if we are verified is an async operation, so on the first render this always returns `false`, but
|
||||
* fires off a background job to update a state variable. It also registers an event listener to update the state
|
||||
* variable changes.
|
||||
*
|
||||
* @param client - Matrix client.
|
||||
* @returns A boolean which is `true` if the local device has been verified.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Some notes on implementation.
|
||||
*
|
||||
* It turns out "is this device verified?" isn't a question that is easy to answer as you might think.
|
||||
*
|
||||
* Roughly speaking, it normally means "do we believe this device actually belongs to the person it claims to belong
|
||||
* to", and that data is available via `getDeviceVerificationStatus().isVerified()`. However, the problem is that for
|
||||
* the local device, that "do we believe..." question is trivially true, and `isVerified()` always returns true.
|
||||
*
|
||||
* Instead, when we're talking about the local device, what we really mean is one of:
|
||||
* * "have we completed a verification dance (either interactive verification with a device with access to the
|
||||
* cross-signing secrets, or typing in the 4S key)?", or
|
||||
* * "will other devices consider this one to be verified?"
|
||||
*
|
||||
* (The first is generally required but not sufficient for the second to be true.)
|
||||
*
|
||||
* The second question basically amounts to "has this device been signed by our cross-signing key". So one option here
|
||||
* is to use `getDeviceVerificationStatus().isCrossSigningVerified()`. That might work, but it's a bit annoying because
|
||||
* it needs a `/keys/query` request to complete after the actual verification process completes.
|
||||
*
|
||||
* A slightly less rigorous check is just to find out if we have validated our own public cross-signing keys. If we
|
||||
* have, it's a good indication that we've at least completed a verification dance -- and hopefully, during that dance,
|
||||
* a cross-signature of our own device was published. And it's also easy to monitor via `UserTrustStatusChanged` events.
|
||||
*
|
||||
* Sooo: TL;DR: `getUserVerificationStatus()` is a good proxy for "is the local device verified?".
|
||||
*/
|
||||
function useLocalVerificationState(client: MatrixClient): boolean {
|
||||
const [value, setValue] = useState(false);
|
||||
|
||||
// On the first render, initialise the state variable
|
||||
useEffect(() => {
|
||||
const userId = client.getUserId();
|
||||
if (!userId) return;
|
||||
const crypto = client.getCrypto();
|
||||
crypto?.getUserVerificationStatus(userId).then(
|
||||
(verificationStatus) => setValue(verificationStatus.isCrossSigningVerified()),
|
||||
(error) => logger.error("Error fetching verification status", error),
|
||||
);
|
||||
}, [client]);
|
||||
|
||||
// Update the value whenever our own trust status changes.
|
||||
useEventEmitter(client, CryptoEvent.UserTrustStatusChanged, (userId, verificationStatus) => {
|
||||
if (userId === client.getUserId()) {
|
||||
setValue(verificationStatus.isCrossSigningVerified());
|
||||
}
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Matrix client, which is exposed to all child components via {@link MatrixClientContext}. */
|
||||
client: MatrixClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React component which exposes a {@link MatrixClientContext} and a {@link LocalDeviceVerificationStateContext}
|
||||
* to its children.
|
||||
*/
|
||||
export function MatrixClientContextProvider(props: PropsWithChildren<Props>): React.JSX.Element {
|
||||
const verificationState = useLocalVerificationState(props.client);
|
||||
return (
|
||||
<MatrixClientContext.Provider value={props.client}>
|
||||
<LocalDeviceVerificationStateContext.Provider value={verificationState}>
|
||||
{props.children}
|
||||
</LocalDeviceVerificationStateContext.Provider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
1107
src/components/structures/MessagePanel.tsx
Normal file
1107
src/components/structures/MessagePanel.tsx
Normal file
File diff suppressed because it is too large
Load diff
55
src/components/structures/NonUrgentToastContainer.tsx
Normal file
55
src/components/structures/NonUrgentToastContainer.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ComponentClass } from "../../@types/common";
|
||||
import NonUrgentToastStore from "../../stores/NonUrgentToastStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
interface IState {
|
||||
toasts: ComponentClass[];
|
||||
}
|
||||
|
||||
export default class NonUrgentToastContainer extends React.PureComponent<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
toasts: NonUrgentToastStore.instance.components,
|
||||
};
|
||||
|
||||
NonUrgentToastStore.instance.on(UPDATE_EVENT, this.onUpdateToasts);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
NonUrgentToastStore.instance.off(UPDATE_EVENT, this.onUpdateToasts);
|
||||
}
|
||||
|
||||
private onUpdateToasts = (): void => {
|
||||
this.setState({ toasts: NonUrgentToastStore.instance.components });
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const toasts = this.state.toasts.map((t, i) => {
|
||||
return (
|
||||
<div className="mx_NonUrgentToastContainer_toast" key={`toast-${i}`}>
|
||||
{React.createElement(t, {})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_NonUrgentToastContainer" role="alert">
|
||||
{toasts}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
105
src/components/structures/NotificationPanel.tsx
Normal file
105
src/components/structures/NotificationPanel.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2016-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications";
|
||||
|
||||
import { _t } from "../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
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 EmptyState from "../views/right_panel/EmptyState";
|
||||
|
||||
interface IProps {
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
narrow: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* Component which shows the global notification list using a TimelinePanel
|
||||
*/
|
||||
export default class NotificationPanel extends React.PureComponent<IProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
public declare context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private card = React.createRef<HTMLDivElement>();
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
narrow: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onMeasurement = (narrow: boolean): void => {
|
||||
this.setState({ narrow });
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const emptyState = (
|
||||
<EmptyState
|
||||
Icon={NotificationsIcon}
|
||||
title={_t("notif_panel|empty_heading")}
|
||||
description={_t("notif_panel|empty_description")}
|
||||
/>
|
||||
);
|
||||
|
||||
let content: JSX.Element;
|
||||
const timelineSet = MatrixClientPeg.safeGet().getNotifTimelineSet();
|
||||
if (timelineSet) {
|
||||
// wrap a TimelinePanel with the jump-to-event bits turned off.
|
||||
content = (
|
||||
<TimelinePanel
|
||||
manageReadReceipts={false}
|
||||
manageReadMarkers={false}
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview={false}
|
||||
empty={emptyState}
|
||||
alwaysShowTimestamps={true}
|
||||
layout={Layout.Group}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
logger.error("No notifTimelineSet available!");
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
...this.context,
|
||||
timelineRenderingType: TimelineRenderingType.Notification,
|
||||
narrow: this.state.narrow,
|
||||
}}
|
||||
>
|
||||
<BaseCard
|
||||
header={_t("notifications|enable_prompt_toast_title")}
|
||||
/**
|
||||
* Need to rename this CSS class to something more generic
|
||||
* Will be done once all the panels are using a similar layout
|
||||
*/
|
||||
className="mx_ThreadPanel"
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
>
|
||||
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
|
||||
{content}
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
263
src/components/structures/PictureInPictureDragger.tsx
Normal file
263
src/components/structures/PictureInPictureDragger.tsx
Normal file
|
@ -0,0 +1,263 @@
|
|||
/*
|
||||
Copyright 2021-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
|
||||
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
||||
import { lerp } from "../../utils/AnimationUtils";
|
||||
import { MarkedExecution } from "../../utils/MarkedExecution";
|
||||
|
||||
const PIP_VIEW_WIDTH = 336;
|
||||
const PIP_VIEW_HEIGHT = 232;
|
||||
|
||||
const MOVING_AMT = 0.2;
|
||||
const SNAPPING_AMT = 0.1;
|
||||
|
||||
const PADDING = {
|
||||
top: 58,
|
||||
bottom: 58,
|
||||
left: 76,
|
||||
right: 8,
|
||||
};
|
||||
|
||||
/**
|
||||
* The type of a callback which will create the pip content children.
|
||||
*/
|
||||
export type CreatePipChildren = (options: IChildrenOptions) => JSX.Element;
|
||||
|
||||
interface IChildrenOptions {
|
||||
// a callback which is called when a mouse event (most likely mouse down) occurs at start of moving the pip around
|
||||
onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
// a callback which is called when the content fo the pip changes in a way that is likely to cause a resize
|
||||
onResize: (event: Event) => void;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
children: Array<CreatePipChildren>;
|
||||
draggable: boolean;
|
||||
onDoubleClick?: () => void;
|
||||
onMove?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture'
|
||||
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
|
||||
*/
|
||||
export default class PictureInPictureDragger extends React.Component<IProps> {
|
||||
private callViewWrapper = createRef<HTMLDivElement>();
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
|
||||
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
|
||||
private translationX = this.desiredTranslationX;
|
||||
private translationY = this.desiredTranslationY;
|
||||
private mouseHeld = false;
|
||||
private scheduledUpdate: MarkedExecution = new MarkedExecution(
|
||||
() => this.animationCallback(),
|
||||
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
|
||||
);
|
||||
private startingPositionX = 0;
|
||||
private startingPositionY = 0;
|
||||
|
||||
private _moving = false;
|
||||
public get moving(): boolean {
|
||||
return this._moving;
|
||||
}
|
||||
private set moving(value: boolean) {
|
||||
this._moving = value;
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
document.addEventListener("mousemove", this.onMoving);
|
||||
document.addEventListener("mouseup", this.onEndMoving);
|
||||
UIStore.instance.on(UI_EVENTS.Resize, this.onResize);
|
||||
// correctly position the PiP
|
||||
this.snap();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
document.removeEventListener("mousemove", this.onMoving);
|
||||
document.removeEventListener("mouseup", this.onEndMoving);
|
||||
UIStore.instance.off(UI_EVENTS.Resize, this.onResize);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||
if (prevProps.children !== this.props.children) this.snap(true);
|
||||
}
|
||||
|
||||
private animationCallback = (): void => {
|
||||
if (
|
||||
!this.moving &&
|
||||
Math.abs(this.translationX - this.desiredTranslationX) <= 1 &&
|
||||
Math.abs(this.translationY - this.desiredTranslationY) <= 1
|
||||
) {
|
||||
// Break the loop by settling the element into its final position
|
||||
this.translationX = this.desiredTranslationX;
|
||||
this.translationY = this.desiredTranslationY;
|
||||
this.setStyle();
|
||||
} else {
|
||||
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
|
||||
this.translationX = lerp(this.translationX, this.desiredTranslationX, amt);
|
||||
this.translationY = lerp(this.translationY, this.desiredTranslationY, amt);
|
||||
|
||||
this.setStyle();
|
||||
this.scheduledUpdate.mark();
|
||||
}
|
||||
|
||||
this.props.onMove?.();
|
||||
};
|
||||
|
||||
private setStyle = (): void => {
|
||||
if (!this.callViewWrapper.current) return;
|
||||
// Set the element's style directly, bypassing React for efficiency
|
||||
this.callViewWrapper.current.style.transform = `translateX(${this.translationX}px) translateY(${this.translationY}px)`;
|
||||
};
|
||||
|
||||
private setTranslation(inTranslationX: number, inTranslationY: number): void {
|
||||
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
|
||||
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
|
||||
|
||||
// Avoid overflow on the x axis
|
||||
if (inTranslationX + width >= UIStore.instance.windowWidth) {
|
||||
this.desiredTranslationX = UIStore.instance.windowWidth - width;
|
||||
} else if (inTranslationX <= 0) {
|
||||
this.desiredTranslationX = 0;
|
||||
} else {
|
||||
this.desiredTranslationX = inTranslationX;
|
||||
}
|
||||
|
||||
// Avoid overflow on the y axis
|
||||
if (inTranslationY + height >= UIStore.instance.windowHeight) {
|
||||
this.desiredTranslationY = UIStore.instance.windowHeight - height;
|
||||
} else if (inTranslationY <= 0) {
|
||||
this.desiredTranslationY = 0;
|
||||
} else {
|
||||
this.desiredTranslationY = inTranslationY;
|
||||
}
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
this.snap(false);
|
||||
};
|
||||
|
||||
private snap = (animate = false): void => {
|
||||
const translationX = this.desiredTranslationX;
|
||||
const translationY = this.desiredTranslationY;
|
||||
// We subtract the PiP size from the window size in order to calculate
|
||||
// the position to snap to from the PiP center and not its top-left
|
||||
// corner
|
||||
const windowWidth =
|
||||
UIStore.instance.windowWidth - (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH);
|
||||
const windowHeight =
|
||||
UIStore.instance.windowHeight - (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT);
|
||||
|
||||
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
|
||||
this.desiredTranslationX = windowWidth - PADDING.right;
|
||||
this.desiredTranslationY = windowHeight - PADDING.bottom;
|
||||
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
|
||||
this.desiredTranslationX = windowWidth - PADDING.right;
|
||||
this.desiredTranslationY = PADDING.top;
|
||||
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
|
||||
this.desiredTranslationX = PADDING.left;
|
||||
this.desiredTranslationY = windowHeight - PADDING.bottom;
|
||||
} else {
|
||||
this.desiredTranslationX = PADDING.left;
|
||||
this.desiredTranslationY = PADDING.top;
|
||||
}
|
||||
|
||||
if (!animate) {
|
||||
this.translationX = this.desiredTranslationX;
|
||||
this.translationY = this.desiredTranslationY;
|
||||
}
|
||||
|
||||
// We start animating here because we want the PiP to move when we're
|
||||
// resizing the window
|
||||
this.scheduledUpdate.mark();
|
||||
};
|
||||
|
||||
private onStartMoving = (event: React.MouseEvent | MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.mouseHeld = true;
|
||||
this.startingPositionX = event.clientX;
|
||||
this.startingPositionY = event.clientY;
|
||||
};
|
||||
|
||||
private onMoving = (event: MouseEvent): void => {
|
||||
if (!this.mouseHeld) return;
|
||||
|
||||
if (
|
||||
Math.abs(this.startingPositionX - event.clientX) < 5 &&
|
||||
Math.abs(this.startingPositionY - event.clientY) < 5
|
||||
) {
|
||||
// User needs to move the widget by at least five pixels.
|
||||
// Improves click detection when using a touchpad or with nervous hands.
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.moving) {
|
||||
this.moving = true;
|
||||
this.initX = event.pageX - this.desiredTranslationX;
|
||||
this.initY = event.pageY - this.desiredTranslationY;
|
||||
this.scheduledUpdate.mark();
|
||||
}
|
||||
|
||||
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
|
||||
};
|
||||
|
||||
private onEndMoving = (event: MouseEvent): void => {
|
||||
if (!this.mouseHeld) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.mouseHeld = false;
|
||||
// Delaying this to the next event loop tick is necessary for click
|
||||
// event cancellation to work
|
||||
setTimeout(() => (this.moving = false));
|
||||
this.snap(true);
|
||||
};
|
||||
|
||||
private onClickCapture = (event: React.MouseEvent): void => {
|
||||
// To prevent mouse up events during dragging from being double-counted
|
||||
// as clicks, we cancel clicks before they ever reach the target
|
||||
if (this.moving) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const style = {
|
||||
transform: `translateX(${this.translationX}px) translateY(${this.translationY}px)`,
|
||||
};
|
||||
|
||||
const children = this.props.children.map((create: CreatePipChildren) => {
|
||||
return create({
|
||||
onStartMoving: this.onStartMoving,
|
||||
onResize: this.onResize,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={this.props.className}
|
||||
style={style}
|
||||
ref={this.callViewWrapper}
|
||||
onClickCapture={this.onClickCapture}
|
||||
onDoubleClick={this.props.onDoubleClick}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
361
src/components/structures/PipContainer.tsx
Normal file
361
src/components/structures/PipContainer.tsx
Normal file
|
@ -0,0 +1,361 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2017-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { MutableRefObject, ReactNode, useContext, useRef } from "react";
|
||||
import { CallEvent, CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import LegacyCallView from "../views/voip/LegacyCallView";
|
||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import PictureInPictureDragger, { CreatePipChildren } from "./PictureInPictureDragger";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWidgetStore";
|
||||
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { SDKContext, SdkContextClass } from "../../contexts/SDKContext";
|
||||
import {
|
||||
useCurrentVoiceBroadcastPreRecording,
|
||||
useCurrentVoiceBroadcastRecording,
|
||||
VoiceBroadcastPlayback,
|
||||
VoiceBroadcastPlaybackBody,
|
||||
VoiceBroadcastPreRecording,
|
||||
VoiceBroadcastPreRecordingPip,
|
||||
VoiceBroadcastRecording,
|
||||
VoiceBroadcastRecordingPip,
|
||||
VoiceBroadcastSmallPlaybackBody,
|
||||
} from "../../voice-broadcast";
|
||||
import { useCurrentVoiceBroadcastPlayback } from "../../voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback";
|
||||
import { WidgetPip } from "../views/pips/WidgetPip";
|
||||
|
||||
const SHOW_CALL_IN_STATES = [
|
||||
CallState.Connected,
|
||||
CallState.InviteSent,
|
||||
CallState.Connecting,
|
||||
CallState.CreateAnswer,
|
||||
CallState.CreateOffer,
|
||||
CallState.WaitLocalMedia,
|
||||
];
|
||||
|
||||
interface IProps {
|
||||
voiceBroadcastRecording: Optional<VoiceBroadcastRecording>;
|
||||
voiceBroadcastPreRecording: Optional<VoiceBroadcastPreRecording>;
|
||||
voiceBroadcastPlayback: Optional<VoiceBroadcastPlayback>;
|
||||
movePersistedElement: MutableRefObject<(() => void) | undefined>;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
viewedRoomId?: string;
|
||||
|
||||
// The main call that we are displaying (ie. not including the call in the room being viewed, if any)
|
||||
primaryCall: MatrixCall | null;
|
||||
|
||||
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
|
||||
// they belong to
|
||||
secondaryCall: MatrixCall;
|
||||
|
||||
// widget candidate to be displayed in the pip view.
|
||||
persistentWidgetId: string | null;
|
||||
persistentRoomId: string | null;
|
||||
showWidgetInPip: boolean;
|
||||
}
|
||||
|
||||
// Splits a list of calls into one 'primary' one and a list
|
||||
// (which should be a single element) of other calls.
|
||||
// The primary will be the one not on hold, or an arbitrary one
|
||||
// if they're all on hold)
|
||||
function getPrimarySecondaryCallsForPip(roomId: Optional<string>): [MatrixCall | null, MatrixCall[]] {
|
||||
if (!roomId) return [null, []];
|
||||
|
||||
const calls = LegacyCallHandler.instance.getAllActiveCallsForPip(roomId);
|
||||
|
||||
let primary: MatrixCall | null = null;
|
||||
let secondaries: MatrixCall[] = [];
|
||||
|
||||
for (const call of calls) {
|
||||
if (!SHOW_CALL_IN_STATES.includes(call.state)) continue;
|
||||
|
||||
if (!call.isRemoteOnHold() && primary === null) {
|
||||
primary = call;
|
||||
} else {
|
||||
secondaries.push(call);
|
||||
}
|
||||
}
|
||||
|
||||
if (primary === null && secondaries.length > 0) {
|
||||
primary = secondaries[0];
|
||||
secondaries = secondaries.slice(1);
|
||||
}
|
||||
|
||||
if (secondaries.length > 1) {
|
||||
// We should never be in more than two calls so this shouldn't happen
|
||||
logger.log("Found more than 1 secondary call! Other calls will not be shown.");
|
||||
}
|
||||
|
||||
return [primary, secondaries];
|
||||
}
|
||||
|
||||
/**
|
||||
* PipContainer shows a small version of the LegacyCallView or a sticky widget hovering over the UI in
|
||||
* 'picture-in-picture' (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing
|
||||
* and all widgets that are active but not shown in any other possible container.
|
||||
*/
|
||||
|
||||
class PipContainerInner extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
|
||||
const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId);
|
||||
|
||||
this.state = {
|
||||
viewedRoomId: roomId || undefined,
|
||||
primaryCall: primaryCall || null,
|
||||
secondaryCall: secondaryCalls[0],
|
||||
persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(),
|
||||
persistentRoomId: ActiveWidgetStore.instance.getPersistentRoomId(),
|
||||
showWidgetInPip: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
|
||||
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
|
||||
SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate);
|
||||
MatrixClientPeg.safeGet().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
|
||||
const room = MatrixClientPeg.safeGet().getRoom(this.state.viewedRoomId);
|
||||
if (room) {
|
||||
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
|
||||
}
|
||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence);
|
||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges);
|
||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
|
||||
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
|
||||
const cli = MatrixClientPeg.get();
|
||||
cli?.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
|
||||
SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate);
|
||||
const room = cli?.getRoom(this.state.viewedRoomId);
|
||||
if (room) {
|
||||
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
|
||||
}
|
||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Persistence, this.onWidgetPersistence);
|
||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onWidgetDockChanges);
|
||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onWidgetDockChanges);
|
||||
}
|
||||
|
||||
private onMove = (): void => this.props.movePersistedElement.current?.();
|
||||
|
||||
private onRoomViewStoreUpdate = (): void => {
|
||||
const newRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
const oldRoomId = this.state.viewedRoomId;
|
||||
if (newRoomId === oldRoomId) return;
|
||||
// The WidgetLayoutStore observer always tracks the currently viewed Room,
|
||||
// so we don't end up with multiple observers and know what observer to remove on unmount
|
||||
const oldRoom = MatrixClientPeg.get()?.getRoom(oldRoomId);
|
||||
if (oldRoom) {
|
||||
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(oldRoom), this.updateCalls);
|
||||
}
|
||||
const newRoom = MatrixClientPeg.get()?.getRoom(newRoomId || undefined);
|
||||
if (newRoom) {
|
||||
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(newRoom), this.updateCalls);
|
||||
}
|
||||
if (!newRoomId) return;
|
||||
|
||||
const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(newRoomId);
|
||||
this.setState({
|
||||
viewedRoomId: newRoomId,
|
||||
primaryCall: primaryCall,
|
||||
secondaryCall: secondaryCalls[0],
|
||||
});
|
||||
this.updateShowWidgetInPip();
|
||||
};
|
||||
|
||||
private onWidgetPersistence = (): void => {
|
||||
this.updateShowWidgetInPip();
|
||||
};
|
||||
|
||||
private onWidgetDockChanges = (): void => {
|
||||
this.updateShowWidgetInPip();
|
||||
};
|
||||
|
||||
private updateCalls = (): void => {
|
||||
if (!this.state.viewedRoomId) return;
|
||||
const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.state.viewedRoomId);
|
||||
|
||||
this.setState({
|
||||
primaryCall: primaryCall,
|
||||
secondaryCall: secondaryCalls[0],
|
||||
});
|
||||
this.updateShowWidgetInPip();
|
||||
};
|
||||
|
||||
private onCallRemoteHold = (): void => {
|
||||
if (!this.state.viewedRoomId) return;
|
||||
const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(this.state.viewedRoomId);
|
||||
|
||||
this.setState({
|
||||
primaryCall: primaryCall,
|
||||
secondaryCall: secondaryCalls[0],
|
||||
});
|
||||
};
|
||||
|
||||
private onDoubleClick = (): void => {
|
||||
const callRoomId = this.state.primaryCall?.roomId;
|
||||
if (callRoomId ?? this.state.persistentRoomId) {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: callRoomId ?? this.state.persistentRoomId ?? undefined,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public updateShowWidgetInPip(): void {
|
||||
const persistentWidgetId = ActiveWidgetStore.instance.getPersistentWidgetId();
|
||||
const persistentRoomId = ActiveWidgetStore.instance.getPersistentRoomId();
|
||||
|
||||
let fromAnotherRoom = false;
|
||||
let notDocked = false;
|
||||
// Sanity check the room - the widget may have been destroyed between render cycles, and
|
||||
// thus no room is associated anymore.
|
||||
if (persistentWidgetId && persistentRoomId && MatrixClientPeg.safeGet().getRoom(persistentRoomId)) {
|
||||
notDocked = !ActiveWidgetStore.instance.isDocked(persistentWidgetId, persistentRoomId);
|
||||
fromAnotherRoom = this.state.viewedRoomId !== persistentRoomId;
|
||||
}
|
||||
|
||||
// The widget should only be shown as a persistent app (in a floating
|
||||
// pip container) if it is not visible on screen: either because we are
|
||||
// viewing a different room OR because it is in none of the possible
|
||||
// containers of the room view.
|
||||
const showWidgetInPip = fromAnotherRoom || notDocked;
|
||||
|
||||
this.setState({ showWidgetInPip, persistentWidgetId, persistentRoomId });
|
||||
}
|
||||
|
||||
private createVoiceBroadcastPlaybackPipContent(voiceBroadcastPlayback: VoiceBroadcastPlayback): CreatePipChildren {
|
||||
const content =
|
||||
this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId() ? (
|
||||
<VoiceBroadcastPlaybackBody playback={voiceBroadcastPlayback} pip={true} />
|
||||
) : (
|
||||
<VoiceBroadcastSmallPlaybackBody playback={voiceBroadcastPlayback} />
|
||||
);
|
||||
|
||||
return ({ onStartMoving }) => (
|
||||
<div key={`vb-playback-${voiceBroadcastPlayback.infoEvent.getId()}`} onMouseDown={onStartMoving}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private createVoiceBroadcastPreRecordingPipContent(
|
||||
voiceBroadcastPreRecording: VoiceBroadcastPreRecording,
|
||||
): CreatePipChildren {
|
||||
return ({ onStartMoving }) => (
|
||||
<div key="vb-pre-recording" onMouseDown={onStartMoving}>
|
||||
<VoiceBroadcastPreRecordingPip voiceBroadcastPreRecording={voiceBroadcastPreRecording} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private createVoiceBroadcastRecordingPipContent(
|
||||
voiceBroadcastRecording: VoiceBroadcastRecording,
|
||||
): CreatePipChildren {
|
||||
return ({ onStartMoving }) => (
|
||||
<div key={`vb-recording-${voiceBroadcastRecording.infoEvent.getId()}`} onMouseDown={onStartMoving}>
|
||||
<VoiceBroadcastRecordingPip recording={voiceBroadcastRecording} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
const pipMode = true;
|
||||
let pipContent: Array<CreatePipChildren> = [];
|
||||
|
||||
if (this.props.voiceBroadcastRecording) {
|
||||
pipContent = [this.createVoiceBroadcastRecordingPipContent(this.props.voiceBroadcastRecording)];
|
||||
} else if (this.props.voiceBroadcastPreRecording) {
|
||||
pipContent = [this.createVoiceBroadcastPreRecordingPipContent(this.props.voiceBroadcastPreRecording)];
|
||||
} else if (this.props.voiceBroadcastPlayback) {
|
||||
pipContent = [this.createVoiceBroadcastPlaybackPipContent(this.props.voiceBroadcastPlayback)];
|
||||
}
|
||||
|
||||
if (this.state.primaryCall) {
|
||||
// get a ref to call inside the current scope
|
||||
const call = this.state.primaryCall;
|
||||
pipContent.push(({ onStartMoving, onResize }) => (
|
||||
<LegacyCallView
|
||||
key="call-view"
|
||||
onMouseDownOnHeader={onStartMoving}
|
||||
call={call}
|
||||
secondaryCall={this.state.secondaryCall}
|
||||
pipMode={pipMode}
|
||||
onResize={onResize}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
if (this.state.showWidgetInPip && this.state.persistentWidgetId) {
|
||||
pipContent.push(({ onStartMoving }) => (
|
||||
<WidgetPip
|
||||
key="widget-pip"
|
||||
widgetId={this.state.persistentWidgetId!}
|
||||
room={MatrixClientPeg.safeGet().getRoom(this.state.persistentRoomId ?? undefined)!}
|
||||
viewingRoom={this.state.viewedRoomId === this.state.persistentRoomId}
|
||||
onStartMoving={onStartMoving}
|
||||
movePersistedElement={this.props.movePersistedElement}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
if (pipContent.length) {
|
||||
return (
|
||||
<PictureInPictureDragger
|
||||
className="mx_LegacyCallPreview"
|
||||
draggable={pipMode}
|
||||
onDoubleClick={this.onDoubleClick}
|
||||
onMove={this.onMove}
|
||||
>
|
||||
{pipContent}
|
||||
</PictureInPictureDragger>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const PipContainer: React.FC = () => {
|
||||
const sdkContext = useContext(SDKContext);
|
||||
const voiceBroadcastPreRecordingStore = sdkContext.voiceBroadcastPreRecordingStore;
|
||||
const { currentVoiceBroadcastPreRecording } = useCurrentVoiceBroadcastPreRecording(voiceBroadcastPreRecordingStore);
|
||||
|
||||
const voiceBroadcastRecordingsStore = sdkContext.voiceBroadcastRecordingsStore;
|
||||
const { currentVoiceBroadcastRecording } = useCurrentVoiceBroadcastRecording(voiceBroadcastRecordingsStore);
|
||||
|
||||
const voiceBroadcastPlaybacksStore = sdkContext.voiceBroadcastPlaybacksStore;
|
||||
const { currentVoiceBroadcastPlayback } = useCurrentVoiceBroadcastPlayback(voiceBroadcastPlaybacksStore);
|
||||
|
||||
const movePersistedElement = useRef<() => void>();
|
||||
|
||||
return (
|
||||
<PipContainerInner
|
||||
voiceBroadcastPlayback={currentVoiceBroadcastPlayback}
|
||||
voiceBroadcastPreRecording={currentVoiceBroadcastPreRecording}
|
||||
voiceBroadcastRecording={currentVoiceBroadcastRecording}
|
||||
movePersistedElement={movePersistedElement}
|
||||
/>
|
||||
);
|
||||
};
|
44
src/components/structures/ReleaseAnnouncement.tsx
Normal file
44
src/components/structures/ReleaseAnnouncement.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps, JSX, PropsWithChildren } from "react";
|
||||
import { ReleaseAnnouncement as ReleaseAnnouncementCompound } from "@vector-im/compound-web";
|
||||
|
||||
import { ReleaseAnnouncementStore, Feature } from "../../stores/ReleaseAnnouncementStore";
|
||||
import { useIsReleaseAnnouncementOpen } from "../../hooks/useIsReleaseAnnouncementOpen";
|
||||
|
||||
interface ReleaseAnnouncementProps
|
||||
extends Omit<ComponentProps<typeof ReleaseAnnouncementCompound>, "open" | "onClick"> {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a release announcement component around the children
|
||||
* Wrapper gluing the release announcement compound and the ReleaseAnnouncementStore
|
||||
* @param feature - the feature to announce, should be listed in {@link Feature}
|
||||
* @param children
|
||||
* @param props
|
||||
* @constructor
|
||||
*/
|
||||
export function ReleaseAnnouncement({
|
||||
feature,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<ReleaseAnnouncementProps>): JSX.Element {
|
||||
const enabled = useIsReleaseAnnouncementOpen(feature);
|
||||
|
||||
return (
|
||||
<ReleaseAnnouncementCompound
|
||||
open={enabled}
|
||||
onClick={() => ReleaseAnnouncementStore.instance.nextReleaseAnnouncement()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ReleaseAnnouncementCompound>
|
||||
);
|
||||
}
|
318
src/components/structures/RightPanel.tsx
Normal file
318
src/components/structures/RightPanel.tsx
Normal file
|
@ -0,0 +1,318 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent } from "react";
|
||||
import { Room, RoomState, RoomStateEvent, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { throttle } from "lodash";
|
||||
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
|
||||
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
|
||||
import WidgetCard from "../views/right_panel/WidgetCard";
|
||||
import MemberList from "../views/rooms/MemberList";
|
||||
import UserInfo from "../views/right_panel/UserInfo";
|
||||
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
|
||||
import FilePanel from "./FilePanel";
|
||||
import ThreadView from "./ThreadView";
|
||||
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 { XOR } from "../../@types/common";
|
||||
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
|
||||
|
||||
interface BaseProps {
|
||||
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
|
||||
resizeNotifier: ResizeNotifier;
|
||||
e2eStatus?: E2EStatus;
|
||||
}
|
||||
|
||||
interface RoomlessProps extends BaseProps {
|
||||
room?: undefined;
|
||||
permalinkCreator?: undefined;
|
||||
}
|
||||
|
||||
interface RoomProps extends BaseProps {
|
||||
room: Room;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onSearchChange?: (e: ChangeEvent) => void;
|
||||
onSearchCancel?: () => void;
|
||||
}
|
||||
|
||||
type Props = XOR<RoomlessProps, RoomProps>;
|
||||
|
||||
interface IState {
|
||||
phase?: RightPanelPhases;
|
||||
searchQuery: string;
|
||||
cardState?: IRightPanelCardState;
|
||||
}
|
||||
|
||||
export default class RightPanel extends React.Component<Props, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
public constructor(props: Props, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
searchQuery: "",
|
||||
};
|
||||
}
|
||||
|
||||
private readonly delayedUpdate = throttle(
|
||||
(): void => {
|
||||
this.forceUpdate();
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.context.on(RoomStateEvent.Members, this.onRoomStateMember);
|
||||
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.context?.removeListener(RoomStateEvent.Members, this.onRoomStateMember);
|
||||
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
||||
}
|
||||
|
||||
public static getDerivedStateFromProps(props: Props): Partial<IState> {
|
||||
let currentCard: IRightPanelCard | undefined;
|
||||
if (props.room) {
|
||||
currentCard = RightPanelStore.instance.currentCardForRoom(props.room.roomId);
|
||||
}
|
||||
|
||||
return {
|
||||
cardState: currentCard?.state,
|
||||
phase: currentCard?.phase ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember): void => {
|
||||
if (!this.props.room || member.roomId !== this.props.room.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// redraw the badge on the membership list
|
||||
if (this.state.phase === RightPanelPhases.RoomMemberList) {
|
||||
this.delayedUpdate();
|
||||
} else if (
|
||||
this.state.phase === RightPanelPhases.RoomMemberInfo &&
|
||||
member.userId === this.state.cardState?.member?.userId
|
||||
) {
|
||||
// refresh the member info (e.g. new power level)
|
||||
this.delayedUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
private onRightPanelStoreUpdate = (): void => {
|
||||
this.setState({ ...(RightPanel.getDerivedStateFromProps(this.props) as IState) });
|
||||
};
|
||||
|
||||
private onClose = (): void => {
|
||||
// XXX: There are three different ways of 'closing' this panel depending on what state
|
||||
// things are in... this knows far more than it should do about the state of the rest
|
||||
// of the app and is generally a bit silly.
|
||||
if (this.props.overwriteCard?.state?.member) {
|
||||
// If we have a user prop then we're displaying a user from the 'user' page type
|
||||
// in LoggedInView, so need to change the page type to close the panel (we switch
|
||||
// to the home page which is not obviously the correct thing to do, but I'm not sure
|
||||
// anything else is - we could hide the close button altogether?)
|
||||
dis.dispatch({
|
||||
action: Action.ViewHomePage,
|
||||
});
|
||||
} else if (
|
||||
this.state.phase === RightPanelPhases.EncryptionPanel &&
|
||||
this.state.cardState?.verificationRequest?.pending
|
||||
) {
|
||||
// When the user clicks close on the encryption panel cancel the pending request first if any
|
||||
this.state.cardState.verificationRequest.cancel();
|
||||
} else {
|
||||
RightPanelStore.instance.togglePanel(this.props.room?.roomId ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
private onSearchQueryChanged = (searchQuery: string): void => {
|
||||
this.setState({ searchQuery });
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let card = <div />;
|
||||
const roomId = this.props.room?.roomId;
|
||||
const phase = this.props.overwriteCard?.phase ?? this.state.phase;
|
||||
const cardState = this.props.overwriteCard?.state ?? this.state.cardState;
|
||||
switch (phase) {
|
||||
case RightPanelPhases.RoomMemberList:
|
||||
if (!!roomId) {
|
||||
card = (
|
||||
<MemberList
|
||||
roomId={roomId}
|
||||
key={roomId}
|
||||
onClose={this.onClose}
|
||||
searchQuery={this.state.searchQuery}
|
||||
onSearchQueryChanged={this.onSearchQueryChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case RightPanelPhases.SpaceMemberList:
|
||||
if (!!cardState?.spaceId || !!roomId) {
|
||||
card = (
|
||||
<MemberList
|
||||
roomId={cardState?.spaceId ?? roomId!}
|
||||
key={cardState?.spaceId ?? roomId!}
|
||||
onClose={this.onClose}
|
||||
searchQuery={this.state.searchQuery}
|
||||
onSearchQueryChanged={this.onSearchQueryChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomMemberInfo:
|
||||
case RightPanelPhases.SpaceMemberInfo:
|
||||
case RightPanelPhases.EncryptionPanel: {
|
||||
if (!!cardState?.member) {
|
||||
const roomMember = cardState.member instanceof RoomMember ? cardState.member : undefined;
|
||||
card = (
|
||||
<UserInfo
|
||||
user={cardState.member}
|
||||
room={this.context.getRoom(roomMember?.roomId) ?? this.props.room}
|
||||
key={roomId ?? cardState.member.userId}
|
||||
onClose={this.onClose}
|
||||
phase={phase}
|
||||
verificationRequest={cardState.verificationRequest}
|
||||
verificationRequestPromise={cardState.verificationRequestPromise}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RightPanelPhases.Room3pidMemberInfo:
|
||||
case RightPanelPhases.Space3pidMemberInfo:
|
||||
if (!!cardState?.memberInfoEvent) {
|
||||
card = (
|
||||
<ThirdPartyMemberInfo event={cardState.memberInfoEvent} key={roomId} onClose={this.onClose} />
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.NotificationPanel:
|
||||
card = <NotificationPanel onClose={this.onClose} />;
|
||||
break;
|
||||
|
||||
case RightPanelPhases.PinnedMessages:
|
||||
if (!!this.props.room) {
|
||||
card = (
|
||||
<PinnedMessagesCard
|
||||
room={this.props.room}
|
||||
onClose={this.onClose}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case RightPanelPhases.Timeline:
|
||||
if (!!this.props.room) {
|
||||
card = (
|
||||
<TimelineCard
|
||||
classNames="mx_ThreadPanel mx_TimelineCard"
|
||||
room={this.props.room}
|
||||
timelineSet={this.props.room.getUnfilteredTimelineSet()}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onClose={this.onClose}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
case RightPanelPhases.FilePanel:
|
||||
if (!!roomId) {
|
||||
card = (
|
||||
<FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.ThreadView:
|
||||
if (!!this.props.room && !!cardState?.threadHeadEvent) {
|
||||
card = (
|
||||
<ThreadView
|
||||
room={this.props.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onClose={this.onClose}
|
||||
mxEvent={cardState.threadHeadEvent}
|
||||
initialEvent={cardState.initialEvent}
|
||||
isInitialEventHighlighted={cardState.isInitialEventHighlighted}
|
||||
initialEventScrollIntoView={cardState.initialEventScrollIntoView}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.ThreadPanel:
|
||||
if (!!this.props.room) {
|
||||
card = (
|
||||
<ThreadPanel
|
||||
roomId={this.props.room.roomId}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
onClose={this.onClose}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.RoomSummary:
|
||||
if (!!this.props.room) {
|
||||
card = (
|
||||
<RoomSummaryCard
|
||||
room={this.props.room}
|
||||
// whenever RightPanel is passed a room it is passed a permalinkcreator
|
||||
permalinkCreator={this.props.permalinkCreator!}
|
||||
onSearchChange={this.props.onSearchChange}
|
||||
onSearchCancel={this.props.onSearchCancel}
|
||||
focusRoomSearch={cardState?.focusRoomSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Extensions:
|
||||
if (!!this.props.room) {
|
||||
card = <ExtensionsCard room={this.props.room} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case RightPanelPhases.Widget:
|
||||
if (!!this.props.room && !!cardState?.widgetId) {
|
||||
card = <WidgetCard room={this.props.room} widgetId={cardState.widgetId} onClose={this.onClose} />;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="mx_RightPanel" id="mx_RightPanel">
|
||||
{card}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
74
src/components/structures/RoomSearch.tsx
Normal file
74
src/components/structures/RoomSearch.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import * as React from "react";
|
||||
|
||||
import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { IS_MAC, Key } from "../../Keyboard";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
export default class RoomSearch extends React.PureComponent<IProps> {
|
||||
private readonly dispatcherRef: string;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private openSpotlight(): void {
|
||||
defaultDispatcher.fire(Action.OpenSpotlight);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
if (payload.action === "focus_room_filter") {
|
||||
this.openSpotlight();
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const classes = classNames(
|
||||
{
|
||||
mx_RoomSearch: true,
|
||||
mx_RoomSearch_minimized: this.props.isMinimized,
|
||||
},
|
||||
"mx_RoomSearch_spotlightTrigger",
|
||||
);
|
||||
|
||||
const icon = <div className="mx_RoomSearch_icon" />;
|
||||
|
||||
const shortcutPrompt = (
|
||||
<kbd className="mx_RoomSearch_shortcutPrompt">
|
||||
{IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"}
|
||||
</kbd>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccessibleButton onClick={this.openSpotlight} className={classes} aria-label={_t("action|search")}>
|
||||
{icon}
|
||||
{!this.props.isMinimized && (
|
||||
<div className="mx_RoomSearch_spotlightTriggerText">{_t("action|search")}</div>
|
||||
)}
|
||||
{shortcutPrompt}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
325
src/components/structures/RoomSearchView.tsx
Normal file
325
src/components/structures/RoomSearchView.tsx
Normal file
|
@ -0,0 +1,325 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ISearchResults,
|
||||
IThreadBundledRelationship,
|
||||
MatrixEvent,
|
||||
THREAD_RELATION_TYPE,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { haveRendererForEvent } from "../../events/EventTileFactory";
|
||||
import SearchResultTile from "../views/rooms/SearchResultTile";
|
||||
import { searchPagination, SearchScope } from "../../Searching";
|
||||
import Modal from "../../Modal";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import RoomContext from "../../contexts/RoomContext";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function (msg: string): void {};
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (DEBUG) {
|
||||
// using bind means that we get to keep useful line numbers in the console
|
||||
debuglog = logger.log.bind(console);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
term: string;
|
||||
scope: SearchScope;
|
||||
inProgress: boolean;
|
||||
promise: Promise<ISearchResults>;
|
||||
abortController?: AbortController;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
className: string;
|
||||
onUpdate(inProgress: boolean, results: ISearchResults | null): void;
|
||||
}
|
||||
|
||||
// XXX: todo: merge overlapping results somehow?
|
||||
// XXX: why doesn't searching on name work?
|
||||
export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
||||
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => {
|
||||
const client = useContext(MatrixClientContext);
|
||||
const roomContext = useContext(RoomContext);
|
||||
const [highlights, setHighlights] = useState<string[] | null>(null);
|
||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||
const aborted = useRef(false);
|
||||
// A map from room ID to permalink creator
|
||||
const permalinkCreators = useRef(new Map<string, RoomPermalinkCreator>()).current;
|
||||
const innerRef = useRef<ScrollPanel | null>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
permalinkCreators.forEach((pc) => pc.stop());
|
||||
permalinkCreators.clear();
|
||||
};
|
||||
}, [permalinkCreators]);
|
||||
|
||||
const handleSearchResult = useCallback(
|
||||
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
|
||||
onUpdate(true, null);
|
||||
|
||||
return searchPromise.then(
|
||||
async (results): Promise<boolean> => {
|
||||
debuglog("search complete");
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
|
||||
// postgres on synapse returns us precise details of the strings
|
||||
// which actually got matched for highlighting.
|
||||
//
|
||||
// In either case, we want to highlight the literal search term
|
||||
// whether it was used by the search engine or not.
|
||||
|
||||
let highlights = results.highlights;
|
||||
if (!highlights.includes(term)) {
|
||||
highlights = highlights.concat(term);
|
||||
}
|
||||
|
||||
// For overlapping highlights,
|
||||
// favour longer (more specific) terms first
|
||||
highlights = highlights.sort(function (a, b) {
|
||||
return b.length - a.length;
|
||||
});
|
||||
|
||||
for (const result of results.results) {
|
||||
for (const event of result.context.getTimeline()) {
|
||||
const bundledRelationship =
|
||||
event.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||
THREAD_RELATION_TYPE.name,
|
||||
);
|
||||
if (!bundledRelationship || event.getThread()) continue;
|
||||
const room = client.getRoom(event.getRoomId());
|
||||
const thread = room?.findThreadForEvent(event);
|
||||
if (thread) {
|
||||
event.setThread(thread);
|
||||
} else {
|
||||
room?.createThread(event.getId()!, event, [], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHighlights(highlights);
|
||||
setResults({ ...results }); // copy to force a refresh
|
||||
onUpdate(false, results);
|
||||
return false;
|
||||
},
|
||||
(error) => {
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
logger.error("Search failed", error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("error_dialog|search_failed|title"),
|
||||
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
|
||||
});
|
||||
onUpdate(false, null);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
},
|
||||
[client, term, onUpdate],
|
||||
);
|
||||
|
||||
// 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 === null) {
|
||||
return (
|
||||
<div
|
||||
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
|
||||
data-testid="messagePanelSearchSpinner"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
|
||||
if (!backwards) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!results.next_batch) {
|
||||
debuglog("no more search results");
|
||||
return false;
|
||||
}
|
||||
|
||||
debuglog("requesting more search results");
|
||||
const searchPromise = searchPagination(client, results);
|
||||
return handleSearchResult(searchPromise);
|
||||
};
|
||||
|
||||
const ret: JSX.Element[] = [];
|
||||
|
||||
if (inProgress) {
|
||||
ret.push(
|
||||
<li key="search-spinner">
|
||||
<Spinner />
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
|
||||
if (!results.next_batch) {
|
||||
if (!results?.results?.length) {
|
||||
ret.push(
|
||||
<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2>
|
||||
</li>,
|
||||
);
|
||||
} else {
|
||||
ret.push(
|
||||
<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// once dynamic content in the search results load, make the scrollPanel check
|
||||
// the scroll offsets.
|
||||
const onHeightChanged = (): void => {
|
||||
innerRef.current?.checkScroll();
|
||||
};
|
||||
|
||||
const onRef = (e: ScrollPanel | null): void => {
|
||||
if (typeof ref === "function") {
|
||||
ref(e);
|
||||
} else if (!!ref) {
|
||||
ref.current = e;
|
||||
}
|
||||
innerRef.current = e;
|
||||
};
|
||||
|
||||
let lastRoomId: string | undefined;
|
||||
let mergedTimeline: MatrixEvent[] = [];
|
||||
let ourEventsIndexes: number[] = [];
|
||||
|
||||
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
|
||||
const result = results.results[i];
|
||||
|
||||
const mxEv = result.context.getEvent();
|
||||
const roomId = mxEv.getRoomId()!;
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
|
||||
// As per the spec, an all rooms search can create this condition,
|
||||
// it happens with Seshat but not Synapse.
|
||||
// It will make the result count not match the displayed count.
|
||||
logger.log("Hiding search result from an unknown room", roomId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scope === SearchScope.All) {
|
||||
if (roomId !== lastRoomId) {
|
||||
ret.push(
|
||||
<li key={mxEv.getId() + "-room"}>
|
||||
<h2>
|
||||
{_t("common|room")}: {room.name}
|
||||
</h2>
|
||||
</li>,
|
||||
);
|
||||
lastRoomId = roomId;
|
||||
}
|
||||
}
|
||||
|
||||
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
|
||||
|
||||
// merging two successive search result if the query is present in both of them
|
||||
const currentTimeline = result.context.getTimeline();
|
||||
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
|
||||
|
||||
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
|
||||
// if this is the first searchResult we merge then add all values of the current searchResult
|
||||
if (mergedTimeline.length == 0) {
|
||||
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
|
||||
mergedTimeline.push(currentTimeline[j]);
|
||||
}
|
||||
ourEventsIndexes.push(result.context.getOurEventIndex());
|
||||
}
|
||||
|
||||
// merge the events of the next searchResult
|
||||
for (let j = 1; j < nextTimeline.length; j++) {
|
||||
mergedTimeline.push(nextTimeline[j]);
|
||||
}
|
||||
|
||||
// add the index of the matching event of the next searchResult
|
||||
ourEventsIndexes.push(
|
||||
ourEventsIndexes[ourEventsIndexes.length - 1] +
|
||||
results.results[i - 1].context.getOurEventIndex() +
|
||||
1,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mergedTimeline.length == 0) {
|
||||
mergedTimeline = result.context.getTimeline();
|
||||
ourEventsIndexes = [];
|
||||
ourEventsIndexes.push(result.context.getOurEventIndex());
|
||||
}
|
||||
|
||||
let permalinkCreator = permalinkCreators.get(roomId);
|
||||
if (!permalinkCreator) {
|
||||
permalinkCreator = new RoomPermalinkCreator(room);
|
||||
permalinkCreator.start();
|
||||
permalinkCreators.set(roomId, permalinkCreator);
|
||||
}
|
||||
|
||||
ret.push(
|
||||
<SearchResultTile
|
||||
key={mxEv.getId()}
|
||||
timeline={mergedTimeline}
|
||||
ourEventsIndexes={ourEventsIndexes}
|
||||
searchHighlights={highlights ?? []}
|
||||
resultLink={resultLink}
|
||||
permalinkCreator={permalinkCreator}
|
||||
onHeightChanged={onHeightChanged}
|
||||
/>,
|
||||
);
|
||||
|
||||
ourEventsIndexes = [];
|
||||
mergedTimeline = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollPanel
|
||||
ref={onRef}
|
||||
className={"mx_RoomView_searchResultsPanel " + className}
|
||||
onFillRequest={onSearchResultsFillRequest}
|
||||
resizeNotifier={resizeNotifier}
|
||||
>
|
||||
<li className="mx_RoomView_scrollheader" />
|
||||
{ret}
|
||||
</ScrollPanel>
|
||||
);
|
||||
},
|
||||
);
|
293
src/components/structures/RoomStatusBar.tsx
Normal file
293
src/components/structures/RoomStatusBar.tsx
Normal file
|
@ -0,0 +1,293 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventStatus,
|
||||
MatrixError,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
SyncState,
|
||||
SyncStateData,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { Icon as WarningIcon } from "../../../res/img/feather-customised/warning-triangle.svg";
|
||||
import { _t, _td } from "../../languageHandler";
|
||||
import Resend from "../../Resend";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { messageForResourceLimitError } from "../../utils/ErrorUtils";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import InlineSpinner from "../views/elements/InlineSpinner";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { RoomStatusBarUnsentMessages } from "./RoomStatusBarUnsentMessages";
|
||||
import ExternalLink from "../views/elements/ExternalLink";
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
const STATUS_BAR_EXPANDED_LARGE = 2;
|
||||
|
||||
export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] {
|
||||
if (!room) {
|
||||
return [];
|
||||
}
|
||||
return room.getPendingEvents().filter(function (ev) {
|
||||
const isNotSent = ev.status === EventStatus.NOT_SENT;
|
||||
const belongsToTheThread = threadId === ev.threadRootId;
|
||||
return isNotSent && (!threadId || belongsToTheThread);
|
||||
});
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
// the room this statusbar is representing.
|
||||
room: Room;
|
||||
|
||||
// true if the room is being peeked at. This affects components that shouldn't
|
||||
// logically be shown when peeking, such as a prompt to invite people to a room.
|
||||
isPeeking?: boolean;
|
||||
// callback for when the user clicks on the 'resend all' button in the
|
||||
// 'unsent messages' bar
|
||||
onResendAllClick?: () => void;
|
||||
|
||||
// callback for when the user clicks on the 'cancel all' button in the
|
||||
// 'unsent messages' bar
|
||||
onCancelAllClick?: () => void;
|
||||
|
||||
// callback for when the user clicks on the 'invite others' button in the
|
||||
// 'you are alone' bar
|
||||
onInviteClick?: () => void;
|
||||
|
||||
// callback for when we do something that changes the size of the
|
||||
// status bar. This is used to trigger a re-layout in the parent
|
||||
// component.
|
||||
onResize?: () => void;
|
||||
|
||||
// callback for when the status bar can be hidden from view, as it is
|
||||
// not displaying anything
|
||||
onHidden?: () => void;
|
||||
|
||||
// callback for when the status bar is displaying something and should
|
||||
// be visible
|
||||
onVisible?: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
syncState: SyncState | null;
|
||||
syncStateData: SyncStateData | null;
|
||||
unsentMessages: MatrixEvent[];
|
||||
isResending: boolean;
|
||||
}
|
||||
|
||||
export default class RoomStatusBar extends React.PureComponent<IProps, IState> {
|
||||
private unmounted = false;
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
syncState: this.context.getSyncState(),
|
||||
syncStateData: this.context.getSyncStateData(),
|
||||
unsentMessages: getUnsentMessages(this.props.room),
|
||||
isResending: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
const client = this.context;
|
||||
client.on(ClientEvent.Sync, this.onSyncStateChange);
|
||||
client.on(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated);
|
||||
|
||||
this.checkSize();
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
this.checkSize();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
|
||||
const client = this.context;
|
||||
if (client) {
|
||||
client.removeListener(ClientEvent.Sync, this.onSyncStateChange);
|
||||
client.removeListener(RoomEvent.LocalEchoUpdated, this.onRoomLocalEchoUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
private onSyncStateChange = (state: SyncState, prevState: SyncState | null, data?: SyncStateData): void => {
|
||||
if (state === "SYNCING" && prevState === "SYNCING") {
|
||||
return;
|
||||
}
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
syncState: state,
|
||||
syncStateData: data ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
private onResendAllClick = (): void => {
|
||||
Resend.resendUnsentEvents(this.props.room).then(() => {
|
||||
this.setState({ isResending: false });
|
||||
});
|
||||
this.setState({ isResending: true });
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
};
|
||||
|
||||
private onCancelAllClick = (): void => {
|
||||
Resend.cancelUnsentEvents(this.props.room);
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
};
|
||||
|
||||
private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room): void => {
|
||||
if (room.roomId !== this.props.room.roomId) return;
|
||||
const messages = getUnsentMessages(this.props.room);
|
||||
this.setState({
|
||||
unsentMessages: messages,
|
||||
isResending: messages.length > 0 && this.state.isResending,
|
||||
});
|
||||
};
|
||||
|
||||
// Check whether current size is greater than 0, if yes call props.onVisible
|
||||
private checkSize(): void {
|
||||
if (this.getSize()) {
|
||||
if (this.props.onVisible) this.props.onVisible();
|
||||
} else {
|
||||
if (this.props.onHidden) this.props.onHidden();
|
||||
}
|
||||
}
|
||||
|
||||
// We don't need the actual height - just whether it is likely to have
|
||||
// changed - so we use '0' to indicate normal size, and other values to
|
||||
// indicate other sizes.
|
||||
private getSize(): number {
|
||||
if (this.shouldShowConnectionError()) {
|
||||
return STATUS_BAR_EXPANDED;
|
||||
} else if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return STATUS_BAR_EXPANDED_LARGE;
|
||||
}
|
||||
return STATUS_BAR_HIDDEN;
|
||||
}
|
||||
|
||||
private shouldShowConnectionError(): boolean {
|
||||
// no conn bar trumps the "some not sent" msg since you can't resend without
|
||||
// a connection!
|
||||
// There's one situation in which we don't show this 'no connection' bar, and that's
|
||||
// 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",
|
||||
);
|
||||
return this.state.syncState === "ERROR" && !errorIsMauError;
|
||||
}
|
||||
|
||||
private getUnsentMessageContent(): JSX.Element {
|
||||
const unsentMessages = this.state.unsentMessages;
|
||||
|
||||
let title: ReactNode;
|
||||
|
||||
let consentError: MatrixError | null = null;
|
||||
let resourceLimitError: MatrixError | null = null;
|
||||
for (const m of unsentMessages) {
|
||||
if (m.error && m.error.errcode === "M_CONSENT_NOT_GIVEN") {
|
||||
consentError = m.error;
|
||||
break;
|
||||
} else if (m.error && m.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
|
||||
resourceLimitError = m.error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (consentError) {
|
||||
title = _t(
|
||||
"room|status_bar|requires_consent_agreement",
|
||||
{},
|
||||
{
|
||||
consentLink: (sub) => (
|
||||
<ExternalLink href={consentError!.data?.consent_uri} target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</ExternalLink>
|
||||
),
|
||||
},
|
||||
);
|
||||
} else if (resourceLimitError) {
|
||||
title = messageForResourceLimitError(
|
||||
resourceLimitError.data.limit_type,
|
||||
resourceLimitError.data.admin_contact,
|
||||
{
|
||||
"monthly_active_user": _td("room|status_bar|monthly_user_limit_reached"),
|
||||
"hs_disabled": _td("room|status_bar|homeserver_blocked"),
|
||||
"": _td("room|status_bar|exceeded_resource_limit"),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = _t("room|status_bar|some_messages_not_sent");
|
||||
}
|
||||
|
||||
let buttonRow = (
|
||||
<>
|
||||
<AccessibleButton onClick={this.onCancelAllClick} className="mx_RoomStatusBar_unsentCancelAllBtn">
|
||||
{_t("room|status_bar|delete_all")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this.onResendAllClick} className="mx_RoomStatusBar_unsentRetry">
|
||||
{_t("room|status_bar|retry_all")}
|
||||
</AccessibleButton>
|
||||
</>
|
||||
);
|
||||
if (this.state.isResending) {
|
||||
buttonRow = (
|
||||
<>
|
||||
<InlineSpinner w={20} h={20} />
|
||||
{/* span for css */}
|
||||
<span>{_t("forward|sending")}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomStatusBarUnsentMessages
|
||||
title={title}
|
||||
description={_t("room|status_bar|select_messages_to_retry")}
|
||||
notificationState={StaticNotificationState.RED_EXCLAMATION}
|
||||
buttons={buttonRow}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (this.shouldShowConnectionError()) {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_connectionLostBar">
|
||||
<WarningIcon width="24px" height="24px" />
|
||||
<div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_title">
|
||||
{_t("room|status_bar|server_connectivity_lost_title")}
|
||||
</div>
|
||||
<div className="mx_RoomStatusBar_connectionLostBar_desc">
|
||||
{_t("room|status_bar|server_connectivity_lost_description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.unsentMessages.length > 0 || this.state.isResending) {
|
||||
return this.getUnsentMessageContent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
36
src/components/structures/RoomStatusBarUnsentMessages.tsx
Normal file
36
src/components/structures/RoomStatusBarUnsentMessages.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactElement, ReactNode } from "react";
|
||||
|
||||
import { StaticNotificationState } from "../../stores/notifications/StaticNotificationState";
|
||||
import NotificationBadge from "../views/rooms/NotificationBadge";
|
||||
|
||||
interface RoomStatusBarUnsentMessagesProps {
|
||||
title: ReactNode;
|
||||
description?: string;
|
||||
notificationState: StaticNotificationState;
|
||||
buttons: ReactElement;
|
||||
}
|
||||
|
||||
export const RoomStatusBarUnsentMessages = (props: RoomStatusBarUnsentMessagesProps): ReactElement => {
|
||||
return (
|
||||
<div className="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages">
|
||||
<div role="alert">
|
||||
<div className="mx_RoomStatusBar_unsentBadge">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
2547
src/components/structures/RoomView.tsx
Normal file
2547
src/components/structures/RoomView.tsx
Normal file
File diff suppressed because it is too large
Load diff
956
src/components/structures/ScrollPanel.tsx
Normal file
956
src/components/structures/ScrollPanel.tsx
Normal file
|
@ -0,0 +1,956 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, CSSProperties, ReactNode } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import Timer from "../../utils/Timer";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
|
||||
// The amount of extra scroll distance to allow prior to unfilling.
|
||||
// See getExcessHeight.
|
||||
const UNPAGINATION_PADDING = 6000;
|
||||
// The number of milliseconds to debounce calls to onUnfillRequest,
|
||||
// to prevent many scroll events causing many unfilling requests.
|
||||
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
|
||||
// updateHeight makes the height a `Math.ceil` multiple of this, so we don't have to update the height too often.
|
||||
// It also allows the user to scroll past the pagination spinner a bit, so they don't feel blocked so
|
||||
// much while the content loads.
|
||||
const PAGE_SIZE = 400;
|
||||
|
||||
const debuglog = (...args: any[]): void => {
|
||||
if (SettingsStore.getValue("debug_scroll_panel")) {
|
||||
logger.log.call(console, "ScrollPanel debuglog:", ...args);
|
||||
}
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
/* stickyBottom: if set to true, then once the user hits the bottom of
|
||||
* the list, any new children added to the list will cause the list to
|
||||
* scroll down to show the new element, rather than preserving the
|
||||
* existing view.
|
||||
*/
|
||||
stickyBottom?: boolean;
|
||||
|
||||
/* startAtBottom: if set to true, the view is assumed to start
|
||||
* scrolled to the bottom.
|
||||
* XXX: It's likely this is unnecessary and can be derived from
|
||||
* stickyBottom, but I'm adding an extra parameter to ensure
|
||||
* behaviour stays the same for other uses of ScrollPanel.
|
||||
* If so, let's remove this parameter down the line.
|
||||
*/
|
||||
startAtBottom?: boolean;
|
||||
|
||||
/* className: classnames to add to the top-level div
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/* style: styles to add to the top-level div
|
||||
*/
|
||||
style?: CSSProperties;
|
||||
|
||||
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
|
||||
*/
|
||||
resizeNotifier?: ResizeNotifier;
|
||||
|
||||
/* fixedChildren: allows for children to be passed which are rendered outside
|
||||
* of the wrapper
|
||||
*/
|
||||
fixedChildren?: ReactNode;
|
||||
children?: ReactNode;
|
||||
|
||||
/* onFillRequest(backwards): a callback which is called on scroll when
|
||||
* the user nears the start (backwards = true) or end (backwards =
|
||||
* false) of the list.
|
||||
*
|
||||
* This should return a promise; no more calls will be made until the
|
||||
* promise completes.
|
||||
*
|
||||
* The promise should resolve to true if there is more data to be
|
||||
* retrieved in this direction (in which case onFillRequest may be
|
||||
* called again immediately), or false if there is no more data in this
|
||||
* direction (at this time) - which will stop the pagination cycle until
|
||||
* the user scrolls again.
|
||||
*/
|
||||
onFillRequest?(backwards: boolean): Promise<boolean>;
|
||||
|
||||
/* onUnfillRequest(backwards): a callback which is called on scroll when
|
||||
* there are children elements that are far out of view and could be removed
|
||||
* without causing pagination to occur.
|
||||
*
|
||||
* This function should accept a boolean, which is true to indicate the back/top
|
||||
* of the panel and false otherwise, and a scroll token, which refers to the
|
||||
* first element to remove if removing from the front/bottom, and last element
|
||||
* to remove if removing from the back/top.
|
||||
*/
|
||||
onUnfillRequest?(backwards: boolean, scrollToken: string): void;
|
||||
|
||||
/* onScroll: a callback which is called whenever any scroll happens.
|
||||
*/
|
||||
onScroll?(event: Event): void;
|
||||
}
|
||||
|
||||
/* This component implements an intelligent scrolling list.
|
||||
*
|
||||
* It wraps a list of <li> children; when items are added to the start or end
|
||||
* of the list, the scroll position is updated so that the user still sees the
|
||||
* same position in the list.
|
||||
*
|
||||
* It also provides a hook which allows parents to provide more list elements
|
||||
* when we get close to the start or end of the list.
|
||||
*
|
||||
* Each child element should have a 'data-scroll-tokens'. This string of
|
||||
* comma-separated tokens may contain a single token or many, where many indicates
|
||||
* that the element contains elements that have scroll tokens themselves. The first
|
||||
* token in 'data-scroll-tokens' is used to serialise the scroll state, and returned
|
||||
* as the 'trackedScrollToken' attribute by getScrollState().
|
||||
*
|
||||
* IMPORTANT: INDIVIDUAL TOKENS WITHIN 'data-scroll-tokens' MUST NOT CONTAIN COMMAS.
|
||||
*
|
||||
* Some notes about the implementation:
|
||||
*
|
||||
* The saved 'scrollState' can exist in one of two states:
|
||||
*
|
||||
* - stuckAtBottom: (the default, and restored by resetScrollState): the
|
||||
* viewport is scrolled down as far as it can be. When the children are
|
||||
* updated, the scroll position will be updated to ensure it is still at
|
||||
* the bottom.
|
||||
*
|
||||
* - fixed, in which the viewport is conceptually tied at a specific scroll
|
||||
* offset. We don't save the absolute scroll offset, because that would be
|
||||
* affected by window width, zoom level, amount of scrollback, etc. Instead,
|
||||
* we save an identifier for the last fully-visible message, and the number
|
||||
* of pixels the window was scrolled below it - which is hopefully near
|
||||
* enough.
|
||||
*
|
||||
* The 'stickyBottom' property controls the behaviour when we reach the bottom
|
||||
* of the window (either through a user-initiated scroll, or by calling
|
||||
* scrollToBottom). If stickyBottom is enabled, the scrollState will enter
|
||||
* 'stuckAtBottom' state - ensuring that new additions cause the window to
|
||||
* scroll down further. If stickyBottom is disabled, we just save the scroll
|
||||
* offset as normal.
|
||||
*/
|
||||
|
||||
export interface IScrollState {
|
||||
stuckAtBottom?: boolean;
|
||||
trackedNode?: HTMLElement;
|
||||
trackedScrollToken?: string;
|
||||
bottomOffset?: number;
|
||||
pixelOffset?: number;
|
||||
}
|
||||
|
||||
interface IPreventShrinkingState {
|
||||
offsetFromBottom: number;
|
||||
offsetNode: HTMLElement;
|
||||
}
|
||||
|
||||
export default class ScrollPanel extends React.Component<IProps> {
|
||||
// noinspection JSUnusedLocalSymbols
|
||||
public static defaultProps = {
|
||||
stickyBottom: true,
|
||||
startAtBottom: true,
|
||||
onFillRequest: function (backwards: boolean) {
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
onUnfillRequest: function (backwards: boolean, scrollToken: string) {},
|
||||
onScroll: function () {},
|
||||
};
|
||||
|
||||
private readonly pendingFillRequests: Record<"b" | "f", boolean | null> = {
|
||||
b: null,
|
||||
f: null,
|
||||
};
|
||||
private readonly itemlist = createRef<HTMLOListElement>();
|
||||
private unmounted = false;
|
||||
private scrollTimeout?: Timer;
|
||||
// Are we currently trying to backfill?
|
||||
private isFilling = false;
|
||||
// Is the current fill request caused by a props update?
|
||||
private isFillingDueToPropsUpdate = false;
|
||||
// Did another request to check the fill state arrive while we were trying to backfill?
|
||||
private fillRequestWhileRunning = false;
|
||||
// Is that next fill request scheduled because of a props update?
|
||||
private pendingFillDueToPropsUpdate = false;
|
||||
private scrollState!: IScrollState;
|
||||
private preventShrinkingState: IPreventShrinkingState | null = null;
|
||||
private unfillDebouncer: number | null = null;
|
||||
private bottomGrowth!: number;
|
||||
private minListHeight!: number;
|
||||
private heightUpdateInProgress = false;
|
||||
private divScroll: HTMLDivElement | null = null;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.props.resizeNotifier?.on("middlePanelResizedNoisy", this.onResize);
|
||||
|
||||
this.resetScrollState();
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.checkScroll();
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
// after adding event tiles, we may need to tweak the scroll (either to
|
||||
// keep at the bottom of the timeline, or to maintain the view after
|
||||
// adding events to the top).
|
||||
//
|
||||
// This will also re-check the fill state, in case the pagination was inadequate
|
||||
this.checkScroll(true);
|
||||
this.updatePreventShrinking();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
// set a boolean to say we've been unmounted, which any pending
|
||||
// promises can use to throw away their results.
|
||||
//
|
||||
// (We could use isMounted(), but facebook have deprecated that.)
|
||||
this.unmounted = true;
|
||||
|
||||
this.props.resizeNotifier?.removeListener("middlePanelResizedNoisy", this.onResize);
|
||||
|
||||
this.divScroll = null;
|
||||
}
|
||||
|
||||
private onScroll = (ev: Event): void => {
|
||||
// skip scroll events caused by resizing
|
||||
if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return;
|
||||
debuglog("onScroll called past resize gate; scroll node top:", this.getScrollNode().scrollTop);
|
||||
this.scrollTimeout?.restart();
|
||||
this.saveScrollState();
|
||||
this.updatePreventShrinking();
|
||||
this.props.onScroll?.(ev);
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.checkFillState();
|
||||
};
|
||||
|
||||
private onResize = (): void => {
|
||||
debuglog("onResize called");
|
||||
this.checkScroll();
|
||||
// update preventShrinkingState if present
|
||||
if (this.preventShrinkingState) {
|
||||
this.preventShrinking();
|
||||
}
|
||||
};
|
||||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
// where it ought to be, and set off pagination requests if necessary.
|
||||
public checkScroll = (isFromPropsUpdate = false): void => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
// We don't care if these two conditions race - they're different trees.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.restoreSavedScrollState();
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.checkFillState(0, isFromPropsUpdate);
|
||||
};
|
||||
|
||||
// return true if the content is fully scrolled down right now; else false.
|
||||
//
|
||||
// note that this is independent of the 'stuckAtBottom' state - it is simply
|
||||
// about whether the content is scrolled down right now, irrespective of
|
||||
// whether it will stay that way when the children update.
|
||||
public isAtBottom = (): boolean => {
|
||||
const sn = this.getScrollNode();
|
||||
// fractional values (both too big and too small)
|
||||
// for scrollTop happen on certain browsers/platforms
|
||||
// when scrolled all the way down. E.g. Chrome 72 on debian.
|
||||
//
|
||||
// We therefore leave a bit of wiggle-room and assume we're at the
|
||||
// bottom if the unscrolled area is less than one pixel high.
|
||||
//
|
||||
// non-standard DPI settings also seem to have effect here and can
|
||||
// actually lead to scrollTop+clientHeight being *larger* than
|
||||
// scrollHeight. (observed in element-desktop on Ubuntu 20.04)
|
||||
//
|
||||
return sn.scrollHeight - (sn.scrollTop + sn.clientHeight) <= 1;
|
||||
};
|
||||
|
||||
// returns the vertical height in the given direction that can be removed from
|
||||
// the content box (which has a height of scrollHeight, see checkFillState) without
|
||||
// pagination occurring.
|
||||
//
|
||||
// padding* = UNPAGINATION_PADDING
|
||||
//
|
||||
// ### Region determined as excess.
|
||||
//
|
||||
// .---------. - -
|
||||
// |#########| | |
|
||||
// |#########| - | scrollTop |
|
||||
// | | | padding* | |
|
||||
// | | | | |
|
||||
// .-+---------+-. - - | |
|
||||
// : | | : | | |
|
||||
// : | | : | clientHeight | |
|
||||
// : | | : | | |
|
||||
// .-+---------+-. - - |
|
||||
// | | | | | |
|
||||
// | | | | | clientHeight | scrollHeight
|
||||
// | | | | | |
|
||||
// `-+---------+-' - |
|
||||
// : | | : | |
|
||||
// : | | : | clientHeight |
|
||||
// : | | : | |
|
||||
// `-+---------+-' - - |
|
||||
// | | | padding* |
|
||||
// | | | |
|
||||
// |#########| - |
|
||||
// |#########| |
|
||||
// `---------' -
|
||||
private getExcessHeight(backwards: boolean): number {
|
||||
const sn = this.getScrollNode();
|
||||
const contentHeight = this.getMessagesHeight();
|
||||
const listHeight = this.getListHeight();
|
||||
const clippedHeight = contentHeight - listHeight;
|
||||
const unclippedScrollTop = sn.scrollTop + clippedHeight;
|
||||
|
||||
if (backwards) {
|
||||
return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING;
|
||||
} else {
|
||||
return contentHeight - (unclippedScrollTop + 2 * sn.clientHeight) - UNPAGINATION_PADDING;
|
||||
}
|
||||
}
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
public checkFillState = async (depth = 0, isFromPropsUpdate = false): Promise<void> => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstCall = depth === 0;
|
||||
const sn = this.getScrollNode();
|
||||
|
||||
// if there is less than a screen's worth of messages above or below the
|
||||
// viewport, try to get some more messages.
|
||||
//
|
||||
// scrollTop is the number of pixels between the top of the content and
|
||||
// the top of the viewport.
|
||||
//
|
||||
// scrollHeight is the total height of the content.
|
||||
//
|
||||
// clientHeight is the height of the viewport (excluding borders,
|
||||
// margins, and scrollbars).
|
||||
//
|
||||
//
|
||||
// .---------. - -
|
||||
// | | | scrollTop |
|
||||
// .-+---------+-. - - |
|
||||
// | | | | | |
|
||||
// | | | | | clientHeight | scrollHeight
|
||||
// | | | | | |
|
||||
// `-+---------+-' - |
|
||||
// | | |
|
||||
// | | |
|
||||
// `---------' -
|
||||
//
|
||||
|
||||
// as filling is async and recursive,
|
||||
// don't allow more than 1 chain of calls concurrently
|
||||
// do make a note when a new request comes in while already running one,
|
||||
// so we can trigger a new chain of calls once done.
|
||||
// However, we make an exception for when we're already filling due to a
|
||||
// props (or children) update, because very often the children include
|
||||
// spinners to say whether we're paginating or not, so this would cause
|
||||
// infinite paginating.
|
||||
if (isFirstCall) {
|
||||
if (this.isFilling && !this.isFillingDueToPropsUpdate) {
|
||||
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
|
||||
this.fillRequestWhileRunning = true;
|
||||
this.pendingFillDueToPropsUpdate = isFromPropsUpdate;
|
||||
return;
|
||||
}
|
||||
debuglog("isFilling: setting");
|
||||
this.isFilling = true;
|
||||
this.isFillingDueToPropsUpdate = isFromPropsUpdate;
|
||||
}
|
||||
|
||||
const itemlist = this.itemlist.current;
|
||||
const firstTile = itemlist?.firstElementChild as HTMLElement | undefined;
|
||||
const fillPromises: Promise<void>[] = [];
|
||||
|
||||
// if scrollTop gets to 1 screen from the top of the first tile,
|
||||
// try backward filling
|
||||
if (!firstTile || sn.scrollTop - firstTile.offsetTop < sn.clientHeight) {
|
||||
// need to back-fill
|
||||
fillPromises.push(this.maybeFill(depth, true));
|
||||
}
|
||||
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
|
||||
// try forward filling
|
||||
if (sn.scrollHeight - sn.scrollTop < sn.clientHeight * 2) {
|
||||
// need to forward-fill
|
||||
fillPromises.push(this.maybeFill(depth, false));
|
||||
}
|
||||
|
||||
if (fillPromises.length) {
|
||||
try {
|
||||
await Promise.all(fillPromises);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
if (isFirstCall) {
|
||||
debuglog("isFilling: clearing");
|
||||
this.isFilling = false;
|
||||
this.isFillingDueToPropsUpdate = false;
|
||||
}
|
||||
|
||||
if (this.fillRequestWhileRunning) {
|
||||
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
|
||||
this.fillRequestWhileRunning = false;
|
||||
this.pendingFillDueToPropsUpdate = false;
|
||||
// noinspection ES6MissingAwait
|
||||
this.checkFillState(0, refillDueToPropsUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
// check if unfilling is possible and send an unfill request if necessary
|
||||
private checkUnfillState(backwards: boolean): void {
|
||||
let excessHeight = this.getExcessHeight(backwards);
|
||||
if (excessHeight <= 0 || !this.itemlist.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const origExcessHeight = excessHeight;
|
||||
|
||||
const tiles = this.itemlist.current.children;
|
||||
|
||||
// The scroll token of the first/last tile to be unpaginated
|
||||
let markerScrollToken: string | null = null;
|
||||
|
||||
// Subtract heights of tiles to simulate the tiles being unpaginated until the
|
||||
// excess height is less than the height of the next tile to subtract. This
|
||||
// prevents excessHeight becoming negative, which could lead to future
|
||||
// pagination.
|
||||
//
|
||||
// If backwards is true, we unpaginate (remove) tiles from the back (top).
|
||||
let tile: HTMLElement;
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
tile = tiles[backwards ? i : tiles.length - 1 - i] as HTMLElement;
|
||||
// Subtract height of tile as if it were unpaginated
|
||||
excessHeight -= tile.clientHeight;
|
||||
//If removing the tile would lead to future pagination, break before setting scroll token
|
||||
if (tile.clientHeight > excessHeight) {
|
||||
break;
|
||||
}
|
||||
// The tile may not have a scroll token, so guard it
|
||||
if (tile.dataset.scrollTokens) {
|
||||
markerScrollToken = tile.dataset.scrollTokens.split(",")[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (markerScrollToken) {
|
||||
// Use a debouncer to prevent multiple unfill calls in quick succession
|
||||
// This is to make the unfilling process less aggressive
|
||||
if (this.unfillDebouncer) {
|
||||
clearTimeout(this.unfillDebouncer);
|
||||
}
|
||||
this.unfillDebouncer = window.setTimeout(() => {
|
||||
this.unfillDebouncer = null;
|
||||
debuglog("unfilling now", { backwards, origExcessHeight });
|
||||
this.props.onUnfillRequest?.(backwards, markerScrollToken!);
|
||||
}, UNFILL_REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// check if there is already a pending fill request. If not, set one off.
|
||||
private maybeFill(depth: number, backwards: boolean): Promise<void> {
|
||||
const dir = backwards ? "b" : "f";
|
||||
if (this.pendingFillRequests[dir]) {
|
||||
debuglog("Already a fill in progress - not starting another; direction=", dir);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
debuglog("starting fill; direction=", dir);
|
||||
|
||||
// onFillRequest can end up calling us recursively (via onScroll
|
||||
// events) so make sure we set this before firing off the call.
|
||||
this.pendingFillRequests[dir] = true;
|
||||
|
||||
// wait 1ms before paginating, because otherwise
|
||||
// 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);
|
||||
|
||||
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
|
||||
* properties:
|
||||
*
|
||||
* boolean stuckAtBottom: true if we are tracking the bottom of the
|
||||
* scroll. false if we are tracking a particular child.
|
||||
*
|
||||
* string trackedScrollToken: undefined if stuckAtBottom is true; if it is
|
||||
* false, the first token in data-scroll-tokens of the child which we are
|
||||
* tracking.
|
||||
*
|
||||
* number bottomOffset: undefined if stuckAtBottom is true; if it is false,
|
||||
* the number of pixels the bottom of the tracked child is above the
|
||||
* bottom of the scroll panel.
|
||||
*/
|
||||
public getScrollState = (): IScrollState => this.scrollState;
|
||||
|
||||
/* reset the saved scroll state.
|
||||
*
|
||||
* This is useful if the list is being replaced, and you don't want to
|
||||
* preserve scroll even if new children happen to have the same scroll
|
||||
* tokens as old ones.
|
||||
*
|
||||
* This will cause the viewport to be scrolled down to the bottom on the
|
||||
* next update of the child list. This is different to scrollToBottom(),
|
||||
* which would save the current bottom-most child as the active one (so is
|
||||
* no use if no children exist yet, or if you are about to replace the
|
||||
* child list.)
|
||||
*/
|
||||
public resetScrollState = (): void => {
|
||||
this.scrollState = {
|
||||
stuckAtBottom: this.props.startAtBottom,
|
||||
};
|
||||
this.bottomGrowth = 0;
|
||||
this.minListHeight = 0;
|
||||
this.scrollTimeout = new Timer(100);
|
||||
this.heightUpdateInProgress = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* jump to the top of the content.
|
||||
*/
|
||||
public scrollToTop = (): void => {
|
||||
this.getScrollNode().scrollTop = 0;
|
||||
this.saveScrollState();
|
||||
};
|
||||
|
||||
/**
|
||||
* jump to the bottom of the content.
|
||||
*/
|
||||
public scrollToBottom = (): void => {
|
||||
// the easiest way to make sure that the scroll state is correctly
|
||||
// saved is to do the scroll, then save the updated state. (Calculating
|
||||
// it ourselves is hard, and we can't rely on an onScroll callback
|
||||
// happening, since there may be no user-visible change here).
|
||||
const sn = this.getScrollNode();
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
this.saveScrollState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Page up/down.
|
||||
*
|
||||
* @param {number} multiple: -1 to page up, +1 to page down
|
||||
*/
|
||||
public scrollRelative = (multiple: -1 | 1): void => {
|
||||
const scrollNode = this.getScrollNode();
|
||||
// TODO: Document what magic number 0.9 is doing
|
||||
const delta = multiple * scrollNode.clientHeight * 0.9;
|
||||
scrollNode.scrollBy(0, delta);
|
||||
this.saveScrollState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll up/down in response to a scroll key
|
||||
* @param {object} ev the keyboard event
|
||||
*/
|
||||
public handleScrollKey = (ev: React.KeyboardEvent | KeyboardEvent): void => {
|
||||
const roomAction = getKeyBindingsManager().getRoomAction(ev);
|
||||
switch (roomAction) {
|
||||
case KeyBindingAction.ScrollUp:
|
||||
this.scrollRelative(-1);
|
||||
break;
|
||||
case KeyBindingAction.ScrollDown:
|
||||
this.scrollRelative(1);
|
||||
break;
|
||||
case KeyBindingAction.JumpToFirstMessage:
|
||||
this.scrollToTop();
|
||||
break;
|
||||
case KeyBindingAction.JumpToLatestMessage:
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
/* Scroll the panel to bring the DOM node with the scroll token
|
||||
* `scrollToken` into view.
|
||||
*
|
||||
* offsetBase gives the reference point for the pixelOffset. 0 means the
|
||||
* top of the container, 1 means the bottom, and fractional values mean
|
||||
* somewhere in the middle. If omitted, it defaults to 0.
|
||||
*
|
||||
* pixelOffset gives the number of pixels *above* the offsetBase that the
|
||||
* node (specifically, the bottom of it) will be positioned. If omitted, it
|
||||
* defaults to 0.
|
||||
*/
|
||||
public scrollToToken = (scrollToken: string, pixelOffset = 0, offsetBase = 0): void => {
|
||||
// set the trackedScrollToken, so we can get the node through getTrackedNode
|
||||
this.scrollState = {
|
||||
stuckAtBottom: false,
|
||||
trackedScrollToken: scrollToken,
|
||||
};
|
||||
const trackedNode = this.getTrackedNode();
|
||||
const scrollNode = this.getScrollNode();
|
||||
if (trackedNode) {
|
||||
// set the scrollTop to the position we want.
|
||||
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
|
||||
// would position the trackedNode towards the top of the viewport.
|
||||
// This because when setting the scrollTop only 10 or so events might be loaded,
|
||||
// not giving enough content below the trackedNode to scroll downwards
|
||||
// enough, so it ends up in the top of the viewport.
|
||||
debuglog("scrollToken: setting scrollTop", { offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop });
|
||||
scrollNode.scrollTop = trackedNode.offsetTop - scrollNode.clientHeight * offsetBase + pixelOffset;
|
||||
this.saveScrollState();
|
||||
}
|
||||
};
|
||||
|
||||
private saveScrollState(): void {
|
||||
if (this.props.stickyBottom && this.isAtBottom()) {
|
||||
this.scrollState = { stuckAtBottom: true };
|
||||
debuglog("saved stuckAtBottom state");
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollNode = this.getScrollNode();
|
||||
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
|
||||
|
||||
const itemlist = this.itemlist.current;
|
||||
if (!itemlist) return;
|
||||
const messages = itemlist.children;
|
||||
let node: HTMLElement | null = null;
|
||||
|
||||
// TODO: do a binary search here, as items are sorted by offsetTop
|
||||
// loop backwards, from bottom-most message (as that is the most common case)
|
||||
for (let i = messages.length - 1; i >= 0; --i) {
|
||||
const htmlMessage = messages[i] as HTMLElement;
|
||||
if (!htmlMessage.dataset?.scrollTokens) {
|
||||
// dataset is only specified on HTMLElements
|
||||
continue;
|
||||
}
|
||||
node = htmlMessage;
|
||||
// break at the first message (coming from the bottom)
|
||||
// that has it's offsetTop above the bottom of the viewport.
|
||||
if (this.topFromBottom(node) > viewportBottom) {
|
||||
// Use this node as the scrollToken
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
debuglog("unable to save scroll state: found no children in the viewport");
|
||||
return;
|
||||
}
|
||||
const scrollToken = node!.dataset.scrollTokens?.split(",")[0];
|
||||
debuglog("saving anchored scroll state to message", scrollToken);
|
||||
const bottomOffset = this.topFromBottom(node);
|
||||
this.scrollState = {
|
||||
stuckAtBottom: false,
|
||||
trackedNode: node,
|
||||
trackedScrollToken: scrollToken,
|
||||
bottomOffset: bottomOffset,
|
||||
pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room
|
||||
};
|
||||
}
|
||||
|
||||
private async restoreSavedScrollState(): Promise<void> {
|
||||
const scrollState = this.scrollState;
|
||||
|
||||
if (scrollState.stuckAtBottom) {
|
||||
const sn = this.getScrollNode();
|
||||
if (sn.scrollTop !== sn.scrollHeight) {
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
}
|
||||
} else if (scrollState.trackedScrollToken) {
|
||||
const itemlist = this.itemlist.current;
|
||||
const trackedNode = this.getTrackedNode();
|
||||
if (trackedNode) {
|
||||
const newBottomOffset = this.topFromBottom(trackedNode);
|
||||
const bottomDiff = newBottomOffset - (scrollState.bottomOffset ?? 0);
|
||||
this.bottomGrowth += bottomDiff;
|
||||
scrollState.bottomOffset = newBottomOffset;
|
||||
const newHeight = `${this.getListHeight()}px`;
|
||||
if (itemlist && itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
debuglog("balancing height because messages below viewport grew by", bottomDiff);
|
||||
}
|
||||
}
|
||||
if (!this.heightUpdateInProgress) {
|
||||
this.heightUpdateInProgress = true;
|
||||
try {
|
||||
await this.updateHeight();
|
||||
} finally {
|
||||
this.heightUpdateInProgress = false;
|
||||
}
|
||||
} else {
|
||||
debuglog("not updating height because request already in progress");
|
||||
}
|
||||
}
|
||||
|
||||
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
|
||||
private async updateHeight(): Promise<void> {
|
||||
// wait until user has stopped scrolling
|
||||
if (this.scrollTimeout?.isRunning()) {
|
||||
debuglog("updateHeight waiting for scrolling to end ... ");
|
||||
await this.scrollTimeout.finished();
|
||||
debuglog("updateHeight actually running now");
|
||||
} else {
|
||||
debuglog("updateHeight running without delay");
|
||||
}
|
||||
|
||||
// We might have unmounted since the timer finished, so abort if so.
|
||||
if (this.unmounted) {
|
||||
debuglog("updateHeight: abort due to unmount");
|
||||
return;
|
||||
}
|
||||
|
||||
const sn = this.getScrollNode();
|
||||
const itemlist = this.itemlist.current;
|
||||
const contentHeight = this.getMessagesHeight();
|
||||
// Only round to the nearest page when we're basing the height off the content, not off the scrollNode height
|
||||
// otherwise it'll cause too much overscroll which makes it possible to entirely scroll content off-screen.
|
||||
if (contentHeight < sn.clientHeight) {
|
||||
this.minListHeight = sn.clientHeight;
|
||||
} else {
|
||||
this.minListHeight = Math.ceil(contentHeight / PAGE_SIZE) * PAGE_SIZE;
|
||||
}
|
||||
this.bottomGrowth = 0;
|
||||
const newHeight = `${this.getListHeight()}px`;
|
||||
|
||||
const scrollState = this.scrollState;
|
||||
if (scrollState.stuckAtBottom) {
|
||||
if (itemlist && itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
if (sn.scrollTop !== sn.scrollHeight) {
|
||||
sn.scrollTop = sn.scrollHeight;
|
||||
}
|
||||
debuglog("updateHeight to", newHeight);
|
||||
} else if (scrollState.trackedScrollToken) {
|
||||
const trackedNode = this.getTrackedNode();
|
||||
// if the timeline has been reloaded
|
||||
// this can be called before scrollToBottom or whatever has been called
|
||||
// so don't do anything if the node has disappeared from
|
||||
// the currently filled piece of the timeline
|
||||
if (trackedNode) {
|
||||
const oldTop = trackedNode.offsetTop;
|
||||
if (itemlist && itemlist.style.height !== newHeight) {
|
||||
itemlist.style.height = newHeight;
|
||||
}
|
||||
const newTop = trackedNode.offsetTop;
|
||||
const topDiff = newTop - oldTop;
|
||||
// important to scroll by a relative amount as
|
||||
// reading scrollTop and then setting it might
|
||||
// yield out of date values and cause a jump
|
||||
// when setting it
|
||||
sn.scrollBy(0, topDiff);
|
||||
debuglog("updateHeight to", { newHeight, topDiff });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getTrackedNode(): HTMLElement | undefined {
|
||||
const scrollState = this.scrollState;
|
||||
const trackedNode = scrollState.trackedNode;
|
||||
|
||||
if (!trackedNode?.parentElement && this.itemlist.current) {
|
||||
let node: HTMLElement | undefined = undefined;
|
||||
const messages = this.itemlist.current.children;
|
||||
const scrollToken = scrollState.trackedScrollToken;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; --i) {
|
||||
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!)) {
|
||||
node = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (node) {
|
||||
debuglog("had to find tracked node again for token:", scrollState.trackedScrollToken);
|
||||
}
|
||||
scrollState.trackedNode = node;
|
||||
}
|
||||
|
||||
if (!scrollState.trackedNode) {
|
||||
debuglog("No node with token:", scrollState.trackedScrollToken);
|
||||
return;
|
||||
}
|
||||
|
||||
return scrollState.trackedNode;
|
||||
}
|
||||
|
||||
private getListHeight(): number {
|
||||
return this.bottomGrowth + this.minListHeight;
|
||||
}
|
||||
|
||||
private getMessagesHeight(): number {
|
||||
const itemlist = this.itemlist.current;
|
||||
const lastNode = itemlist?.lastElementChild as HTMLElement;
|
||||
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
|
||||
const firstNodeTop = (itemlist?.firstElementChild as HTMLElement)?.offsetTop ?? 0;
|
||||
// 18 is itemlist padding
|
||||
return lastNodeBottom - firstNodeTop + 18 * 2;
|
||||
}
|
||||
|
||||
private topFromBottom(node: HTMLElement): number {
|
||||
if (!this.itemlist.current) return -1;
|
||||
// current capped height - distance from top = distance from bottom of container to top of tracked element
|
||||
return this.itemlist.current.clientHeight - node.offsetTop;
|
||||
}
|
||||
|
||||
/* get the DOM node which has the scrollTop property we care about for our
|
||||
* message panel.
|
||||
*/
|
||||
private getScrollNode(): HTMLDivElement {
|
||||
if (this.unmounted) {
|
||||
// this shouldn't happen, but when it does, turn the NPE into
|
||||
// something more meaningful.
|
||||
throw new Error("ScrollPanel.getScrollNode called when unmounted");
|
||||
}
|
||||
|
||||
if (!this.divScroll) {
|
||||
// Likewise, we should have the ref by this point, but if not
|
||||
// turn the NPE into something meaningful.
|
||||
throw new Error("ScrollPanel.getScrollNode called before AutoHideScrollbar ref collected");
|
||||
}
|
||||
|
||||
return this.divScroll;
|
||||
}
|
||||
|
||||
private collectScroll = (divScroll: HTMLDivElement | null): void => {
|
||||
this.divScroll = divScroll;
|
||||
};
|
||||
|
||||
/**
|
||||
Mark the bottom offset of the last tile, so we can balance it out when
|
||||
anything below it changes, by calling updatePreventShrinking, to keep
|
||||
the same minimum bottom offset, effectively preventing the timeline to shrink.
|
||||
*/
|
||||
public preventShrinking = (): void => {
|
||||
const messageList = this.itemlist.current;
|
||||
const tiles = messageList?.children;
|
||||
if (!tiles) {
|
||||
return;
|
||||
}
|
||||
let lastTileNode;
|
||||
for (let i = tiles.length - 1; i >= 0; i--) {
|
||||
const node = tiles[i] as HTMLElement;
|
||||
if (node.dataset.scrollTokens) {
|
||||
lastTileNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!lastTileNode) {
|
||||
return;
|
||||
}
|
||||
this.clearPreventShrinking();
|
||||
const offsetFromBottom = messageList.clientHeight - (lastTileNode.offsetTop + lastTileNode.clientHeight);
|
||||
this.preventShrinkingState = {
|
||||
offsetFromBottom: offsetFromBottom,
|
||||
offsetNode: lastTileNode,
|
||||
};
|
||||
debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom");
|
||||
};
|
||||
|
||||
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
|
||||
public clearPreventShrinking = (): void => {
|
||||
const messageList = this.itemlist.current;
|
||||
const balanceElement = messageList && messageList.parentElement;
|
||||
if (balanceElement) balanceElement.style.removeProperty("paddingBottom");
|
||||
this.preventShrinkingState = null;
|
||||
debuglog("prevent shrinking cleared");
|
||||
};
|
||||
|
||||
/**
|
||||
update the container padding to balance
|
||||
the bottom offset of the last tile since
|
||||
preventShrinking was called.
|
||||
Clears the prevent-shrinking state ones the offset
|
||||
from the bottom of the marked tile grows larger than
|
||||
what it was when marking.
|
||||
*/
|
||||
public updatePreventShrinking = (): void => {
|
||||
if (this.preventShrinkingState && this.itemlist.current) {
|
||||
const sn = this.getScrollNode();
|
||||
const scrollState = this.scrollState;
|
||||
const messageList = this.itemlist.current;
|
||||
const { offsetNode, offsetFromBottom } = this.preventShrinkingState;
|
||||
// element used to set paddingBottom to balance the typing notifs disappearing
|
||||
const balanceElement = messageList.parentElement;
|
||||
// if the offsetNode got unmounted, clear
|
||||
let shouldClear = !offsetNode.parentElement;
|
||||
// also if 200px from bottom
|
||||
if (!shouldClear && !scrollState.stuckAtBottom) {
|
||||
const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight);
|
||||
shouldClear = spaceBelowViewport >= 200;
|
||||
}
|
||||
// try updating if not clearing
|
||||
if (!shouldClear) {
|
||||
const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight);
|
||||
const offsetDiff = offsetFromBottom - currentOffset;
|
||||
if (offsetDiff > 0 && balanceElement) {
|
||||
balanceElement.style.paddingBottom = `${offsetDiff}px`;
|
||||
debuglog("update prevent shrinking ", offsetDiff, "px from bottom");
|
||||
} else if (offsetDiff < 0) {
|
||||
shouldClear = true;
|
||||
}
|
||||
}
|
||||
if (shouldClear) {
|
||||
this.clearPreventShrinking();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
// TODO: the classnames on the div and ol could do with being updated to
|
||||
// reflect the fact that we don't necessarily contain a list of messages.
|
||||
// it's not obvious why we have a separate div and ol anyway.
|
||||
|
||||
// give the <ol> an explicit role=list because Safari+VoiceOver seems to think an ordered-list with
|
||||
// list-style-type: none; is no longer a list
|
||||
return (
|
||||
<AutoHideScrollbar
|
||||
wrappedRef={this.collectScroll}
|
||||
onScroll={this.onScroll}
|
||||
className={`mx_ScrollPanel ${this.props.className}`}
|
||||
style={this.props.style}
|
||||
>
|
||||
{this.props.fixedChildren}
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref={this.itemlist} className="mx_RoomView_MessageList" aria-live="polite">
|
||||
{this.props.children}
|
||||
</ol>
|
||||
</div>
|
||||
</AutoHideScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
155
src/components/structures/SearchBox.tsx
Normal file
155
src/components/structures/SearchBox.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, HTMLProps } from "react";
|
||||
import { throttle } from "lodash";
|
||||
import classNames from "classnames";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
|
||||
interface IProps extends HTMLProps<HTMLInputElement> {
|
||||
onSearch: (query: string) => void;
|
||||
onCleared?: (source?: string) => void;
|
||||
onKeyDown?: (ev: React.KeyboardEvent) => void;
|
||||
onFocus?: (ev: React.FocusEvent) => void;
|
||||
onBlur?: (ev: React.FocusEvent) => void;
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
blurredPlaceholder?: string;
|
||||
autoFocus?: boolean;
|
||||
initialValue?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
searchTerm: string;
|
||||
blurred: boolean;
|
||||
}
|
||||
|
||||
export default class SearchBox extends React.Component<IProps, IState> {
|
||||
private search = createRef<HTMLInputElement>();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
searchTerm: props.initialValue || "",
|
||||
blurred: true,
|
||||
};
|
||||
}
|
||||
|
||||
private onChange = (): void => {
|
||||
if (!this.search.current) return;
|
||||
this.setState({ searchTerm: this.search.current.value });
|
||||
this.onSearch();
|
||||
};
|
||||
|
||||
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);
|
||||
switch (action) {
|
||||
case KeyBindingAction.Escape:
|
||||
this.clearSearch("keyboard");
|
||||
break;
|
||||
}
|
||||
if (this.props.onKeyDown) this.props.onKeyDown(ev);
|
||||
};
|
||||
|
||||
private onFocus = (ev: React.FocusEvent): void => {
|
||||
this.setState({ blurred: false });
|
||||
(ev.target as HTMLInputElement).select();
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private onBlur = (ev: React.FocusEvent): void => {
|
||||
this.setState({ blurred: true });
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private clearSearch(source?: string): void {
|
||||
if (this.search.current) this.search.current.value = "";
|
||||
this.onChange();
|
||||
this.props.onCleared?.(source);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const {
|
||||
onSearch,
|
||||
onCleared,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
className = "",
|
||||
placeholder,
|
||||
blurredPlaceholder,
|
||||
autoFocus,
|
||||
initialValue,
|
||||
collapsed,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
// check for collapsed here and
|
||||
// not at parent so we keep
|
||||
// searchTerm in our state
|
||||
// when collapsing and expanding
|
||||
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;
|
||||
|
||||
// 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 })}>
|
||||
<input
|
||||
{...props}
|
||||
key="searchfield"
|
||||
type="text"
|
||||
ref={this.search}
|
||||
className={"mx_textinput_icon mx_textinput_search " + className}
|
||||
value={this.state.searchTerm}
|
||||
onFocus={this.onFocus}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onBlur={this.onBlur}
|
||||
placeholder={this.state.blurred ? blurredPlaceholder || placeholder : placeholder}
|
||||
autoComplete="off"
|
||||
autoFocus={this.props.autoFocus}
|
||||
data-testid="searchbox-input"
|
||||
/>
|
||||
{clearButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
920
src/components/structures/SpaceHierarchy.tsx
Normal file
920
src/components/structures/SpaceHierarchy.tsx
Normal file
|
@ -0,0 +1,920 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
Dispatch,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
ClientEvent,
|
||||
MatrixClient,
|
||||
MatrixError,
|
||||
EventType,
|
||||
RoomType,
|
||||
GuestAccess,
|
||||
HistoryVisibility,
|
||||
HierarchyRelation,
|
||||
HierarchyRoom,
|
||||
JoinRule,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||
import classNames from "classnames";
|
||||
import { sortBy, uniqBy } from "lodash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { KnownMembership, SpaceChildEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||
import BaseAvatar from "../views/avatars/BaseAvatar";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import InfoTooltip from "../views/elements/InfoTooltip";
|
||||
import TextWithTooltip from "../views/elements/TextWithTooltip";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import { getChildOrder } from "../../stores/spaces/SpaceStore";
|
||||
import { Linkify, topicToHtml } from "../../HtmlUtils";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { useTypedEventEmitterState } from "../../hooks/useEventEmitter";
|
||||
import { IOOBData } from "../../stores/ThreepidInviteStore";
|
||||
import { awaitRoomDownSync } from "../../utils/RoomUpgrade";
|
||||
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload";
|
||||
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
import { getTopic } from "../../hooks/room/useTopic";
|
||||
import { SdkContextClass } from "../../contexts/SDKContext";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void;
|
||||
}
|
||||
|
||||
interface ITileProps {
|
||||
room: HierarchyRoom;
|
||||
suggested?: boolean;
|
||||
selected?: boolean;
|
||||
numChildRooms?: number;
|
||||
hasPermissions?: boolean;
|
||||
children?: ReactNode;
|
||||
onViewRoomClick(): void;
|
||||
onJoinRoomClick(): Promise<unknown>;
|
||||
onToggleClick?(): void;
|
||||
}
|
||||
|
||||
const Tile: React.FC<ITileProps> = ({
|
||||
room,
|
||||
suggested,
|
||||
selected,
|
||||
hasPermissions,
|
||||
onToggleClick,
|
||||
onViewRoomClick,
|
||||
onJoinRoomClick,
|
||||
numChildRooms,
|
||||
children,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const joinedRoom = useTypedEventEmitterState(cli, ClientEvent.Room, () => {
|
||||
const cliRoom = cli?.getRoom(room.room_id);
|
||||
return cliRoom?.getMyMembership() === KnownMembership.Join ? cliRoom : undefined;
|
||||
});
|
||||
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("common|unnamed_space") : _t("common|unnamed_room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onViewRoomClick();
|
||||
};
|
||||
const onJoinClick = async (ev: ButtonEvent): Promise<void> => {
|
||||
setBusy(true);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
try {
|
||||
await onJoinRoomClick();
|
||||
await awaitRoomDownSync(cli, room.room_id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
let button: ReactElement;
|
||||
if (busy) {
|
||||
button = (
|
||||
<AccessibleButton
|
||||
disabled={true}
|
||||
onClick={onJoinClick}
|
||||
kind="primary_outline"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
title={_t("space|joining_space")}
|
||||
>
|
||||
<Spinner w={24} h={24} />
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (joinedRoom || room.join_rule === JoinRule.Knock) {
|
||||
// If the room is knockable, show the "View" button even if we are not a member; that
|
||||
// allows us to reuse the "request to join" UX in RoomView.
|
||||
button = (
|
||||
<AccessibleButton
|
||||
onClick={onPreviewClick}
|
||||
kind="primary_outline"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{_t("action|view")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else {
|
||||
button = (
|
||||
<AccessibleButton onClick={onJoinClick} kind="primary" onFocus={onFocus} tabIndex={isActive ? 0 : -1}>
|
||||
{_t("action|join")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let checkbox: ReactElement | undefined;
|
||||
if (onToggleClick) {
|
||||
if (hasPermissions) {
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||
} else {
|
||||
checkbox = (
|
||||
<TextWithTooltip
|
||||
tooltip={_t("space|user_lacks_permission")}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||
</TextWithTooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let avatar: ReactElement;
|
||||
if (joinedRoom) {
|
||||
avatar = <RoomAvatar room={joinedRoom} size="20px" />;
|
||||
} else {
|
||||
avatar = (
|
||||
<BaseAvatar
|
||||
name={name}
|
||||
idName={room.room_id}
|
||||
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
|
||||
size="20px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let description = _t("common|n_members", { count: room.num_joined_members ?? 0 });
|
||||
if (numChildRooms !== undefined) {
|
||||
description += " · " + _t("common|n_rooms", { count: numChildRooms });
|
||||
}
|
||||
|
||||
let topic: ReactNode | string | null;
|
||||
if (joinedRoom) {
|
||||
const topicObj = getTopic(joinedRoom);
|
||||
topic = topicToHtml(topicObj?.text, topicObj?.html);
|
||||
} else {
|
||||
topic = room.topic;
|
||||
}
|
||||
|
||||
let topicSection: ReactNode | undefined;
|
||||
if (topic) {
|
||||
topicSection = (
|
||||
<Linkify
|
||||
options={{
|
||||
attributes: {
|
||||
onClick(ev: MouseEvent) {
|
||||
// prevent clicks on links from bubbling up to the room tile
|
||||
ev.stopPropagation();
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{" · "}
|
||||
{topic}
|
||||
</Linkify>
|
||||
);
|
||||
}
|
||||
|
||||
let joinedSection: ReactElement | undefined;
|
||||
if (joinedRoom) {
|
||||
joinedSection = <div className="mx_SpaceHierarchy_roomTile_joined">{_t("common|joined")}</div>;
|
||||
}
|
||||
|
||||
let suggestedSection: ReactElement | undefined;
|
||||
if (suggested && (!joinedRoom || hasPermissions)) {
|
||||
suggestedSection = <InfoTooltip tooltip={_t("space|suggested_tooltip")}>{_t("space|suggested")}</InfoTooltip>;
|
||||
}
|
||||
|
||||
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">
|
||||
{description}
|
||||
{topicSection}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_SpaceHierarchy_actions">
|
||||
{button}
|
||||
{checkbox}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
let childToggle: JSX.Element | undefined;
|
||||
let childSection: JSX.Element | undefined;
|
||||
let onKeyDown: KeyboardEventHandler | undefined;
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (showChildren) {
|
||||
const onChildrenKeyDown = (e: React.KeyboardEvent): void => {
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(e);
|
||||
switch (action) {
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ref.current?.focus();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
childSection = (
|
||||
<div className="mx_SpaceHierarchy_subspace_children" onKeyDown={onChildrenKeyDown} role="group">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
let handled = false;
|
||||
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(e);
|
||||
switch (action) {
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
if (showChildren) {
|
||||
handled = true;
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.ArrowRight:
|
||||
handled = true;
|
||||
if (showChildren) {
|
||||
const childSection = ref.current?.nextElementSibling;
|
||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
|
||||
} else {
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className="mx_SpaceHierarchy_roomTileWrapper"
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
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}
|
||||
ref={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{content}
|
||||
{childToggle}
|
||||
</AccessibleButton>
|
||||
{childSection}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void => {
|
||||
const room = hierarchy.roomMap.get(roomId);
|
||||
|
||||
// Don't let the user view a room they won't be able to either peek or join:
|
||||
// fail earlier so they don't have to click back to the directory.
|
||||
if (cli.isGuest()) {
|
||||
if (!room?.world_readable && !room?.guest_can_join) {
|
||||
defaultDispatcher.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const roomAlias = getDisplayAliasForAliasSet(room?.canonical_alias ?? "", room?.aliases ?? []) || undefined;
|
||||
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
should_peek: true,
|
||||
room_alias: roomAlias,
|
||||
room_id: roomId,
|
||||
via_servers: Array.from(hierarchy.viaMap.get(roomId) || []),
|
||||
oob_data: {
|
||||
avatarUrl: room?.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which would normally decide what the name is.
|
||||
name: room?.name || roomAlias || _t("common|unnamed_room"),
|
||||
roomType,
|
||||
} as IOOBData,
|
||||
metricsTrigger: "RoomDirectory",
|
||||
});
|
||||
};
|
||||
|
||||
export const joinRoom = async (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): Promise<unknown> => {
|
||||
// Don't let the user view a room they won't be able to either peek or join:
|
||||
// fail earlier so they don't have to click back to the directory.
|
||||
if (cli.isGuest()) {
|
||||
defaultDispatcher.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cli.joinRoom(roomId, {
|
||||
viaServers: Array.from(hierarchy.viaMap.get(roomId) || []),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof MatrixError) {
|
||||
SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId);
|
||||
} else {
|
||||
logger.warn("Got a non-MatrixError while joining room", err);
|
||||
SdkContextClass.instance.roomViewStore.showJoinRoomError(
|
||||
new MatrixError({
|
||||
error: _t("error|unknown"),
|
||||
}),
|
||||
roomId,
|
||||
);
|
||||
}
|
||||
|
||||
// rethrow error so that the caller can handle react to it too
|
||||
throw err;
|
||||
}
|
||||
|
||||
defaultDispatcher.dispatch<JoinRoomReadyPayload>({
|
||||
action: Action.JoinRoomReady,
|
||||
roomId,
|
||||
metricsTrigger: "SpaceHierarchy",
|
||||
});
|
||||
};
|
||||
|
||||
interface IHierarchyLevelProps {
|
||||
root: HierarchyRoom;
|
||||
roomSet: Set<HierarchyRoom>;
|
||||
hierarchy: RoomHierarchy;
|
||||
parents: Set<string>;
|
||||
selectedMap?: Map<string, Set<string>>;
|
||||
onViewRoomClick(roomId: string, roomType?: RoomType): void;
|
||||
onJoinRoomClick(roomId: string, parents: Set<string>): Promise<unknown>;
|
||||
onToggleClick?(parentId: string, childId: string): void;
|
||||
}
|
||||
|
||||
export const toLocalRoom = (cli: MatrixClient, room: HierarchyRoom, hierarchy: RoomHierarchy): HierarchyRoom => {
|
||||
const history = cli.getRoomUpgradeHistory(
|
||||
room.room_id,
|
||||
true,
|
||||
SettingsStore.getValue("feature_dynamic_room_predecessors"),
|
||||
);
|
||||
|
||||
// Pick latest room that is actually part of the hierarchy
|
||||
let cliRoom: Room | null = null;
|
||||
for (let idx = history.length - 1; idx >= 0; --idx) {
|
||||
if (hierarchy.roomMap.get(history[idx].roomId)) {
|
||||
cliRoom = history[idx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cliRoom) {
|
||||
return {
|
||||
...room,
|
||||
room_id: cliRoom.roomId,
|
||||
room_type: cliRoom.getType(),
|
||||
name: cliRoom.name,
|
||||
topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic,
|
||||
avatar_url: cliRoom.getMxcAvatarUrl() ?? undefined,
|
||||
canonical_alias: cliRoom.getCanonicalAlias() ?? undefined,
|
||||
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,
|
||||
num_joined_members: cliRoom.getJoinedMemberCount(),
|
||||
};
|
||||
}
|
||||
|
||||
return room;
|
||||
};
|
||||
|
||||
export const HierarchyLevel: React.FC<IHierarchyLevelProps> = ({
|
||||
root,
|
||||
roomSet,
|
||||
hierarchy,
|
||||
parents,
|
||||
selectedMap,
|
||||
onViewRoomClick,
|
||||
onJoinRoomClick,
|
||||
onToggleClick,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const space = cli.getRoom(root.room_id);
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getSafeUserId());
|
||||
|
||||
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: HierarchyRelation) => {
|
||||
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, hierarchy));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[[] as HierarchyRoom[], [] as HierarchyRoom[]],
|
||||
);
|
||||
|
||||
const newParents = new Set(parents).add(root.room_id);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{uniqBy(childRooms, "room_id").map((room) => (
|
||||
<Tile
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
|
||||
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
|
||||
onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)}
|
||||
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
|
||||
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, newParents)}
|
||||
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,
|
||||
): {
|
||||
loading: boolean;
|
||||
rooms?: HierarchyRoom[];
|
||||
hierarchy?: RoomHierarchy;
|
||||
error?: Error;
|
||||
loadMore(pageSize?: number): Promise<void>;
|
||||
} => {
|
||||
const [rooms, setRooms] = useState<HierarchyRoom[]>([]);
|
||||
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
const resetHierarchy = useCallback(() => {
|
||||
setError(undefined);
|
||||
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
|
||||
hierarchy.load().then(() => {
|
||||
if (space !== hierarchy.root) return; // discard stale results
|
||||
setRooms(hierarchy.rooms ?? []);
|
||||
}, setError);
|
||||
setHierarchy(hierarchy);
|
||||
}, [space]);
|
||||
useEffect(resetHierarchy, [resetHierarchy]);
|
||||
|
||||
useDispatcher(defaultDispatcher, (payload) => {
|
||||
if (payload.action === Action.UpdateSpaceHierarchy) {
|
||||
setRooms([]); // TODO
|
||||
resetHierarchy();
|
||||
}
|
||||
});
|
||||
|
||||
const loadMore = useCallback(
|
||||
async (pageSize?: number): Promise<void> => {
|
||||
if (!hierarchy || 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) {
|
||||
return {
|
||||
loading: true,
|
||||
loadMore,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
loading: hierarchy.loading,
|
||||
rooms,
|
||||
hierarchy,
|
||||
loadMore,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
const useIntersectionObserver = (callback: () => void): ((element: HTMLDivElement) => void) => {
|
||||
const handleObserver = (entries: IntersectionObserverEntry[]): void => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
const observerRef = useRef<IntersectionObserver>();
|
||||
return (element: HTMLDivElement) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
} else if (element) {
|
||||
observerRef.current = new IntersectionObserver(handleObserver, {
|
||||
root: element.parentElement,
|
||||
rootMargin: "0px 0px 600px 0px",
|
||||
});
|
||||
}
|
||||
|
||||
if (observerRef.current && element) {
|
||||
observerRef.current.observe(element);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface IManageButtonsProps {
|
||||
hierarchy: RoomHierarchy;
|
||||
selected: Map<string, Set<string>>;
|
||||
setSelected: Dispatch<SetStateAction<Map<string, Set<string>>>>;
|
||||
setError: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
const ManageButtons: React.FC<IManageButtonsProps> = ({ hierarchy, selected, setSelected, setError }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
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 selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return hierarchy.isSuggested(parentId, childId);
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let buttonText = _t("common|saving");
|
||||
if (!saving) {
|
||||
buttonText = selectionAllSuggested ? _t("space|unmark_suggested") : _t("space|mark_suggested");
|
||||
}
|
||||
|
||||
const title = !selectedRelations.length ? _t("space|select_room_below") : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AccessibleButton
|
||||
onClick={async (): Promise<void> => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
const userId = cli.getSafeUserId();
|
||||
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);
|
||||
}
|
||||
|
||||
hierarchy.removeRelation(parentId, childId);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("space|failed_remove_rooms"));
|
||||
}
|
||||
setRemoving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
aria-label={removing ? _t("redact|ongoing") : _t("action|remove")}
|
||||
title={title}
|
||||
placement="top"
|
||||
>
|
||||
{removing ? _t("redact|ongoing") : _t("action|remove")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
onClick={async (): Promise<void> => {
|
||||
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: SpaceChildEventContent = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
// 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");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
aria-label={buttonText}
|
||||
title={title}
|
||||
placement="top"
|
||||
>
|
||||
{buttonText}
|
||||
</AccessibleButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SpaceHierarchy: React.FC<IProps> = ({ space, initialText = "", showRoom, additionalButtons }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [query, setQuery] = useState(initialText);
|
||||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const { loading, rooms, hierarchy, loadMore, error: hierarchyError } = useRoomHierarchy(space);
|
||||
|
||||
const filteredRoomSet = useMemo<Set<HierarchyRoom>>(() => {
|
||||
if (!rooms?.length || !hierarchy) return new Set();
|
||||
const lcQuery = query.toLowerCase().trim();
|
||||
if (!lcQuery) return new Set(rooms);
|
||||
|
||||
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)];
|
||||
while (queue.length) {
|
||||
const roomId = queue.pop()!;
|
||||
visited.add(roomId);
|
||||
hierarchy.backRefs.get(roomId)?.forEach((parentId) => {
|
||||
if (!visited.has(parentId)) {
|
||||
queue.push(parentId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Set(rooms.filter((r) => visited.has(r.room_id)));
|
||||
}, [rooms, hierarchy, query]);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
let errorText = error;
|
||||
if (!error && hierarchyError) {
|
||||
errorText = _t("space|failed_load_rooms");
|
||||
}
|
||||
|
||||
const loaderRef = useIntersectionObserver(loadMore);
|
||||
|
||||
if (!loading && hierarchy!.noSupport) {
|
||||
return <p>{_t("space|incompatible_server_hierarchy")}</p>;
|
||||
}
|
||||
|
||||
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
|
||||
state.refs[0]?.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleClick = (parentId: string, childId: string): void => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId)!;
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
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() === KnownMembership.Join &&
|
||||
space.currentState.maySendStateEvent(EventType.SpaceChild, cli.getSafeUserId());
|
||||
|
||||
const root = hierarchy.roomMap.get(space.roomId);
|
||||
let results: JSX.Element | undefined;
|
||||
if (filteredRoomSet.size && root) {
|
||||
results = (
|
||||
<>
|
||||
<HierarchyLevel
|
||||
root={root}
|
||||
roomSet={filteredRoomSet}
|
||||
hierarchy={hierarchy}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? onToggleClick : undefined}
|
||||
onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)}
|
||||
onJoinRoomClick={async (roomId, parents) => {
|
||||
for (const parent of parents) {
|
||||
if (cli.getRoom(parent)?.getMyMembership() !== KnownMembership.Join) {
|
||||
await joinRoom(cli, hierarchy, parent);
|
||||
}
|
||||
}
|
||||
await joinRoom(cli, hierarchy, roomId);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (!hierarchy.canLoadMore) {
|
||||
results = (
|
||||
<div className="mx_SpaceHierarchy_noResults">
|
||||
<h3>{_t("common|no_results_found")}</h3>
|
||||
<div>{_t("space|no_search_result_hint")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let loader: JSX.Element | undefined;
|
||||
if (hierarchy.canLoadMore) {
|
||||
loader = (
|
||||
<div ref={loaderRef}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
content = (
|
||||
<>
|
||||
<div className="mx_SpaceHierarchy_listHeader">
|
||||
<h4 className="mx_SpaceHierarchy_listHeader_header">
|
||||
{query.trim()
|
||||
? _t("space|title_when_query_available")
|
||||
: _t("space|title_when_query_unavailable")}
|
||||
</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("common|space")}
|
||||
>
|
||||
{results}
|
||||
</ul>
|
||||
{loader}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchBox
|
||||
className="mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("space|search_placeholder")}
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
/>
|
||||
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceHierarchy;
|
774
src/components/structures/SpaceRoomView.tsx
Normal file
774
src/components/structures/SpaceRoomView.tsx
Normal file
|
@ -0,0 +1,774 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventType, RoomType, JoinRule, Preset, Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import React, { useCallback, useContext, useRef, useState } from "react";
|
||||
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import createRoom, { IOpts } from "../../createRoom";
|
||||
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import * as Email from "../../email";
|
||||
import { useEventEmitterState } from "../../hooks/useEventEmitter";
|
||||
import { useMyRoomMembership } from "../../hooks/useRoomMembers";
|
||||
import { useFeatureEnabled } from "../../hooks/useSettings";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import { _t } from "../../languageHandler";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite";
|
||||
import { UIComponent } from "../../settings/UIFeature";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
|
||||
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import {
|
||||
shouldShowSpaceInvite,
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceInvite,
|
||||
showSpaceSettings,
|
||||
} from "../../utils/space";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import { BetaPill } from "../views/beta/BetaCard";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import {
|
||||
AddExistingToSpace,
|
||||
defaultDmsRenderer,
|
||||
defaultRoomsRenderer,
|
||||
} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import Field from "../views/elements/Field";
|
||||
import RoomFacePile from "../views/elements/RoomFacePile";
|
||||
import RoomName from "../views/elements/RoomName";
|
||||
import RoomTopic from "../views/elements/RoomTopic";
|
||||
import withValidation from "../views/elements/Validation";
|
||||
import RoomInfoLine from "../views/rooms/RoomInfoLine";
|
||||
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
import MainSplit from "./MainSplit";
|
||||
import RightPanel from "./RightPanel";
|
||||
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
justCreatedOpts?: IOpts;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onJoinButtonClicked(): void;
|
||||
onRejectButtonClicked(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase: Phase;
|
||||
firstRoomId?: string; // internal state for the creation wizard
|
||||
showRightPanel: boolean;
|
||||
myMembership: string;
|
||||
}
|
||||
|
||||
enum Phase {
|
||||
Landing,
|
||||
PublicCreateRooms,
|
||||
PublicShare,
|
||||
PrivateScope,
|
||||
PrivateInvite,
|
||||
PrivateCreateRooms,
|
||||
PrivateExistingRooms,
|
||||
}
|
||||
|
||||
const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
|
||||
const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces);
|
||||
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||
|
||||
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("action|new_room")}
|
||||
iconClassName="mx_RoomList_iconNewRoom"
|
||||
onClick={async (e): Promise<void> => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
PosthogTrackers.trackInteraction("WebSpaceHomeCreateRoomButton", e);
|
||||
if (await showCreateNewRoom(space)) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{videoRoomsEnabled && (
|
||||
<IconizedContextMenuOption
|
||||
label={_t("action|new_video_room")}
|
||||
iconClassName="mx_RoomList_iconNewVideoRoom"
|
||||
onClick={async (e): Promise<void> => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (
|
||||
await showCreateNewRoom(
|
||||
space,
|
||||
elementCallVideoRoomsEnabled
|
||||
? RoomType.UnstableCall
|
||||
: RoomType.ElementVideo,
|
||||
)
|
||||
) {
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<IconizedContextMenuOption
|
||||
label={_t("action|add_existing_room")}
|
||||
iconClassName="mx_RoomList_iconAddExistingRoom"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
showAddExistingRooms(space);
|
||||
}}
|
||||
/>
|
||||
{canCreateSpace && (
|
||||
<IconizedContextMenuOption
|
||||
label={_t("room_list|add_space_label")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
showCreateNewSubspace(space);
|
||||
}}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
)}
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuButton
|
||||
kind="primary"
|
||||
ref={handle}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
label={_t("action|add")}
|
||||
>
|
||||
{_t("action|add")}
|
||||
</ContextMenuButton>
|
||||
{contextMenu}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SpaceLanding: React.FC<{ space: Room }> = ({ space }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
const userId = cli.getSafeUserId();
|
||||
|
||||
const storeIsShowingSpaceMembers = useCallback(
|
||||
() =>
|
||||
RightPanelStore.instance.isOpenForRoom(space.roomId) &&
|
||||
RightPanelStore.instance.currentCardForRoom(space.roomId)?.phase === RightPanelPhases.SpaceMemberList,
|
||||
[space.roomId],
|
||||
);
|
||||
const isShowingMembers = useEventEmitterState(RightPanelStore.instance, UPDATE_EVENT, storeIsShowingSpaceMembers);
|
||||
|
||||
let inviteButton;
|
||||
if (shouldShowSpaceInvite(space) && shouldShowComponent(UIComponent.InviteUsers)) {
|
||||
inviteButton = (
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
className="mx_SpaceRoomView_landing_inviteButton"
|
||||
onClick={() => {
|
||||
showSpaceInvite(space);
|
||||
}}
|
||||
>
|
||||
{_t("action|invite")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAddRoomPermissions =
|
||||
myMembership === KnownMembership.Join && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
let addRoomButton;
|
||||
if (hasAddRoomPermissions) {
|
||||
addRoomButton = <SpaceLandingAddButton space={space} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
if (shouldShowSpaceSettings(space)) {
|
||||
settingsButton = (
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_landing_settingsButton"
|
||||
onClick={() => {
|
||||
showSpaceSettings(space);
|
||||
}}
|
||||
title={_t("common|settings")}
|
||||
placement="bottom"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onMembersClick = (): void => {
|
||||
RightPanelStore.instance.setCard({ phase: RightPanelPhases.SpaceMemberList });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx_SpaceRoomView_landing">
|
||||
<div className="mx_SpaceRoomView_landing_header">
|
||||
<RoomAvatar room={space} size="80px" viewAvatarOnClick={true} type="square" />
|
||||
</div>
|
||||
<div className="mx_SpaceRoomView_landing_name">
|
||||
<RoomName room={space}>
|
||||
{(name) => {
|
||||
const tags = { name: () => <h1>{name}</h1> };
|
||||
return _t("space|landing_welcome", {}, 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>
|
||||
);
|
||||
};
|
||||
|
||||
const SpaceSetupFirstRooms: React.FC<{
|
||||
space: Room;
|
||||
title: string;
|
||||
description: JSX.Element;
|
||||
onFinished(firstRoomId?: string): void;
|
||||
}> = ({ space, title, description, onFinished }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const numFields = 3;
|
||||
const placeholders = [_t("common|general"), _t("common|random"), _t("common|support")];
|
||||
const [roomNames, setRoomName] = useStateArray(numFields, [_t("common|general"), _t("common|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("common|room_name")}
|
||||
placeholder={placeholders[i]}
|
||||
value={roomNames[i]}
|
||||
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => setRoomName(i, ev.target.value)}
|
||||
autoFocus={i === 2}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const onNextClick = async (ev: ButtonEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
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(space.client, {
|
||||
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] ?? undefined);
|
||||
} catch (e) {
|
||||
logger.error("Failed to create initial space rooms", e);
|
||||
setError(_t("create_space|failed_create_initial_rooms"));
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("create_space|skip_action");
|
||||
if (roomNames.some((name) => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
buttonLabel = busy ? _t("create_space|creating_rooms") : _t("action|continue");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
element="input"
|
||||
type="submit"
|
||||
form="mx_SpaceSetupFirstRooms"
|
||||
value={buttonLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SpaceAddExistingRooms: React.FC<{
|
||||
space: Room;
|
||||
onFinished(): void;
|
||||
}> = ({ space, onFinished }) => {
|
||||
return (
|
||||
<div>
|
||||
<h1>{_t("create_space|add_existing_rooms_heading")}</h1>
|
||||
<div className="mx_SpaceRoomView_description">{_t("create_space|add_existing_rooms_description")}</div>
|
||||
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
emptySelectionButton={
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{_t("create_space|skip_action")}
|
||||
</AccessibleButton>
|
||||
}
|
||||
filterPlaceholder={_t("space|room_filter_placeholder")}
|
||||
onFinished={onFinished}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISpaceSetupPublicShareProps extends Pick<IProps & IState, "justCreatedOpts" | "space" | "firstRoomId"> {
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
const SpaceSetupPublicShare: React.FC<ISpaceSetupPublicShareProps> = ({
|
||||
justCreatedOpts,
|
||||
space,
|
||||
onFinished,
|
||||
firstRoomId,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mx_SpaceRoomView_publicShare">
|
||||
<h1>
|
||||
{_t("create_space|share_heading", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
})}
|
||||
</h1>
|
||||
<div className="mx_SpaceRoomView_description">{_t("create_space|share_description")}</div>
|
||||
|
||||
<SpacePublicShare space={space} />
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons">
|
||||
<AccessibleButton kind="primary" onClick={onFinished}>
|
||||
{firstRoomId ? _t("create_space|done_action_first_room") : _t("create_space|done_action")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SpaceSetupPrivateScope: React.FC<{
|
||||
space: Room;
|
||||
justCreatedOpts?: IOpts;
|
||||
onFinished(createRooms: boolean): void;
|
||||
}> = ({ space, justCreatedOpts, onFinished }) => {
|
||||
return (
|
||||
<div className="mx_SpaceRoomView_privateScope">
|
||||
<h1>{_t("create_space|private_personal_heading")}</h1>
|
||||
<div className="mx_SpaceRoomView_description">
|
||||
{_t("create_space|private_personal_description", {
|
||||
name: justCreatedOpts?.createOpts?.name || space.name,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_privateScope_justMeButton"
|
||||
onClick={() => {
|
||||
onFinished(false);
|
||||
}}
|
||||
>
|
||||
{_t("create_space|personal_space")}
|
||||
<div>{_t("create_space|personal_space_description")}</div>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className="mx_SpaceRoomView_privateScope_meAndMyTeammatesButton"
|
||||
onClick={() => {
|
||||
onFinished(true);
|
||||
}}
|
||||
>
|
||||
{_t("create_space|private_space")}
|
||||
<div>{_t("create_space|private_space_description")}</div>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const validateEmailRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("auth|email_field_label_invalid"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const SpaceSetupPrivateInvite: React.FC<{
|
||||
space: Room;
|
||||
onFinished(): void;
|
||||
}> = ({ space, onFinished }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const numFields = 3;
|
||||
const fieldRefs = [useRef<Field>(null), useRef<Field>(null), useRef<Field>(null)];
|
||||
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("common|email_address")}
|
||||
placeholder={_t("auth|email_field_label")}
|
||||
value={emailAddresses[i]}
|
||||
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => setEmailAddress(i, ev.target.value)}
|
||||
ref={fieldRefs[i]}
|
||||
onValidate={validateEmailRules}
|
||||
autoFocus={i === 0}
|
||||
disabled={busy}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const onNextClick = async (ev: ButtonEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
setError("");
|
||||
for (const fieldRef of fieldRefs) {
|
||||
const valid = await fieldRef.current?.validate({ allowEmpty: true });
|
||||
|
||||
if (valid === false) {
|
||||
// true/null are allowed
|
||||
fieldRef.current!.focus();
|
||||
fieldRef.current!.validate({ allowEmpty: true, focused: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
const targetIds = emailAddresses.map((name) => name.trim()).filter(Boolean);
|
||||
try {
|
||||
const result = await inviteMultipleToRoom(space.client, space.roomId, targetIds);
|
||||
|
||||
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("create_space|failed_invite_users", {
|
||||
csvUsers: failedUsers.join(", "),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onFinished();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to invite users to space: ", err);
|
||||
setError(_t("invite|error_invite"));
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
let onClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
};
|
||||
let buttonLabel = _t("create_space|skip_action");
|
||||
if (emailAddresses.some((name) => name.trim())) {
|
||||
onClick = onNextClick;
|
||||
buttonLabel = busy ? _t("create_space|inviting_users") : _t("action|continue");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SpaceRoomView_inviteTeammates">
|
||||
<h1>{_t("create_space|invite_teammates_heading")}</h1>
|
||||
<div className="mx_SpaceRoomView_description">{_t("create_space|invite_teammates_description")}</div>
|
||||
|
||||
{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("create_space|invite_teammates_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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
private readonly dispatcherRef: string;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
|
||||
let phase = Phase.Landing;
|
||||
|
||||
const creator = this.props.space.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender();
|
||||
const showSetup = this.props.justCreatedOpts && context.getSafeUserId() === creator;
|
||||
|
||||
if (showSetup) {
|
||||
phase =
|
||||
this.props.justCreatedOpts!.createOpts?.preset === Preset.PublicChat
|
||||
? Phase.PublicCreateRooms
|
||||
: Phase.PrivateScope;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
phase,
|
||||
showRightPanel: RightPanelStore.instance.isOpenForRoom(this.props.space.roomId),
|
||||
myMembership: this.props.space.getMyMembership(),
|
||||
};
|
||||
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.context.on(RoomEvent.MyMembership, this.onMyMembership);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
||||
this.context.off(RoomEvent.MyMembership, this.onMyMembership);
|
||||
}
|
||||
|
||||
private onMyMembership = (room: Room, myMembership: string): void => {
|
||||
if (room.roomId === this.props.space.roomId) {
|
||||
this.setState({ myMembership });
|
||||
}
|
||||
};
|
||||
|
||||
private onRightPanelStoreUpdate = (): void => {
|
||||
this.setState({
|
||||
showRightPanel: RightPanelStore.instance.isOpenForRoom(this.props.space.roomId),
|
||||
});
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
if (payload.action === Action.ViewRoom && payload.room_id === this.props.space.roomId) {
|
||||
this.setState({ phase: Phase.Landing });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
private goToFirstRoom = async (): Promise<void> => {
|
||||
if (this.state.firstRoomId) {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.state.firstRoomId,
|
||||
metricsTrigger: undefined, // other
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ phase: Phase.Landing });
|
||||
};
|
||||
|
||||
private renderBody(): JSX.Element {
|
||||
switch (this.state.phase) {
|
||||
case Phase.Landing:
|
||||
if (this.state.myMembership === KnownMembership.Join) {
|
||||
return <SpaceLanding space={this.props.space} />;
|
||||
} else {
|
||||
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("create_space|setup_rooms_community_heading", {
|
||||
spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
|
||||
})}
|
||||
description={
|
||||
<>
|
||||
{_t("create_space|setup_rooms_community_description")}
|
||||
<br />
|
||||
{_t("create_space|setup_rooms_description")}
|
||||
</>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
case Phase.PrivateScope:
|
||||
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 })}
|
||||
/>
|
||||
);
|
||||
case Phase.PrivateCreateRooms:
|
||||
return (
|
||||
<SpaceSetupFirstRooms
|
||||
space={this.props.space}
|
||||
title={_t("create_space|setup_rooms_private_heading")}
|
||||
description={
|
||||
<>
|
||||
{_t("create_space|setup_rooms_private_description")}
|
||||
<br />
|
||||
{_t("create_space|setup_rooms_description")}
|
||||
</>
|
||||
}
|
||||
onFinished={(firstRoomId: string) => this.setState({ phase: Phase.PrivateInvite, firstRoomId })}
|
||||
/>
|
||||
);
|
||||
case Phase.PrivateExistingRooms:
|
||||
return (
|
||||
<SpaceAddExistingRooms
|
||||
space={this.props.space}
|
||||
onFinished={() => this.setState({ phase: Phase.Landing })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const rightPanel =
|
||||
this.state.showRightPanel && this.state.phase === Phase.Landing ? (
|
||||
<RightPanel
|
||||
room={this.props.space}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<main className="mx_SpaceRoomView">
|
||||
<ErrorBoundary>
|
||||
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier} analyticsRoomType="space">
|
||||
{this.renderBody()}
|
||||
</MainSplit>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
24
src/components/structures/SplashPage.tsx
Normal file
24
src/components/structures/SplashPage.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface Props extends DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function SplashPage({ children, className, ...other }: Props): JSX.Element {
|
||||
const classes = classNames(className, "mx_SplashPage");
|
||||
return (
|
||||
<main {...other} className={classes}>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
201
src/components/structures/TabbedView.tsx
Normal file
201
src/components/structures/TabbedView.tsx
Normal file
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 , 2024 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2017 Travis Ralston
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t, TranslationKey } from "../../languageHandler";
|
||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { PosthogScreenTracker, ScreenName } from "../../PosthogTrackers";
|
||||
import { NonEmptyArray } from "../../@types/common";
|
||||
import { RovingAccessibleButton, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
|
||||
import { useWindowWidth } from "../../hooks/useWindowWidth";
|
||||
|
||||
/**
|
||||
* Represents a tab for the TabbedView.
|
||||
*/
|
||||
export class Tab<T extends string> {
|
||||
/**
|
||||
* Creates a new tab.
|
||||
* @param {string} id The tab's ID.
|
||||
* @param {string} label The untranslated tab label.
|
||||
* @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} body The JSX for the tab container.
|
||||
* @param {string} screenName The screen name to report to Posthog.
|
||||
*/
|
||||
public constructor(
|
||||
public readonly id: T,
|
||||
public readonly label: TranslationKey,
|
||||
public readonly icon: string | JSX.Element | null,
|
||||
public readonly body: React.ReactNode,
|
||||
public readonly screenName?: ScreenName,
|
||||
) {}
|
||||
}
|
||||
|
||||
export function useActiveTabWithDefault<T extends string>(
|
||||
tabs: NonEmptyArray<Tab<string>>,
|
||||
defaultTabID: T,
|
||||
initialTabID?: T,
|
||||
): [T, (tabId: T) => void] {
|
||||
const [activeTabId, setActiveTabId] = React.useState(
|
||||
initialTabID && tabs.some((t) => t.id === initialTabID) ? initialTabID : defaultTabID,
|
||||
);
|
||||
|
||||
return [activeTabId, setActiveTabId];
|
||||
}
|
||||
|
||||
export enum TabLocation {
|
||||
LEFT = "left",
|
||||
TOP = "top",
|
||||
}
|
||||
|
||||
interface ITabPanelProps<T extends string> {
|
||||
tab: Tab<T>;
|
||||
}
|
||||
|
||||
function domIDForTabID(tabId: string): string {
|
||||
return `mx_tabpanel_${tabId}`;
|
||||
}
|
||||
|
||||
function TabPanel<T extends string>({ tab }: ITabPanelProps<T>): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="mx_TabbedView_tabPanel"
|
||||
key={tab.id}
|
||||
id={domIDForTabID(tab.id)}
|
||||
aria-labelledby={`${domIDForTabID(tab.id)}_label`}
|
||||
>
|
||||
<AutoHideScrollbar className="mx_TabbedView_tabPanelContent">{tab.body}</AutoHideScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ITabLabelProps<T extends string> {
|
||||
tab: Tab<T>;
|
||||
isActive: boolean;
|
||||
showToolip: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function TabLabel<T extends string>({ tab, isActive, showToolip, onClick }: ITabLabelProps<T>): JSX.Element {
|
||||
const classes = classNames("mx_TabbedView_tabLabel", {
|
||||
mx_TabbedView_tabLabel_active: isActive,
|
||||
});
|
||||
|
||||
let tabIcon: JSX.Element | undefined;
|
||||
if (tab.icon) {
|
||||
if (typeof tab.icon === "object") {
|
||||
tabIcon = tab.icon;
|
||||
} else if (typeof tab.icon === "string") {
|
||||
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
|
||||
}
|
||||
}
|
||||
|
||||
const id = domIDForTabID(tab.id);
|
||||
|
||||
const label = _t(tab.label);
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
data-testid={`settings-tab-${tab.id}`}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={id}
|
||||
element="li"
|
||||
title={showToolip ? label : undefined}
|
||||
>
|
||||
{tabIcon}
|
||||
<span className="mx_TabbedView_tabLabel_text" id={`${id}_label`}>
|
||||
{label}
|
||||
</span>
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface IProps<T extends string> {
|
||||
// An array of objects representign tabs that the tabbed view will display.
|
||||
tabs: NonEmptyArray<Tab<T>>;
|
||||
// The ID of the tab to show
|
||||
activeTabId: T;
|
||||
// The location of the tabs, dictating the layout of the TabbedView.
|
||||
tabLocation?: TabLocation;
|
||||
// A callback that is called when the active tab should change
|
||||
onChange: (tabId: T) => void;
|
||||
// The screen name to report to Posthog.
|
||||
screenName?: ScreenName;
|
||||
/**
|
||||
* If true, the layout of the tabbed view will be responsive to the viewport size (eg, just showing icons
|
||||
* instead of names of tabs).
|
||||
* Only applies if `tabLocation === TabLocation.LEFT`.
|
||||
* Default: false.
|
||||
*/
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tabbed view component. Given objects representing content with titles, displays
|
||||
* them in a tabbed view where the user can select which one of the items to view at once.
|
||||
*/
|
||||
export default function TabbedView<T extends string>(props: IProps<T>): JSX.Element {
|
||||
const tabLocation = props.tabLocation ?? TabLocation.LEFT;
|
||||
|
||||
const getTabById = (id: T): Tab<T> | undefined => {
|
||||
return props.tabs.find((tab) => tab.id === id);
|
||||
};
|
||||
|
||||
const windowWidth = useWindowWidth();
|
||||
|
||||
const labels = props.tabs.map((tab) => (
|
||||
<TabLabel
|
||||
key={"tab_label_" + tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === props.activeTabId}
|
||||
onClick={() => props.onChange(tab.id)}
|
||||
// This should be the same as the the CSS breakpoint at which the tab labels are hidden
|
||||
showToolip={windowWidth < 1024 && tabLocation == TabLocation.LEFT}
|
||||
/>
|
||||
));
|
||||
const tab = getTabById(props.activeTabId);
|
||||
const panel = tab ? <TabPanel tab={tab} /> : null;
|
||||
|
||||
const tabbedViewClasses = classNames({
|
||||
mx_TabbedView: true,
|
||||
mx_TabbedView_tabsOnLeft: tabLocation == TabLocation.LEFT,
|
||||
mx_TabbedView_tabsOnTop: tabLocation == TabLocation.TOP,
|
||||
mx_TabbedView_responsive: props.responsive,
|
||||
});
|
||||
|
||||
const screenName = tab?.screenName ?? props.screenName;
|
||||
|
||||
return (
|
||||
<div className={tabbedViewClasses}>
|
||||
{screenName && <PosthogScreenTracker screenName={screenName} />}
|
||||
<RovingTabIndexProvider
|
||||
handleLoop
|
||||
handleHomeEnd
|
||||
handleLeftRight={tabLocation == TabLocation.TOP}
|
||||
handleUpDown={tabLocation == TabLocation.LEFT}
|
||||
>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<ul
|
||||
className="mx_TabbedView_tabLabels"
|
||||
role="tablist"
|
||||
aria-orientation={tabLocation == TabLocation.LEFT ? "vertical" : "horizontal"}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
>
|
||||
{labels}
|
||||
</ul>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
{panel}
|
||||
</div>
|
||||
);
|
||||
}
|
247
src/components/structures/ThreadPanel.tsx
Normal file
247
src/components/structures/ThreadPanel.tsx
Normal file
|
@ -0,0 +1,247 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix";
|
||||
import { IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads";
|
||||
|
||||
import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg";
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
||||
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from "./ContextMenu";
|
||||
import RoomContext, { TimelineRenderingType, useRoomContext } 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 { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import { clearRoomNotification } from "../../utils/notifications";
|
||||
import EmptyState from "../views/right_panel/EmptyState";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
}
|
||||
|
||||
export enum ThreadFilterType {
|
||||
"My",
|
||||
"All",
|
||||
}
|
||||
|
||||
type ThreadPanelHeaderOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
key: ThreadFilterType;
|
||||
};
|
||||
|
||||
export const ThreadPanelHeaderFilterOptionItem: React.FC<
|
||||
ThreadPanelHeaderOption & {
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
}
|
||||
> = ({ label, description, onClick, isSelected }) => {
|
||||
return (
|
||||
<MenuItemRadio active={isSelected} className="mx_ThreadPanel_Header_FilterOptionItem" onClick={onClick}>
|
||||
<span>{label}</span>
|
||||
<span>{description}</span>
|
||||
</MenuItemRadio>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreadPanelHeader: React.FC<{
|
||||
filterOption: ThreadFilterType;
|
||||
setFilterOption: (filterOption: ThreadFilterType) => void;
|
||||
}> = ({ filterOption, setFilterOption }) => {
|
||||
const mxClient = useMatrixClientContext();
|
||||
const roomContext = useRoomContext();
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
|
||||
const options: readonly ThreadPanelHeaderOption[] = [
|
||||
{
|
||||
label: _t("threads|all_threads"),
|
||||
description: _t("threads|all_threads_description"),
|
||||
key: ThreadFilterType.All,
|
||||
},
|
||||
{
|
||||
label: _t("threads|my_threads"),
|
||||
description: _t("threads|my_threads_description"),
|
||||
key: ThreadFilterType.My,
|
||||
},
|
||||
];
|
||||
|
||||
const value = options.find((option) => option.key === filterOption);
|
||||
const contextMenuOptions = options.map((opt) => (
|
||||
<ThreadPanelHeaderFilterOptionItem
|
||||
key={opt.key}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
onClick={() => {
|
||||
setFilterOption(opt.key);
|
||||
closeMenu();
|
||||
}}
|
||||
isSelected={opt === value}
|
||||
/>
|
||||
));
|
||||
const contextMenu = menuDisplayed ? (
|
||||
<ContextMenu
|
||||
top={108}
|
||||
right={33}
|
||||
onFinished={closeMenu}
|
||||
chevronFace={ChevronFace.Top}
|
||||
wrapperClassName="mx_BaseCard_header_title"
|
||||
>
|
||||
{contextMenuOptions}
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
|
||||
const onMarkAllThreadsReadClick = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebThreadsMarkAllReadButton", e);
|
||||
if (!roomContext.room) {
|
||||
logger.error("No room in context to mark all threads read");
|
||||
return;
|
||||
}
|
||||
// This actually clears all room notifications by sending an unthreaded read receipt.
|
||||
// We'd have to loop over all unread threads (pagninating back to find any we don't
|
||||
// know about yet) and send threaded receipts for all of them... or implement a
|
||||
// specific API for it. In practice, the user will have to be viewing the room to
|
||||
// see this button, so will have marked the room itself read anyway.
|
||||
clearRoomNotification(roomContext.room, mxClient).catch((e) => {
|
||||
logger.error("Failed to mark all threads read", e);
|
||||
});
|
||||
},
|
||||
[roomContext.room, mxClient],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_BaseCard_header_title">
|
||||
<Tooltip label={_t("threads|mark_all_read")}>
|
||||
<IconButton onClick={onMarkAllThreadsReadClick} size="24px">
|
||||
<MarkAllThreadsReadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="mx_ThreadPanel_vertical_separator" />
|
||||
<ContextMenuButton
|
||||
className="mx_ThreadPanel_dropdown"
|
||||
ref={button}
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
openMenu();
|
||||
PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev);
|
||||
}}
|
||||
>
|
||||
{`${_t("threads|show_thread_filter")} ${value?.label}`}
|
||||
</ContextMenuButton>
|
||||
{contextMenu}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) => {
|
||||
const mxClient = useContext(MatrixClientContext);
|
||||
const roomContext = useContext(RoomContext);
|
||||
const timelinePanel = useRef<TimelinePanel | null>(null);
|
||||
const card = useRef<HTMLDivElement | null>(null);
|
||||
const closeButonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [narrow, setNarrow] = useState<boolean>(false);
|
||||
|
||||
const timelineSet: Optional<EventTimelineSet> =
|
||||
filterOption === ThreadFilterType.My ? room?.threadsTimelineSets[1] : room?.threadsTimelineSets[0];
|
||||
const hasThreads = Boolean(room?.threadsTimelineSets?.[0]?.getLiveTimeline()?.getEvents()?.length);
|
||||
|
||||
useEffect(() => {
|
||||
const room = mxClient.getRoom(roomId);
|
||||
room
|
||||
?.createThreadsTimelineSets()
|
||||
.then(() => room.fetchRoomThreads())
|
||||
.then(() => {
|
||||
setFilterOption(ThreadFilterType.All);
|
||||
setRoom(room);
|
||||
});
|
||||
}, [mxClient, roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineSet && !Thread.hasServerSideSupport) {
|
||||
timelinePanel.current?.refreshTimeline();
|
||||
}
|
||||
}, [timelineSet, timelinePanel]);
|
||||
|
||||
return (
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
...roomContext,
|
||||
timelineRenderingType: TimelineRenderingType.ThreadsList,
|
||||
showHiddenEvents: true,
|
||||
narrow,
|
||||
}}
|
||||
>
|
||||
<BaseCard
|
||||
header={
|
||||
hasThreads && <ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />
|
||||
}
|
||||
id="thread-panel"
|
||||
className="mx_ThreadPanel"
|
||||
ariaLabelledBy="thread-panel-tab"
|
||||
role="tabpanel"
|
||||
onClose={onClose}
|
||||
withoutScrollContainer={true}
|
||||
ref={card}
|
||||
closeButtonRef={closeButonRef}
|
||||
>
|
||||
{card.current && <Measured sensor={card.current} onMeasurement={setNarrow} />}
|
||||
{timelineSet ? (
|
||||
<TimelinePanel
|
||||
key={filterOption + ":" + (timelineSet.getFilter()?.filterId ?? roomId)}
|
||||
ref={timelinePanel}
|
||||
showReadReceipts={false} // No RR support in thread's list
|
||||
manageReadReceipts={false} // No RR support in thread's list
|
||||
manageReadMarkers={false} // No RM support in thread's list
|
||||
sendReadReceiptOnLoad={false} // No RR support in thread's list
|
||||
timelineSet={timelineSet}
|
||||
showUrlPreview={false} // No URL previews at the threads list level
|
||||
empty={
|
||||
<EmptyState
|
||||
Icon={ThreadsIcon}
|
||||
title={_t("threads|empty_title")}
|
||||
description={_t("threads|empty_description", {
|
||||
replyInThread: _t("action|reply_in_thread"),
|
||||
})}
|
||||
/>
|
||||
}
|
||||
alwaysShowTimestamps={true}
|
||||
layout={Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={false}
|
||||
className="mx_RoomView_messagePanel"
|
||||
membersLoaded={true}
|
||||
permalinkCreator={permalinkCreator}
|
||||
disableGrouping={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx_AutoHideScrollbar">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
};
|
||||
export default ThreadPanel;
|
468
src/components/structures/ThreadView.tsx
Normal file
468
src/components/structures/ThreadView.tsx
Normal file
|
@ -0,0 +1,468 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, KeyboardEvent } from "react";
|
||||
import {
|
||||
Thread,
|
||||
THREAD_RELATION_TYPE,
|
||||
ThreadEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
IEventRelation,
|
||||
MatrixEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import classNames from "classnames";
|
||||
|
||||
import BaseCard from "../views/right_panel/BaseCard";
|
||||
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import MessageComposer from "../views/rooms/MessageComposer";
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import { Layout } from "../../settings/enums/Layout";
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import 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 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 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";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
mxEvent: MatrixEvent;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
e2eStatus?: E2EStatus;
|
||||
initialEvent?: MatrixEvent;
|
||||
isInitialEventHighlighted?: boolean;
|
||||
initialEventScrollIntoView?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
thread?: Thread;
|
||||
lastReply?: MatrixEvent | null;
|
||||
layout: Layout;
|
||||
editState?: EditorStateTransfer;
|
||||
replyToEvent?: MatrixEvent;
|
||||
narrow: boolean;
|
||||
}
|
||||
|
||||
export default class ThreadView extends React.Component<IProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
public declare context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private dispatcherRef: string | null = null;
|
||||
private readonly layoutWatcherRef: string;
|
||||
private timelinePanel = createRef<TimelinePanel>();
|
||||
private card = createRef<HTMLDivElement>();
|
||||
|
||||
// Set by setEventId in ctor.
|
||||
private eventId!: string;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.setEventId(this.props.mxEvent);
|
||||
const thread = this.props.room.getThread(this.eventId) ?? undefined;
|
||||
|
||||
this.setupThreadListeners(thread);
|
||||
this.state = {
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
narrow: false,
|
||||
thread,
|
||||
lastReply: thread?.lastReply((ev: MatrixEvent) => {
|
||||
return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
|
||||
}),
|
||||
};
|
||||
|
||||
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, (...[, , , value]) =>
|
||||
this.setState({ layout: value as Layout }),
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.state.thread) {
|
||||
this.postThreadUpdate(this.state.thread);
|
||||
}
|
||||
|
||||
this.setupThread(this.props.mxEvent);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
this.props.room.on(ThreadEvent.New, this.onNewThread);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
|
||||
const roomId = this.props.mxEvent.getRoomId();
|
||||
SettingsStore.unwatchSetting(this.layoutWatcherRef);
|
||||
|
||||
const hasRoomChanged = SdkContextClass.instance.roomViewStore.getRoomId() !== roomId;
|
||||
if (this.props.initialEvent && !hasRoomChanged) {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.props.room.roomId,
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
}
|
||||
|
||||
dis.dispatch<ThreadPayload>({
|
||||
action: Action.ViewThread,
|
||||
thread_id: null,
|
||||
});
|
||||
|
||||
this.state.thread?.off(ThreadEvent.NewReply, this.updateThreadRelation);
|
||||
this.props.room.off(RoomEvent.LocalEchoUpdated, this.updateThreadRelation);
|
||||
this.props.room.removeListener(ThreadEvent.New, this.onNewThread);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
if (prevProps.mxEvent !== this.props.mxEvent) {
|
||||
this.setEventId(this.props.mxEvent);
|
||||
this.setupThread(this.props.mxEvent);
|
||||
}
|
||||
|
||||
if (prevProps.room !== this.props.room) {
|
||||
RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary });
|
||||
}
|
||||
}
|
||||
|
||||
private setEventId(event: MatrixEvent): void {
|
||||
if (!event.getId()) {
|
||||
throw new Error("Got thread event without id");
|
||||
}
|
||||
|
||||
this.eventId = event.getId()!;
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
|
||||
this.setupThread(payload.event);
|
||||
}
|
||||
switch (payload.action) {
|
||||
case Action.ComposerInsert: {
|
||||
if (payload.composerType) break;
|
||||
if (payload.timelineRenderingType !== TimelineRenderingType.Thread) break;
|
||||
|
||||
// re-dispatch to the correct composer
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
...(payload as ComposerInsertPayload),
|
||||
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case Action.EditEvent:
|
||||
// Quit early if it's not a thread context
|
||||
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) : undefined,
|
||||
},
|
||||
() => {
|
||||
if (payload.event) {
|
||||
this.timelinePanel.current?.scrollToEventIfNeeded(payload.event.getId());
|
||||
}
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "reply_to_event":
|
||||
if (payload.context === TimelineRenderingType.Thread) {
|
||||
this.setState({
|
||||
replyToEvent: payload.event,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private setupThread = (mxEv: MatrixEvent): void => {
|
||||
/** presence of event Id has been ensured by {@link setEventId} */
|
||||
const eventId = mxEv.getId()!;
|
||||
|
||||
let thread = this.props.room.getThread(eventId);
|
||||
|
||||
if (!thread) {
|
||||
const events = [];
|
||||
// if the event is still being sent, don't include it in the Thread yet - otherwise the timeline panel
|
||||
// will attempt to show it twice (once as a regular event, once as a pending event) and everything will
|
||||
// blow up
|
||||
if (mxEv.status === null) events.push(mxEv);
|
||||
thread = this.props.room.createThread(eventId, mxEv, events, true);
|
||||
}
|
||||
|
||||
this.updateThread(thread);
|
||||
};
|
||||
|
||||
private onNewThread = (thread: Thread): void => {
|
||||
if (thread.id === this.props.mxEvent.getId()) {
|
||||
this.setupThread(this.props.mxEvent);
|
||||
}
|
||||
};
|
||||
|
||||
private updateThreadRelation = (): void => {
|
||||
this.setState({
|
||||
lastReply: this.threadLastReply,
|
||||
});
|
||||
};
|
||||
|
||||
private get threadLastReply(): MatrixEvent | undefined {
|
||||
return (
|
||||
this.state.thread?.lastReply((ev: MatrixEvent) => {
|
||||
return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
|
||||
}) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
private updateThread = (thread?: Thread): void => {
|
||||
if (this.state.thread === thread) return;
|
||||
|
||||
this.setupThreadListeners(thread, this.state.thread);
|
||||
if (thread) {
|
||||
this.setState(
|
||||
{
|
||||
thread,
|
||||
lastReply: this.threadLastReply,
|
||||
},
|
||||
async () => this.postThreadUpdate(thread),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private async postThreadUpdate(thread: Thread): Promise<void> {
|
||||
dis.dispatch<ThreadPayload>({
|
||||
action: Action.ViewThread,
|
||||
thread_id: thread.id,
|
||||
});
|
||||
thread.emit(ThreadEvent.ViewThread);
|
||||
this.updateThreadRelation();
|
||||
this.timelinePanel.current?.refreshTimeline(this.props.initialEvent?.getId());
|
||||
}
|
||||
|
||||
private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void {
|
||||
if (oldThread) {
|
||||
this.state.thread?.off(ThreadEvent.NewReply, this.updateThreadRelation);
|
||||
this.props.room.off(RoomEvent.LocalEchoUpdated, this.updateThreadRelation);
|
||||
}
|
||||
if (thread) {
|
||||
thread.on(ThreadEvent.NewReply, this.updateThreadRelation);
|
||||
this.props.room.on(RoomEvent.LocalEchoUpdated, this.updateThreadRelation);
|
||||
}
|
||||
}
|
||||
|
||||
private resetJumpToEvent = (event?: string): void => {
|
||||
if (
|
||||
this.props.initialEvent &&
|
||||
this.props.initialEventScrollIntoView &&
|
||||
event === this.props.initialEvent?.getId()
|
||||
) {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.props.room.roomId,
|
||||
event_id: this.props.initialEvent?.getId(),
|
||||
highlighted: this.props.isInitialEventHighlighted,
|
||||
scroll_into_view: false,
|
||||
replyingToEvent: this.state.replyToEvent,
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onMeasurement = (narrow: boolean): void => {
|
||||
this.setState({ narrow });
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: KeyboardEvent): void => {
|
||||
let handled = false;
|
||||
|
||||
const action = getKeyBindingsManager().getRoomAction(ev);
|
||||
switch (action) {
|
||||
case KeyBindingAction.UploadFile: {
|
||||
dis.dispatch(
|
||||
{
|
||||
action: "upload_file",
|
||||
context: TimelineRenderingType.Thread,
|
||||
},
|
||||
true,
|
||||
);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private onFileDrop = (dataTransfer: DataTransfer): void => {
|
||||
const roomId = this.props.mxEvent.getRoomId();
|
||||
if (roomId) {
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(dataTransfer.files),
|
||||
roomId,
|
||||
this.threadRelation,
|
||||
MatrixClientPeg.safeGet(),
|
||||
TimelineRenderingType.Thread,
|
||||
);
|
||||
} else {
|
||||
console.warn("Unknwon roomId for event", this.props.mxEvent);
|
||||
}
|
||||
};
|
||||
|
||||
private get threadRelation(): IEventRelation {
|
||||
const relation: IEventRelation = {
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
event_id: this.state.thread?.id,
|
||||
is_falling_back: true,
|
||||
};
|
||||
|
||||
const fallbackEventId = this.state.lastReply?.getId() ?? this.state.thread?.id;
|
||||
if (fallbackEventId) {
|
||||
relation["m.in_reply_to"] = {
|
||||
event_id: fallbackEventId,
|
||||
};
|
||||
}
|
||||
|
||||
return relation;
|
||||
}
|
||||
|
||||
private renderThreadViewHeader = (): JSX.Element => {
|
||||
return (
|
||||
<div className="mx_BaseCard_header_title">
|
||||
<Heading size="4" className="mx_BaseCard_header_title_heading">
|
||||
{_t("common|thread")}
|
||||
</Heading>
|
||||
<ThreadListContextMenu mxEvent={this.props.mxEvent} permalinkCreator={this.props.permalinkCreator} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const highlightedEventId = this.props.isInitialEventHighlighted ? this.props.initialEvent?.getId() : undefined;
|
||||
|
||||
const threadRelation = this.threadRelation;
|
||||
|
||||
let timeline: JSX.Element | null;
|
||||
if (this.state.thread) {
|
||||
if (this.props.initialEvent && this.props.initialEvent.getRoomId() !== this.state.thread.roomId) {
|
||||
logger.warn(
|
||||
"ThreadView attempting to render TimelinePanel with mismatched initialEvent",
|
||||
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={this.context.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={true}
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.state.thread.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// ThreadView doesn't support IRC layout at this time
|
||||
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.props.initialEvent?.getId()}
|
||||
highlightedEventId={highlightedEventId}
|
||||
eventScrollIntoView={this.props.initialEventScrollIntoView}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
timeline = (
|
||||
<div className="mx_RoomView_messagePanelSpinner">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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,
|
||||
})}
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
header={this.renderThreadViewHeader()}
|
||||
ref={this.card}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onBack={(ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
||||
}}
|
||||
>
|
||||
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
|
||||
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
2072
src/components/structures/TimelinePanel.tsx
Normal file
2072
src/components/structures/TimelinePanel.tsx
Normal file
File diff suppressed because it is too large
Load diff
99
src/components/structures/ToastContainer.tsx
Normal file
99
src/components/structures/ToastContainer.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import classNames from "classnames";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
|
||||
import ToastStore, { IToast } from "../../stores/ToastStore";
|
||||
|
||||
interface IState {
|
||||
toasts: IToast<any>[];
|
||||
countSeen: number;
|
||||
}
|
||||
|
||||
export default class ToastContainer extends React.Component<{}, IState> {
|
||||
public constructor(props: {}) {
|
||||
super(props);
|
||||
this.state = {
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
countSeen: ToastStore.sharedInstance().getCountSeen(),
|
||||
};
|
||||
|
||||
// Start listening here rather than in componentDidMount because
|
||||
// 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);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
ToastStore.sharedInstance().removeListener("update", this.onToastStoreUpdate);
|
||||
}
|
||||
|
||||
private onToastStoreUpdate = (): void => {
|
||||
this.setState({
|
||||
toasts: ToastStore.sharedInstance().getToasts(),
|
||||
countSeen: ToastStore.sharedInstance().getCountSeen(),
|
||||
});
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const totalCount = this.state.toasts.length;
|
||||
const isStacked = totalCount > 1;
|
||||
let toast;
|
||||
let containerClasses;
|
||||
if (totalCount !== 0) {
|
||||
const topToast = this.state.toasts[0];
|
||||
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_icon_${icon}`]: icon,
|
||||
});
|
||||
const toastProps = Object.assign({}, props, {
|
||||
key,
|
||||
toastKey: key,
|
||||
});
|
||||
const content = React.createElement(component, toastProps);
|
||||
|
||||
let countIndicator;
|
||||
if ((title && isStacked) || this.state.countSeen > 0) {
|
||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||
}
|
||||
|
||||
let titleElement;
|
||||
if (title) {
|
||||
titleElement = (
|
||||
<div className="mx_Toast_title">
|
||||
<Text size="lg" weight="semibold" as="h2">
|
||||
{title}
|
||||
</Text>
|
||||
<span className="mx_Toast_title_countIndicator">{countIndicator}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
toast = (
|
||||
<div className={toastClasses}>
|
||||
{titleElement}
|
||||
<div className={bodyClasses}>{content}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
containerClasses = classNames("mx_ToastContainer", {
|
||||
mx_ToastContainer_stacked: isStacked,
|
||||
});
|
||||
}
|
||||
return toast ? (
|
||||
<div className={containerClasses} role="alert">
|
||||
{toast}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
}
|
126
src/components/structures/UploadBar.tsx
Normal file
126
src/components/structures/UploadBar.tsx
Normal file
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015, 2016 , 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Room, IEventRelation } from "matrix-js-sdk/src/matrix";
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import ContentMessages from "../../ContentMessages";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
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 { UploadPayload } from "../../dispatcher/payloads/UploadPayload";
|
||||
import { fileSize } from "../../utils/FileUtils";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
relation?: IEventRelation;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
currentFile?: string;
|
||||
currentUpload?: RoomUpload;
|
||||
currentLoaded?: number;
|
||||
currentTotal?: number;
|
||||
countFiles: number;
|
||||
}
|
||||
|
||||
function isUploadPayload(payload: ActionPayload): payload is UploadPayload {
|
||||
return [
|
||||
Action.UploadStarted,
|
||||
Action.UploadProgress,
|
||||
Action.UploadFailed,
|
||||
Action.UploadFinished,
|
||||
Action.UploadCanceled,
|
||||
].includes(payload.action as Action);
|
||||
}
|
||||
|
||||
export default class UploadBar extends React.PureComponent<IProps, IState> {
|
||||
private dispatcherRef: Optional<string>;
|
||||
private mounted = false;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Set initial state to any available upload in this room - we might be mounting
|
||||
// earlier than the first progress event, so should show something relevant.
|
||||
this.state = this.calculateState();
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.mounted = false;
|
||||
dis.unregister(this.dispatcherRef!);
|
||||
}
|
||||
|
||||
private getUploadsInRoom(): RoomUpload[] {
|
||||
const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation);
|
||||
return uploads.filter((u) => u.roomId === this.props.room.roomId);
|
||||
}
|
||||
|
||||
private calculateState(): IState {
|
||||
const [currentUpload, ...otherUploads] = this.getUploadsInRoom();
|
||||
return {
|
||||
currentUpload,
|
||||
currentFile: currentUpload?.fileName,
|
||||
currentLoaded: currentUpload?.loaded,
|
||||
currentTotal: currentUpload?.total,
|
||||
countFiles: otherUploads.length + 1,
|
||||
};
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
if (!this.mounted) return;
|
||||
if (isUploadPayload(payload)) {
|
||||
this.setState(this.calculateState());
|
||||
}
|
||||
};
|
||||
|
||||
private onCancelClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload!);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (!this.state.currentFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let uploadText: string;
|
||||
if (this.state.countFiles > 1) {
|
||||
// MUST use var name 'count' for pluralization to kick in
|
||||
uploadText = _t("room|upload|uploading_multiple_file", {
|
||||
filename: this.state.currentFile,
|
||||
count: this.state.countFiles - 1,
|
||||
});
|
||||
} else {
|
||||
uploadText = _t("room|upload|uploading_single_file", {
|
||||
filename: this.state.currentFile,
|
||||
});
|
||||
}
|
||||
|
||||
const uploadSize = fileSize(this.state.currentTotal!);
|
||||
return (
|
||||
<div className="mx_UploadBar">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
477
src/components/structures/UserMenu.tsx
Normal file
477
src/components/structures/UserMenu.tsx
Normal file
|
@ -0,0 +1,477 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, ReactNode } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { ChevronFace, ContextMenuButton, MenuProps } from "./ContextMenu";
|
||||
import { UserTab } from "../views/dialogs/UserTab";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
|
||||
import Modal from "../../Modal";
|
||||
import LogoutDialog, { shouldShowLogoutDialog } from "../views/dialogs/LogoutDialog";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
|
||||
import { RovingAccessibleButton } 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 { SettingLevel } from "../../settings/SettingLevel";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../views/context_menus/IconizedContextMenu";
|
||||
import { UIFeature } from "../../settings/UIFeature";
|
||||
import SpaceStore from "../../stores/spaces/SpaceStore";
|
||||
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
|
||||
import UserIdentifierCustomisations from "../../customisations/UserIdentifier";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
|
||||
import { Icon as LiveIcon } from "../../../res/img/compound/live-8px.svg";
|
||||
import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast";
|
||||
import { SDKContext } from "../../contexts/SDKContext";
|
||||
import { shouldShowFeedback } from "../../utils/Feedback";
|
||||
|
||||
interface IProps {
|
||||
isPanelCollapsed: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
contextMenuPosition: PartialDOMRect | null;
|
||||
isDarkTheme: boolean;
|
||||
isHighContrast: boolean;
|
||||
selectedSpace?: Room | null;
|
||||
showLiveAvatarAddon: boolean;
|
||||
}
|
||||
|
||||
const toRightOf = (rect: PartialDOMRect): MenuProps => {
|
||||
return {
|
||||
left: rect.width + rect.left + 8,
|
||||
top: rect.top,
|
||||
chevronFace: ChevronFace.None,
|
||||
};
|
||||
};
|
||||
|
||||
const below = (rect: PartialDOMRect): MenuProps => {
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top + rect.height,
|
||||
chevronFace: ChevronFace.None,
|
||||
};
|
||||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
public static contextType = SDKContext;
|
||||
public declare context: React.ContextType<typeof SDKContext>;
|
||||
|
||||
private dispatcherRef?: string;
|
||||
private themeWatcherRef?: string;
|
||||
private readonly dndWatcherRef?: string;
|
||||
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
contextMenuPosition: null,
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
isHighContrast: this.isUserOnHighContrastTheme(),
|
||||
selectedSpace: SpaceStore.instance.activeSpaceRoom,
|
||||
showLiveAvatarAddon: this.context.voiceBroadcastRecordingsStore.hasCurrent(),
|
||||
};
|
||||
|
||||
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
|
||||
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
}
|
||||
|
||||
private get hasHomePage(): boolean {
|
||||
return !!getHomePageUrl(SdkConfig.get(), this.context.client!);
|
||||
}
|
||||
|
||||
private onCurrentVoiceBroadcastRecordingChanged = (recording: VoiceBroadcastRecording | null): void => {
|
||||
this.setState({
|
||||
showLiveAvatarAddon: recording !== null,
|
||||
});
|
||||
};
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.context.voiceBroadcastRecordingsStore.on(
|
||||
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
|
||||
this.onCurrentVoiceBroadcastRecordingChanged,
|
||||
);
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
|
||||
if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef);
|
||||
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
|
||||
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
|
||||
this.context.voiceBroadcastRecordingsStore.off(
|
||||
VoiceBroadcastRecordingsStoreEvent.CurrentChanged,
|
||||
this.onCurrentVoiceBroadcastRecordingChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private isUserOnDarkTheme(): boolean {
|
||||
if (SettingsStore.getValue("use_system_theme")) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
} else {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return !!getCustomTheme(theme.substring("custom-".length)).is_dark;
|
||||
}
|
||||
return theme === "dark";
|
||||
}
|
||||
}
|
||||
|
||||
private isUserOnHighContrastTheme(): boolean {
|
||||
if (SettingsStore.getValue("use_system_theme")) {
|
||||
return window.matchMedia("(prefers-contrast: more)").matches;
|
||||
} else {
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme.startsWith("custom-")) {
|
||||
return false;
|
||||
}
|
||||
return isHighContrastTheme(theme);
|
||||
}
|
||||
}
|
||||
|
||||
private onProfileUpdate = async (): Promise<void> => {
|
||||
// the store triggered an update, so force a layout update. We don't
|
||||
// have any state to store here for that to magically happen.
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onSelectedSpaceUpdate = async (): Promise<void> => {
|
||||
this.setState({
|
||||
selectedSpace: SpaceStore.instance.activeSpaceRoom,
|
||||
});
|
||||
};
|
||||
|
||||
private onThemeChanged = (): void => {
|
||||
this.setState({
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
isHighContrast: this.isUserOnHighContrastTheme(),
|
||||
});
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
switch (payload.action) {
|
||||
case Action.ToggleUserMenu:
|
||||
if (this.state.contextMenuPosition) {
|
||||
this.setState({ contextMenuPosition: null });
|
||||
} else {
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({ contextMenuPosition: ev.currentTarget.getBoundingClientRect() });
|
||||
};
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
contextMenuPosition: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
width: 20,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseMenu = (): void => {
|
||||
this.setState({ contextMenuPosition: null });
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
PosthogTrackers.trackInteraction("WebUserMenuThemeToggleButton", ev);
|
||||
|
||||
// Disable system theme matching if the user hits this button
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||
|
||||
let newTheme = this.state.isDarkTheme ? "light" : "dark";
|
||||
if (this.state.isHighContrast) {
|
||||
const hcTheme = findHighContrastTheme(newTheme);
|
||||
if (hcTheme) {
|
||||
newTheme = hcTheme;
|
||||
}
|
||||
}
|
||||
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
|
||||
};
|
||||
|
||||
private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record<string, any>): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId, props };
|
||||
defaultDispatcher.dispatch(payload);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onProvideFeedback = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
Modal.createDialog(FeedbackDialog);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = async (ev: ButtonEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (await shouldShowLogoutDialog(MatrixClientPeg.safeGet())) {
|
||||
Modal.createDialog(LogoutDialog);
|
||||
} else {
|
||||
defaultDispatcher.dispatch({ action: "logout" });
|
||||
}
|
||||
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onSignInClick = (): void => {
|
||||
defaultDispatcher.dispatch({ action: "start_login" });
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onRegisterClick = (): void => {
|
||||
defaultDispatcher.dispatch({ action: "start_registration" });
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onHomeClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
defaultDispatcher.dispatch<ViewHomePagePayload>({ action: Action.ViewHomePage });
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private renderContextMenu = (): React.ReactNode => {
|
||||
if (!this.state.contextMenuPosition) return null;
|
||||
|
||||
let topSection: JSX.Element | undefined;
|
||||
if (MatrixClientPeg.safeGet().isGuest()) {
|
||||
topSection = (
|
||||
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
|
||||
{_t(
|
||||
"auth|sign_in_prompt",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onSignInClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
)}
|
||||
{SettingsStore.getValue(UIFeature.Registration)
|
||||
? _t(
|
||||
"auth|create_account_prompt",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onRegisterClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let homeButton: JSX.Element | undefined;
|
||||
if (this.hasHomePage) {
|
||||
homeButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconHome"
|
||||
label={_t("common|home")}
|
||||
onClick={this.onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let feedbackButton: JSX.Element | undefined;
|
||||
if (shouldShowFeedback()) {
|
||||
feedbackButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("common|feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const linkNewDeviceButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconQr"
|
||||
label={_t("user_menu|link_new_device")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })}
|
||||
/>
|
||||
);
|
||||
|
||||
let primaryOptionList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
{linkNewDeviceButton}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("notifications|enable_prompt_toast_title")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("room_settings|security|title")}
|
||||
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("user_menu|settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e)}
|
||||
/>
|
||||
{feedbackButton}
|
||||
<IconizedContextMenuOption
|
||||
className="mx_IconizedContextMenu_option_red"
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("action|sign_out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
|
||||
if (MatrixClientPeg.safeGet().isGuest()) {
|
||||
primaryOptionList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{homeButton}
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("common|settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e)}
|
||||
/>
|
||||
{feedbackButton}
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
}
|
||||
|
||||
const position = this.props.isPanelCollapsed
|
||||
? 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.safeGet().getSafeUserId(),
|
||||
{
|
||||
withDisplayName: true,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RovingAccessibleButton
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
title={
|
||||
this.state.isDarkTheme
|
||||
? _t("user_menu|switch_theme_light")
|
||||
: _t("user_menu|switch_theme_dark")
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={require("../../../res/img/element-icons/roomlist/dark-light-mode.svg").default}
|
||||
role="presentation"
|
||||
alt=""
|
||||
width={16}
|
||||
/>
|
||||
</RovingAccessibleButton>
|
||||
</div>
|
||||
{topSection}
|
||||
{primaryOptionList}
|
||||
</IconizedContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const avatarSize = 32; // should match border-radius of the avatar
|
||||
|
||||
const userId = MatrixClientPeg.safeGet().getSafeUserId();
|
||||
const displayName = OwnProfileStore.instance.displayName || userId;
|
||||
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
|
||||
|
||||
let name: JSX.Element | undefined;
|
||||
if (!this.props.isPanelCollapsed) {
|
||||
name = <div className="mx_UserMenu_name">{displayName}</div>;
|
||||
}
|
||||
|
||||
const liveAvatarAddon = this.state.showLiveAvatarAddon ? (
|
||||
<div className="mx_UserMenu_userAvatarLive" data-testid="user-menu-live-vb">
|
||||
<LiveIcon className="mx_Icon_8" />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="mx_UserMenu">
|
||||
<ContextMenuButton
|
||||
className="mx_UserMenu_contextMenuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
ref={this.buttonRef}
|
||||
label={_t("a11y|user_menu")}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<div className="mx_UserMenu_userAvatar">
|
||||
<BaseAvatar
|
||||
idName={userId}
|
||||
name={displayName}
|
||||
url={avatarUrl}
|
||||
size={avatarSize + "px"}
|
||||
className="mx_UserMenu_userAvatar_BaseAvatar"
|
||||
/>
|
||||
{liveAvatarAddon}
|
||||
</div>
|
||||
{name}
|
||||
{this.renderContextMenu()}
|
||||
</ContextMenuButton>
|
||||
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
103
src/components/structures/UserView.tsx
Normal file
103
src/components/structures/UserView.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
Copyright 2019-2024 New Vector Ltd.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent, RoomMember, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import Modal from "../../Modal";
|
||||
import { _t } from "../../languageHandler";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import MainSplit from "./MainSplit";
|
||||
import RightPanel from "./RightPanel";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
|
||||
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
userId: string;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loading: boolean;
|
||||
member?: RoomMember;
|
||||
}
|
||||
|
||||
export default class UserView extends React.Component<IProps, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.props.userId) {
|
||||
this.loadProfileInfo();
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
// XXX: We shouldn't need to null check the userId here, but we declare
|
||||
// it as optional and MatrixChat sometimes fires in a way which results
|
||||
// in an NPE when we try to update the profile info.
|
||||
if (prevProps.userId !== this.props.userId && this.props.userId) {
|
||||
this.loadProfileInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadProfileInfo(): Promise<void> {
|
||||
this.setState({ loading: true });
|
||||
let profileInfo: Awaited<ReturnType<MatrixClient["getProfileInfo"]>>;
|
||||
try {
|
||||
profileInfo = await this.context.getProfileInfo(this.props.userId);
|
||||
} catch (err) {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("error_dialog|error_loading_user_profile"),
|
||||
description: err instanceof Error ? err.message : _t("invite|failed_generic"),
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
return;
|
||||
}
|
||||
const fakeEvent = new MatrixEvent({ type: "m.room.member", content: profileInfo });
|
||||
// We pass an empty string room ID here, this is slight abuse of the class to simplify code
|
||||
const member = new RoomMember("", this.props.userId);
|
||||
member.setMembershipEvent(fakeEvent);
|
||||
this.setState({ member, loading: false });
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (this.state.loading) {
|
||||
return <Spinner />;
|
||||
} else if (this.state.member) {
|
||||
const panel = (
|
||||
<RightPanel
|
||||
overwriteCard={{ phase: RightPanelPhases.RoomMemberInfo, state: { member: this.state.member } }}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<MainSplit
|
||||
panel={panel}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
defaultSize={420}
|
||||
analyticsRoomType="user_profile"
|
||||
>
|
||||
<UserOnboardingPage />
|
||||
</MainSplit>
|
||||
);
|
||||
} else {
|
||||
return <div />;
|
||||
}
|
||||
}
|
||||
}
|
179
src/components/structures/ViewSource.tsx
Normal file
179
src/components/structures/ViewSource.tsx
Normal file
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016 , 2019, 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SyntaxHighlight from "../views/elements/SyntaxHighlight";
|
||||
import { _t } from "../../languageHandler";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { canEditContent } from "../../utils/EventUtils";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import BaseDialog from "../views/dialogs/BaseDialog";
|
||||
import { DevtoolsContext } from "../views/dialogs/devtools/BaseTool";
|
||||
import { StateEventEditor } from "../views/dialogs/devtools/RoomState";
|
||||
import { stringify, TimelineEventEditor } from "../views/dialogs/devtools/Event";
|
||||
import CopyableText from "../views/elements/CopyableText";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu
|
||||
ignoreEdits?: boolean;
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export default class ViewSource extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isEditing: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onBack = (): void => {
|
||||
// TODO: refresh the "Event ID:" modal header
|
||||
this.setState({ isEditing: false });
|
||||
};
|
||||
|
||||
private onEdit(): void {
|
||||
this.setState({ isEditing: true });
|
||||
}
|
||||
|
||||
// returns the dialog body for viewing the event source
|
||||
private viewSourceContent(): JSX.Element {
|
||||
let mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
if (this.props.ignoreEdits) {
|
||||
mxEvent = this.props.mxEvent;
|
||||
}
|
||||
|
||||
const isEncrypted = mxEvent.isEncrypted();
|
||||
// @ts-ignore
|
||||
const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private
|
||||
const originalEventSource = mxEvent.event;
|
||||
const copyOriginalFunc = (): string => {
|
||||
return stringify(originalEventSource);
|
||||
};
|
||||
if (isEncrypted) {
|
||||
const copyDecryptedFunc = (): string => {
|
||||
return stringify(decryptedEventSource || {});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<details open className="mx_ViewSource_details">
|
||||
<summary>
|
||||
<span className="mx_ViewSource_heading">
|
||||
{_t("devtools|view_source_decrypted_event_source")}
|
||||
</span>
|
||||
</summary>
|
||||
{decryptedEventSource ? (
|
||||
<CopyableText getTextToCopy={copyDecryptedFunc}>
|
||||
<SyntaxHighlight language="json">{stringify(decryptedEventSource)}</SyntaxHighlight>
|
||||
</CopyableText>
|
||||
) : (
|
||||
<div>{_t("devtools|view_source_decrypted_event_source_unavailable")}</div>
|
||||
)}
|
||||
</details>
|
||||
<details className="mx_ViewSource_details">
|
||||
<summary>
|
||||
<span className="mx_ViewSource_heading">{_t("devtools|original_event_source")}</span>
|
||||
</summary>
|
||||
<CopyableText getTextToCopy={copyOriginalFunc}>
|
||||
<SyntaxHighlight language="json">{stringify(originalEventSource)}</SyntaxHighlight>
|
||||
</CopyableText>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="mx_ViewSource_heading">{_t("devtools|original_event_source")}</div>
|
||||
<CopyableText getTextToCopy={copyOriginalFunc}>
|
||||
<SyntaxHighlight language="json">{stringify(originalEventSource)}</SyntaxHighlight>
|
||||
</CopyableText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// returns the SendCustomEvent component prefilled with the correct details
|
||||
private editSourceContent(): JSX.Element {
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
|
||||
const isStateEvent = mxEvent.isState();
|
||||
const roomId = mxEvent.getRoomId();
|
||||
|
||||
if (isStateEvent) {
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)! }}>
|
||||
<StateEventEditor onBack={this.onBack} mxEvent={mxEvent} />
|
||||
</DevtoolsContext.Provider>
|
||||
)}
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Consumer>
|
||||
{(cli) => (
|
||||
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)! }}>
|
||||
<TimelineEventEditor onBack={this.onBack} mxEvent={mxEvent} />
|
||||
</DevtoolsContext.Provider>
|
||||
)}
|
||||
</MatrixClientContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
private canSendStateEvent(mxEvent: MatrixEvent): boolean {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const room = cli.getRoom(mxEvent.getRoomId());
|
||||
return !!room?.currentState.mayClientSendStateEvent(mxEvent.getType(), cli);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit
|
||||
|
||||
const isEditing = this.state.isEditing;
|
||||
const roomId = mxEvent.getRoomId()!;
|
||||
const eventId = mxEvent.getId()!;
|
||||
const canEdit = mxEvent.isState()
|
||||
? this.canSendStateEvent(mxEvent)
|
||||
: canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
return (
|
||||
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t("action|view_source")}>
|
||||
<div className="mx_ViewSource_header">
|
||||
<CopyableText getTextToCopy={() => roomId} border={false}>
|
||||
{_t("devtools|room_id", { roomId })}
|
||||
</CopyableText>
|
||||
<CopyableText getTextToCopy={() => eventId} border={false}>
|
||||
{_t("devtools|event_id", { eventId })}
|
||||
</CopyableText>
|
||||
{mxEvent.threadRootId && (
|
||||
<CopyableText getTextToCopy={() => mxEvent.threadRootId!} border={false}>
|
||||
{_t("devtools|thread_root_id", {
|
||||
threadRootId: mxEvent.threadRootId,
|
||||
})}
|
||||
</CopyableText>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? this.editSourceContent() : this.viewSourceContent()}
|
||||
{!isEditing && canEdit && (
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={() => this.onEdit()}>{_t("action|edit")}</button>
|
||||
</div>
|
||||
)}
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
58
src/components/structures/WaitingForThirdPartyRoomView.tsx
Normal file
58
src/components/structures/WaitingForThirdPartyRoomView.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { RefObject } from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useRoomContext } from "../../contexts/RoomContext";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||
import RoomHeader from "../views/rooms/RoomHeader";
|
||||
import ScrollPanel from "./ScrollPanel";
|
||||
import EventTileBubble from "../views/messages/EventTileBubble";
|
||||
import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
||||
import { UnwrappedEventTile } from "../views/rooms/EventTile";
|
||||
import { _t } from "../../languageHandler";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
|
||||
interface Props {
|
||||
roomView: RefObject<HTMLElement>;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
inviteEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays a waiting room for an encrypted DM with a third party invite.
|
||||
* If encryption by default is enabled, DMs with a third party invite should be encrypted as well.
|
||||
* To avoid UTDs, users are shown a waiting room until the others have joined.
|
||||
*/
|
||||
export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resizeNotifier, inviteEvent }) => {
|
||||
const context = useRoomContext();
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
return (
|
||||
<div className="mx_RoomView mx_RoomView--local">
|
||||
<ErrorBoundary>
|
||||
<RoomHeader room={context.room!} />
|
||||
<main className="mx_RoomView_body" ref={roomView}>
|
||||
<div className="mx_RoomView_timeline">
|
||||
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={resizeNotifier}>
|
||||
<EventTileBubble
|
||||
className="mx_cryptoEvent mx_cryptoEvent_icon"
|
||||
title={_t("room|waiting_for_join_title", { brand })}
|
||||
subtitle={_t("room|waiting_for_join_subtitle", { brand })}
|
||||
/>
|
||||
<NewRoomIntro />
|
||||
<UnwrappedEventTile mxEvent={inviteEvent} />
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</main>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
114
src/components/structures/auth/CompleteSecurity.tsx
Normal file
114
src/components/structures/auth/CompleteSecurity.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore";
|
||||
import SetupEncryptionBody from "./SetupEncryptionBody";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase?: Phase;
|
||||
lostKeys: boolean;
|
||||
}
|
||||
|
||||
export default class CompleteSecurity extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.on("update", this.onStoreUpdate);
|
||||
store.start();
|
||||
this.state = { phase: store.phase, lostKeys: store.lostKeys() };
|
||||
}
|
||||
|
||||
private onStoreUpdate = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
this.setState({ phase: store.phase, lostKeys: store.lostKeys() });
|
||||
};
|
||||
|
||||
private onSkipClick = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.skip();
|
||||
};
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.off("update", this.onStoreUpdate);
|
||||
store.stop();
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { phase, lostKeys } = this.state;
|
||||
let icon;
|
||||
let title;
|
||||
|
||||
if (phase === Phase.Loading) {
|
||||
return null;
|
||||
} else if (phase === Phase.Intro) {
|
||||
if (lostKeys) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("encryption|verification|after_new_login|unable_to_verify");
|
||||
} else {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("encryption|verification|after_new_login|verify_this_device");
|
||||
}
|
||||
} else if (phase === Phase.Done) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
|
||||
title = _t("encryption|verification|after_new_login|device_verified");
|
||||
} else if (phase === Phase.ConfirmSkip) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("common|are_you_sure");
|
||||
} else if (phase === Phase.Busy) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("encryption|verification|after_new_login|verify_this_device");
|
||||
} else if (phase === Phase.ConfirmReset) {
|
||||
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
|
||||
title = _t("encryption|verification|after_new_login|reset_confirmation");
|
||||
} else if (phase === Phase.Finished) {
|
||||
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
|
||||
} else {
|
||||
throw new Error(`Unknown phase ${phase}`);
|
||||
}
|
||||
|
||||
const forceVerification = SdkConfig.get("force_verification");
|
||||
|
||||
let skipButton;
|
||||
if (!forceVerification && (phase === Phase.Intro || phase === Phase.ConfirmReset)) {
|
||||
skipButton = (
|
||||
<AccessibleButton
|
||||
onClick={this.onSkipClick}
|
||||
className="mx_CompleteSecurity_skip"
|
||||
aria-label={_t("encryption|verification|after_new_login|skip_verification")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<CompleteSecurityBody>
|
||||
<h1 className="mx_CompleteSecurity_header">
|
||||
{icon}
|
||||
{title}
|
||||
{skipButton}
|
||||
</h1>
|
||||
<div className="mx_CompleteSecurity_body">
|
||||
<SetupEncryptionBody onFinished={this.props.onFinished} />
|
||||
</div>
|
||||
</CompleteSecurityBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
|
||||
interface Props {
|
||||
/** Callback which the view will call if the user confirms they want to use this window */
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component shown by {@link MatrixChat} when another session is already active in the same browser and we need to
|
||||
* confirm if we should steal its lock
|
||||
*/
|
||||
export function ConfirmSessionLockTheftView(props: Props): JSX.Element {
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
return (
|
||||
<div className="mx_ConfirmSessionLockTheftView">
|
||||
<div className="mx_ConfirmSessionLockTheftView_body">
|
||||
<p>{_t("error_app_opened_in_another_window", { brand, label: _t("action|continue") })}</p>
|
||||
|
||||
<AccessibleButton kind="primary" onClick={props.onConfirm}>
|
||||
{_t("action|continue")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
35
src/components/structures/auth/E2eSetup.tsx
Normal file
35
src/components/structures/auth/E2eSetup.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
|
||||
import CreateCrossSigningDialog from "../../views/dialogs/security/CreateCrossSigningDialog";
|
||||
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
accountPassword?: string;
|
||||
tokenLogin?: boolean;
|
||||
}
|
||||
|
||||
export default class E2eSetup extends React.Component<IProps> {
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<AuthPage>
|
||||
<CompleteSecurityBody>
|
||||
<CreateCrossSigningDialog
|
||||
onFinished={this.props.onFinished}
|
||||
accountPassword={this.props.accountPassword}
|
||||
tokenLogin={this.props.tokenLogin}
|
||||
/>
|
||||
</CompleteSecurityBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
466
src/components/structures/auth/ForgotPassword.tsx
Normal file
466
src/components/structures/auth/ForgotPassword.tsx
Normal file
|
@ -0,0 +1,466 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2017, 2018 , 2019 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { LockSolidIcon, CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
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 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 QuestionDialog from "../../views/dialogs/QuestionDialog";
|
||||
import { EnterEmail } from "./forgot-password/EnterEmail";
|
||||
import { CheckEmail } from "./forgot-password/CheckEmail";
|
||||
import Field from "../../views/elements/Field";
|
||||
import { ErrorMessage } from "../ErrorMessage";
|
||||
import { VerifyEmailModal } from "./forgot-password/VerifyEmailModal";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import { formatSeconds } from "../../../DateUtils";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
|
||||
const emailCheckInterval = 2000;
|
||||
|
||||
enum Phase {
|
||||
// Show email input
|
||||
EnterEmail = 1,
|
||||
// Email is in the process of being sent
|
||||
SendingEmail = 2,
|
||||
// Email has been sent
|
||||
EmailSent = 3,
|
||||
// Show new password input
|
||||
PasswordInput = 4,
|
||||
// Password is in the process of being reset
|
||||
ResettingPassword = 5,
|
||||
// All done
|
||||
Done = 6,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
onLoginClick: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
phase: Phase;
|
||||
email: string;
|
||||
password: string;
|
||||
password2: string;
|
||||
errorText: string | ReactNode | null;
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverDeadError: string;
|
||||
|
||||
logoutDevices: boolean;
|
||||
}
|
||||
|
||||
export default class ForgotPassword extends React.Component<Props, State> {
|
||||
private reset: PasswordReset;
|
||||
private fieldPassword: Field | null = null;
|
||||
private fieldPasswordConfirm: Field | null = null;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
phase: Phase.EnterEmail,
|
||||
email: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
errorText: null,
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: true,
|
||||
serverDeadError: "",
|
||||
logoutDevices: false,
|
||||
};
|
||||
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<Props>): void {
|
||||
if (
|
||||
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
|
||||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
|
||||
) {
|
||||
// Do a liveliness check on the new URLs
|
||||
this.checkServerLiveliness(this.props.serverConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise<void> {
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(serverConfig.hsUrl, serverConfig.isUrl);
|
||||
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const { serverIsAlive, serverDeadError } = AutoDiscoveryUtils.authComponentStateForError(
|
||||
e,
|
||||
"forgot_password",
|
||||
);
|
||||
this.setState({
|
||||
serverIsAlive,
|
||||
errorText: serverDeadError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async onPhaseEmailInputSubmit(): Promise<void> {
|
||||
this.phase = Phase.SendingEmail;
|
||||
|
||||
if (await this.sendVerificationMail()) {
|
||||
this.phase = Phase.EmailSent;
|
||||
return;
|
||||
}
|
||||
|
||||
this.phase = Phase.EnterEmail;
|
||||
}
|
||||
|
||||
private sendVerificationMail = async (): Promise<boolean> => {
|
||||
try {
|
||||
await this.reset.requestResetToken(this.state.email);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
this.handleError(err);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private handleError(err: any): void {
|
||||
if (err?.httpStatus === 429) {
|
||||
// 429: rate limit
|
||||
const retryAfterMs = parseInt(err?.data?.retry_after_ms, 10);
|
||||
|
||||
const errorText = isNaN(retryAfterMs)
|
||||
? _t("auth|reset_password|rate_limit_error")
|
||||
: _t("auth|reset_password|rate_limit_error_with_time", {
|
||||
timeout: formatSeconds(retryAfterMs / 1000),
|
||||
});
|
||||
|
||||
this.setState({
|
||||
errorText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (err?.name === "ConnectionError") {
|
||||
this.setState({
|
||||
errorText: _t("cannot_reach_homeserver") + ": " + _t("cannot_reach_homeserver_detail"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errorText: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
private async onPhaseEmailSentSubmit(): Promise<void> {
|
||||
this.setState({
|
||||
phase: Phase.PasswordInput,
|
||||
});
|
||||
}
|
||||
|
||||
private set phase(phase: Phase) {
|
||||
this.setState({ phase });
|
||||
}
|
||||
|
||||
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
|
||||
const fieldIdsInDisplayOrder = [this.fieldPassword, this.fieldPasswordConfirm];
|
||||
|
||||
const invalidFields: Field[] = [];
|
||||
|
||||
for (const field of fieldIdsInDisplayOrder) {
|
||||
if (!field) continue;
|
||||
|
||||
const valid = await field.validate({ allowEmpty: false });
|
||||
if (!valid) {
|
||||
invalidFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidFields.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus on the first invalid field, then re-validate,
|
||||
// which will result in the error tooltip being displayed for that field.
|
||||
invalidFields[0].focus();
|
||||
invalidFields[0].validate({ allowEmpty: false, focused: true });
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async onPhasePasswordInputSubmit(): Promise<void> {
|
||||
if (!(await this.verifyFieldsBeforeSubmit())) return;
|
||||
|
||||
if (this.state.logoutDevices) {
|
||||
const logoutDevicesConfirmation = await this.renderConfirmLogoutDevicesDialog();
|
||||
if (!logoutDevicesConfirmation) return;
|
||||
}
|
||||
|
||||
this.phase = Phase.ResettingPassword;
|
||||
this.reset.setLogoutDevices(this.state.logoutDevices);
|
||||
|
||||
try {
|
||||
await this.reset.setNewPassword(this.state.password);
|
||||
this.setState({ phase: Phase.Done });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err.httpStatus !== 401) {
|
||||
// 401 = waiting for email verification, else unknown error
|
||||
this.handleError(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const modal = Modal.createDialog(
|
||||
VerifyEmailModal,
|
||||
{
|
||||
email: this.state.email,
|
||||
errorText: this.state.errorText,
|
||||
onCloseClick: () => {
|
||||
modal.close();
|
||||
this.setState({ phase: Phase.PasswordInput });
|
||||
},
|
||||
onReEnterEmailClick: () => {
|
||||
modal.close();
|
||||
this.setState({ phase: Phase.EnterEmail });
|
||||
},
|
||||
onResendClick: this.sendVerificationMail,
|
||||
},
|
||||
"mx_VerifyEMailDialog",
|
||||
false,
|
||||
false,
|
||||
{
|
||||
onBeforeClose: async (reason?: string): Promise<boolean> => {
|
||||
if (reason === "backgroundClick") {
|
||||
// Modal dismissed by clicking the background.
|
||||
// Go one phase back.
|
||||
this.setState({ phase: Phase.PasswordInput });
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Don't retry if the phase changed. For example when going back to email input.
|
||||
while (this.state.phase === Phase.ResettingPassword) {
|
||||
try {
|
||||
await this.reset.setNewPassword(this.state.password);
|
||||
this.setState({ phase: Phase.Done });
|
||||
modal.close();
|
||||
} catch (e) {
|
||||
// Email not confirmed, yet. Retry after a while.
|
||||
await sleep(emailCheckInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
|
||||
// Should not happen because of disabled forms, but just return if currently doing an action.
|
||||
if ([Phase.SendingEmail, Phase.ResettingPassword].includes(this.state.phase)) return;
|
||||
|
||||
this.setState({
|
||||
errorText: "",
|
||||
});
|
||||
|
||||
// Refresh the server errors. Just in case the server came back online of went offline.
|
||||
await this.checkServerLiveliness(this.props.serverConfig);
|
||||
|
||||
// Server error
|
||||
if (!this.state.serverIsAlive) return;
|
||||
|
||||
switch (this.state.phase) {
|
||||
case Phase.EnterEmail:
|
||||
this.onPhaseEmailInputSubmit();
|
||||
break;
|
||||
case Phase.EmailSent:
|
||||
this.onPhaseEmailSentSubmit();
|
||||
break;
|
||||
case Phase.PasswordInput:
|
||||
this.onPhasePasswordInputSubmit();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private onInputChanged = (
|
||||
stateKey: "email" | "password" | "password2",
|
||||
ev: React.FormEvent<HTMLInputElement>,
|
||||
): void => {
|
||||
let value = ev.currentTarget.value;
|
||||
if (stateKey === "email") value = value.trim();
|
||||
this.setState({
|
||||
[stateKey]: value,
|
||||
} as Pick<State, typeof stateKey>);
|
||||
};
|
||||
|
||||
public renderEnterEmail(): JSX.Element {
|
||||
return (
|
||||
<EnterEmail
|
||||
email={this.state.email}
|
||||
errorText={this.state.errorText}
|
||||
homeserver={this.props.serverConfig.hsName}
|
||||
loading={this.state.phase === Phase.SendingEmail}
|
||||
onInputChanged={this.onInputChanged}
|
||||
onLoginClick={this.props.onLoginClick!} // set by default props
|
||||
onSubmitForm={this.onSubmitForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public async renderConfirmLogoutDevicesDialog(): Promise<boolean> {
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
title: _t("common|warning"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{_t("auth|reset_password|other_devices_logout_warning_1")}</p>
|
||||
<p>{_t("auth|reset_password|other_devices_logout_warning_2")}</p>
|
||||
</div>
|
||||
),
|
||||
button: _t("action|continue"),
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
return !!confirmed;
|
||||
}
|
||||
|
||||
public renderCheckEmail(): JSX.Element {
|
||||
return (
|
||||
<CheckEmail
|
||||
email={this.state.email}
|
||||
errorText={this.state.errorText}
|
||||
onReEnterEmailClick={() => this.setState({ phase: Phase.EnterEmail })}
|
||||
onResendClick={this.sendVerificationMail}
|
||||
onSubmitForm={this.onSubmitForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderSetPassword(): JSX.Element {
|
||||
const submitButtonChild =
|
||||
this.state.phase === Phase.ResettingPassword ? <Spinner w={16} h={16} /> : _t("auth|reset_password_action");
|
||||
|
||||
return (
|
||||
<>
|
||||
<LockSolidIcon className="mx_AuthBody_lockIcon" />
|
||||
<h1>{_t("auth|reset_password_title")}</h1>
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
<fieldset disabled={this.state.phase === Phase.ResettingPassword}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<PassphraseField
|
||||
name="reset_password"
|
||||
type="password"
|
||||
label={_td("auth|change_password_new_label")}
|
||||
value={this.state.password}
|
||||
minScore={PASSWORD_MIN_SCORE}
|
||||
fieldRef={(field) => (this.fieldPassword = field)}
|
||||
onChange={this.onInputChanged.bind(this, "password")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PassphraseConfirmField
|
||||
name="reset_password_confirm"
|
||||
label={_td("auth|reset_password|confirm_new_password")}
|
||||
labelRequired={_td("auth|reset_password|password_not_entered")}
|
||||
labelInvalid={_td("auth|reset_password|passwords_mismatch")}
|
||||
value={this.state.password2}
|
||||
password={this.state.password}
|
||||
fieldRef={(field) => (this.fieldPasswordConfirm = field)}
|
||||
onChange={this.onInputChanged.bind(this, "password2")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<StyledCheckbox
|
||||
onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })}
|
||||
checked={this.state.logoutDevices}
|
||||
>
|
||||
{_t("auth|reset_password|sign_out_other_devices")}
|
||||
</StyledCheckbox>
|
||||
</div>
|
||||
{this.state.errorText && <ErrorMessage message={this.state.errorText} />}
|
||||
<button type="submit" className="mx_Login_submit">
|
||||
{submitButtonChild}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
public renderDone(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<CheckIcon className="mx_Icon mx_Icon_32 mx_Icon_accent" />
|
||||
<h1>{_t("auth|reset_password|reset_successful")}</h1>
|
||||
{this.state.logoutDevices ? <p>{_t("auth|reset_password|devices_logout_success")}</p> : null}
|
||||
<input
|
||||
className="mx_Login_submit"
|
||||
type="button"
|
||||
onClick={this.props.onComplete}
|
||||
value={_t("auth|reset_password|return_to_login")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let resetPasswordJsx: JSX.Element;
|
||||
|
||||
switch (this.state.phase) {
|
||||
case Phase.EnterEmail:
|
||||
case Phase.SendingEmail:
|
||||
resetPasswordJsx = this.renderEnterEmail();
|
||||
break;
|
||||
case Phase.EmailSent:
|
||||
resetPasswordJsx = this.renderCheckEmail();
|
||||
break;
|
||||
case Phase.PasswordInput:
|
||||
case Phase.ResettingPassword:
|
||||
resetPasswordJsx = this.renderSetPassword();
|
||||
break;
|
||||
case Phase.Done:
|
||||
resetPasswordJsx = this.renderDone();
|
||||
break;
|
||||
default:
|
||||
// This should not happen. However, it is logged and the user is sent to the start.
|
||||
logger.warn(`unknown forgot password phase ${this.state.phase}`);
|
||||
this.setState({
|
||||
phase: Phase.EnterEmail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody className="mx_AuthBody_forgot-password">{resetPasswordJsx}</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
562
src/components/structures/auth/Login.tsx
Normal file
562
src/components/structures/auth/Login.tsx
Normal file
|
@ -0,0 +1,562 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t, UserFriendlyError } from "../../../languageHandler";
|
||||
import Login, { ClientLoginFlow, OidcNativeFlow } from "../../../Login";
|
||||
import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { IMatrixClientCreds } from "../../../MatrixClientPeg";
|
||||
import PasswordLogin from "../../views/auth/PasswordLogin";
|
||||
import InlineSpinner from "../../views/elements/InlineSpinner";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
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, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import { Features } from "../../../settings/Settings";
|
||||
import { startOidcLogin } from "../../../utils/oidc/authorize";
|
||||
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
// If true, the component will consider itself busy.
|
||||
busy?: boolean;
|
||||
isSyncing?: boolean;
|
||||
// Secondary HS which we try to log into if the user is using
|
||||
// the default HS but login fails. Useful for migrating to a
|
||||
// different homeserver without confusing users.
|
||||
fallbackHsUrl?: string;
|
||||
defaultDeviceDisplayName?: string;
|
||||
fragmentAfterLogin?: string;
|
||||
defaultUsername?: string;
|
||||
|
||||
// Called when the user has logged in. Params:
|
||||
// - The object returned by the login API
|
||||
// - The user's password, if applicable, (may be cached in memory for a
|
||||
// short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn(data: IMatrixClientCreds, password: string): void;
|
||||
|
||||
// login shouldn't know or care how registration, password recovery, etc is done.
|
||||
onRegisterClick(): void;
|
||||
onForgotPasswordClick?(): void;
|
||||
onServerConfigChange(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
busy: boolean;
|
||||
busyLoggingIn?: boolean;
|
||||
errorText?: ReactNode;
|
||||
loginIncorrect: boolean;
|
||||
// can we attempt to log in or are there validation errors?
|
||||
canTryLogin: boolean;
|
||||
|
||||
flows?: ClientLoginFlow[];
|
||||
|
||||
// used for preserving form values when changing homeserver
|
||||
username: string;
|
||||
phoneCountry: string;
|
||||
phoneNumber: string;
|
||||
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError?: ReactNode;
|
||||
}
|
||||
|
||||
type OnPasswordLogin = {
|
||||
(username: string, phoneCountry: undefined, phoneNumber: undefined, password: string): Promise<void>;
|
||||
(username: undefined, phoneCountry: string, phoneNumber: string, password: string): Promise<void>;
|
||||
};
|
||||
|
||||
/*
|
||||
* A wire component which glues together login UI components and Login logic
|
||||
*/
|
||||
export default class LoginComponent extends React.PureComponent<IProps, IState> {
|
||||
private unmounted = false;
|
||||
private oidcNativeFlowEnabled = false;
|
||||
private loginLogic!: Login;
|
||||
|
||||
private readonly stepRendererMap: Record<string, () => ReactNode>;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// only set on a config level, so we don't need to watch
|
||||
this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
canTryLogin: true,
|
||||
|
||||
username: props.defaultUsername ? props.defaultUsername : "",
|
||||
phoneCountry: "",
|
||||
phoneNumber: "",
|
||||
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
};
|
||||
|
||||
// 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,
|
||||
|
||||
// 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"),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
"m.login.sso": () => this.renderSsoStep("sso"),
|
||||
"oidcNativeFlow": () => this.renderOidcNativeStep(),
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.initLoginLogic(this.props.serverConfig);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
if (
|
||||
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
|
||||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl ||
|
||||
// delegatedAuthentication is only set by buildValidatedConfigFromDiscovery and won't be modified
|
||||
// so shallow comparison is fine
|
||||
prevProps.serverConfig.delegatedAuthentication !== this.props.serverConfig.delegatedAuthentication
|
||||
) {
|
||||
// Ensure that we end up actually logging in to the right place
|
||||
this.initLoginLogic(this.props.serverConfig);
|
||||
}
|
||||
}
|
||||
|
||||
public isBusy = (): boolean => !!this.state.busy || !!this.props.busy;
|
||||
|
||||
public onPasswordLogin: OnPasswordLogin = async (
|
||||
username: string | undefined,
|
||||
phoneCountry: string | undefined,
|
||||
phoneNumber: string | undefined,
|
||||
password: string,
|
||||
): Promise<void> => {
|
||||
if (!this.state.serverIsAlive) {
|
||||
this.setState({ busy: true });
|
||||
// Do a quick liveliness check on the URLs
|
||||
let aliveAgain = true;
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||
this.props.serverConfig.hsUrl,
|
||||
this.props.serverConfig.isUrl,
|
||||
);
|
||||
this.setState({ serverIsAlive: true, errorText: "" });
|
||||
} catch (e) {
|
||||
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
this.setState({
|
||||
busy: false,
|
||||
busyLoggingIn: false,
|
||||
...componentState,
|
||||
});
|
||||
aliveAgain = !componentState.serverErrorIsFatal;
|
||||
}
|
||||
|
||||
// Prevent people from submitting their password when something isn't right.
|
||||
if (!aliveAgain) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
busyLoggingIn: true,
|
||||
errorText: null,
|
||||
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: ReactNode;
|
||||
// Some error strings only apply for logging in
|
||||
if (error.httpStatus === 400 && username && username.indexOf("@") > 0) {
|
||||
errorText = _t("auth|unsupported_auth_email");
|
||||
} else {
|
||||
errorText = messageForLoginError(error, this.props.serverConfig);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
busyLoggingIn: false,
|
||||
errorText,
|
||||
// 401 would be the sensible status code for 'incorrect password'
|
||||
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
|
||||
// mentions this (although the bug is for UI auth which is not this)
|
||||
// We treat both as an incorrect password
|
||||
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
public onUsernameChanged = (username: string): void => {
|
||||
this.setState({ username });
|
||||
};
|
||||
|
||||
public onUsernameBlur = async (username: string): Promise<void> => {
|
||||
const doWellknownLookup = username[0] === "@";
|
||||
this.setState({
|
||||
username: username,
|
||||
busy: doWellknownLookup,
|
||||
errorText: null,
|
||||
canTryLogin: true,
|
||||
});
|
||||
if (doWellknownLookup) {
|
||||
const serverName = username.split(":").slice(1).join(":");
|
||||
try {
|
||||
const result = await AutoDiscoveryUtils.validateServerName(serverName);
|
||||
this.props.onServerConfigChange(result);
|
||||
// We'd like to rely on new props coming in via `onServerConfigChange`
|
||||
// so that we know the servers have definitely updated before clearing
|
||||
// the busy state. In the case of a full MXID that resolves to the same
|
||||
// HS as Element's default HS though, there may not be any server change.
|
||||
// To avoid this trap, we clear busy here. For cases where the server
|
||||
// actually has changed, `initLoginLogic` will be called and manages
|
||||
// busy state for its own liveness check.
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
|
||||
|
||||
let message = _t("auth|failed_homeserver_discovery");
|
||||
if (e instanceof UserFriendlyError && e.translatedMessage) {
|
||||
message = e.translatedMessage;
|
||||
}
|
||||
|
||||
let errorText: ReactNode = message;
|
||||
let discoveryState = {};
|
||||
if (AutoDiscoveryUtils.isLivelinessError(e)) {
|
||||
errorText = this.state.errorText;
|
||||
discoveryState = AutoDiscoveryUtils.authComponentStateForError(e);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText,
|
||||
...discoveryState,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onPhoneCountryChanged = (phoneCountry: string): void => {
|
||||
this.setState({ phoneCountry });
|
||||
};
|
||||
|
||||
public onPhoneNumberChanged = (phoneNumber: string): void => {
|
||||
this.setState({ phoneNumber });
|
||||
};
|
||||
|
||||
public onRegisterClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onRegisterClick();
|
||||
};
|
||||
|
||||
public onTryRegisterClick = (ev: ButtonEvent): void => {
|
||||
const hasPasswordFlow = this.state.flows?.find((flow) => flow.type === "m.login.password");
|
||||
const ssoFlow = this.state.flows?.find((flow) => flow.type === "m.login.sso" || flow.type === "m.login.cas");
|
||||
// If has no password flow but an SSO flow guess that the user wants to register with SSO.
|
||||
// TODO: instead hide the Register button if registration is disabled by checking with the server,
|
||||
// has no specific errCode currently and uses M_FORBIDDEN.
|
||||
if (ssoFlow && !hasPasswordFlow) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const ssoKind = ssoFlow.type === "m.login.sso" ? "sso" : "cas";
|
||||
PlatformPeg.get()?.startSingleSignOn(
|
||||
this.loginLogic.createTemporaryClient(),
|
||||
ssoKind,
|
||||
this.props.fragmentAfterLogin,
|
||||
undefined,
|
||||
SSOAction.REGISTER,
|
||||
);
|
||||
} else {
|
||||
// Don't intercept - just go through to the register page
|
||||
this.onRegisterClick(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private async checkServerLiveliness({
|
||||
hsUrl,
|
||||
isUrl,
|
||||
}: Pick<ValidatedServerConfig, "hsUrl" | "isUrl">): Promise<void> {
|
||||
// Do a quick liveliness check on the URLs
|
||||
try {
|
||||
const { warning } = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
if (warning) {
|
||||
this.setState({
|
||||
...AutoDiscoveryUtils.authComponentStateForError(warning),
|
||||
errorText: "",
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
errorText: "",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e as Error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async initLoginLogic({ hsUrl, isUrl }: ValidatedServerConfig): Promise<void> {
|
||||
let isDefaultServer = false;
|
||||
if (
|
||||
this.props.serverConfig.isDefault &&
|
||||
hsUrl === this.props.serverConfig.hsUrl &&
|
||||
isUrl === this.props.serverConfig.isUrl
|
||||
) {
|
||||
isDefaultServer = true;
|
||||
}
|
||||
|
||||
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl! : null;
|
||||
|
||||
this.setState({
|
||||
busy: true,
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
await this.checkServerLiveliness({ hsUrl, isUrl });
|
||||
|
||||
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
|
||||
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
||||
// if native OIDC is enabled in the client pass the server's delegated auth settings
|
||||
delegatedAuthentication: this.oidcNativeFlowEnabled
|
||||
? this.props.serverConfig.delegatedAuthentication
|
||||
: undefined,
|
||||
});
|
||||
this.loginLogic = loginLogic;
|
||||
|
||||
loginLogic
|
||||
.getFlows()
|
||||
.then(
|
||||
(flows) => {
|
||||
// look for a flow where we understand all of the steps.
|
||||
const supportedFlows = flows.filter(this.isSupportedFlow);
|
||||
|
||||
this.setState({
|
||||
flows: supportedFlows,
|
||||
});
|
||||
|
||||
if (supportedFlows.length === 0) {
|
||||
this.setState({
|
||||
errorText: _t("auth|unsupported_auth"),
|
||||
});
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
this.setState({
|
||||
errorText: messageForConnectionError(err, this.props.serverConfig),
|
||||
loginIncorrect: false,
|
||||
canTryLogin: false,
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isSupportedFlow = (flow: ClientLoginFlow): boolean => {
|
||||
// technically the flow can have multiple steps, but no one does this
|
||||
// for login and loginLogic doesn't support it so we can ignore it.
|
||||
if (!this.stepRendererMap[flow.type]) {
|
||||
logger.log("Skipping flow", flow, "due to unsupported login type", flow.type);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public renderLoginComponentForFlows(): ReactNode {
|
||||
if (!this.state.flows) return null;
|
||||
|
||||
// this is the ideal order we want to show the flows in
|
||||
const order = ["oidcNativeFlow", "m.login.password", "m.login.sso"];
|
||||
|
||||
const flows = filterBoolean(order.map((type) => this.state.flows?.find((flow) => flow.type === type)));
|
||||
return (
|
||||
<React.Fragment>
|
||||
{flows.map((flow) => {
|
||||
const stepRenderer = this.stepRendererMap[flow.type];
|
||||
return <React.Fragment key={flow.type}>{stepRenderer()}</React.Fragment>;
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderPasswordStep = (): JSX.Element => {
|
||||
return (
|
||||
<PasswordLogin
|
||||
onSubmit={this.onPasswordLogin}
|
||||
username={this.state.username}
|
||||
phoneCountry={this.state.phoneCountry}
|
||||
phoneNumber={this.state.phoneNumber}
|
||||
onUsernameChanged={this.onUsernameChanged}
|
||||
onUsernameBlur={this.onUsernameBlur}
|
||||
onPhoneCountryChanged={this.onPhoneCountryChanged}
|
||||
onPhoneNumberChanged={this.onPhoneNumberChanged}
|
||||
onForgotPasswordClick={this.props.onForgotPasswordClick}
|
||||
loginIncorrect={this.state.loginIncorrect}
|
||||
serverConfig={this.props.serverConfig}
|
||||
disableSubmit={this.isBusy()}
|
||||
busy={this.props.isSyncing || this.state.busyLoggingIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderOidcNativeStep = (): React.ReactNode => {
|
||||
const flow = this.state.flows!.find((flow) => flow.type === "oidcNativeFlow")! as OidcNativeFlow;
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_Login_fullWidthButton"
|
||||
kind="primary"
|
||||
onClick={async () => {
|
||||
await startOidcLogin(
|
||||
this.props.serverConfig.delegatedAuthentication!,
|
||||
flow.clientId,
|
||||
this.props.serverConfig.hsUrl,
|
||||
this.props.serverConfig.isUrl,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{_t("action|continue")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
private renderSsoStep = (loginType: "cas" | "sso"): JSX.Element => {
|
||||
const flow = this.state.flows?.find((flow) => flow.type === "m.login." + loginType) as SSOFlow;
|
||||
|
||||
return (
|
||||
<SSOButtons
|
||||
matrixClient={this.loginLogic.createTemporaryClient()}
|
||||
flow={flow}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
primary={!this.state.flows?.find((flow) => flow.type === "m.login.password")}
|
||||
action={SSOAction.LOGIN}
|
||||
disabled={this.isBusy()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const loader =
|
||||
this.isBusy() && !this.state.busyLoggingIn ? (
|
||||
<div className="mx_Login_loader">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const errorText = this.state.errorText;
|
||||
|
||||
let errorTextSection;
|
||||
if (errorText) {
|
||||
errorTextSection = <div className="mx_Login_error">{errorText}</div>;
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
mx_Login_error: true,
|
||||
mx_Login_serverError: true,
|
||||
mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = <div className={classes}>{this.state.serverDeadError}</div>;
|
||||
}
|
||||
|
||||
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("auth|syncing") : _t("auth|signing_in")}
|
||||
</div>
|
||||
{this.props.isSyncing && (
|
||||
<div className="mx_AuthBody_paddedFooter_subtitle">{_t("auth|sync_footer_subtitle")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (SettingsStore.getValue(UIFeature.Registration)) {
|
||||
footer = (
|
||||
<span className="mx_AuthBody_changeFlow">
|
||||
{_t(
|
||||
"auth|create_account_prompt",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onTryRegisterClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
|
||||
<AuthBody>
|
||||
<h1>
|
||||
{_t("action|sign_in")}
|
||||
{loader}
|
||||
</h1>
|
||||
{errorTextSection}
|
||||
{serverDeadSection}
|
||||
<ServerPicker
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
disabled={this.isBusy()}
|
||||
/>
|
||||
{this.renderLoginComponentForFlows()}
|
||||
{footer}
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
80
src/components/structures/auth/LoginSplashView.tsx
Normal file
80
src/components/structures/auth/LoginSplashView.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CryptoEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { messageForSyncError } from "../../../utils/ErrorUtils";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import ProgressBar from "../../views/elements/ProgressBar";
|
||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
interface Props {
|
||||
/** The matrix client which is logging in */
|
||||
matrixClient: MatrixClient;
|
||||
|
||||
/**
|
||||
* A callback function. Will be called if the user clicks the "logout" button on the splash screen.
|
||||
*
|
||||
* @param event - The click event
|
||||
*/
|
||||
onLogoutClick: (event: ButtonEvent) => void;
|
||||
|
||||
/**
|
||||
* Error that caused `/sync` to fail. If set, an error message will be shown on the splash screen.
|
||||
*/
|
||||
syncError: Error | null;
|
||||
}
|
||||
|
||||
type MigrationState = {
|
||||
progress: number;
|
||||
totalSteps: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The view that is displayed after we have logged in, before the first /sync is completed.
|
||||
*/
|
||||
export function LoginSplashView(props: Props): React.JSX.Element {
|
||||
const migrationState = useTypedEventEmitterState(
|
||||
props.matrixClient,
|
||||
CryptoEvent.LegacyCryptoStoreMigrationProgress,
|
||||
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }),
|
||||
);
|
||||
let errorBox: React.JSX.Element | undefined;
|
||||
if (props.syncError) {
|
||||
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
|
||||
}
|
||||
|
||||
// If we are migrating the crypto data, show a progress bar. Otherwise, show a normal spinner.
|
||||
let spinnerOrProgress;
|
||||
if (migrationState.totalSteps !== -1) {
|
||||
spinnerOrProgress = (
|
||||
<div className="mx_LoginSplashView_migrationProgress">
|
||||
<p>{_t("migrating_crypto", { brand: SdkConfig.get().brand })}</p>
|
||||
<ProgressBar value={migrationState.progress} max={migrationState.totalSteps} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
spinnerOrProgress = <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_MatrixChat_splash">
|
||||
{errorBox}
|
||||
{spinnerOrProgress}
|
||||
<div className="mx_LoginSplashView_splashButtons">
|
||||
<AccessibleButton kind="link_inline" onClick={props.onLogoutClick}>
|
||||
{_t("action|logout")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
798
src/components/structures/auth/Registration.tsx
Normal file
798
src/components/structures/auth/Registration.tsx
Normal file
|
@ -0,0 +1,798 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
createClient,
|
||||
IAuthData,
|
||||
AuthDict,
|
||||
IInputs,
|
||||
MatrixError,
|
||||
IRegisterRequestParams,
|
||||
IRequestTokenResponse,
|
||||
MatrixClient,
|
||||
SSOFlow,
|
||||
SSOAction,
|
||||
RegisterResponse,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import React, { Fragment, ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils";
|
||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
||||
import * as Lifecycle from "../../../Lifecycle";
|
||||
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import Login, { OidcNativeFlow } from "../../../Login";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ServerPicker from "../../views/elements/ServerPicker";
|
||||
import RegistrationForm from "../../views/auth/RegistrationForm";
|
||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import AuthBody from "../../views/auth/AuthBody";
|
||||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
import InteractiveAuth, { InteractiveAuthCallback } from "../InteractiveAuth";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import { AuthHeaderDisplay } from "./header/AuthHeaderDisplay";
|
||||
import { AuthHeaderProvider } from "./header/AuthHeaderProvider";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
|
||||
import { Features } from "../../../settings/Settings";
|
||||
import { startOidcLogin } from "../../../utils/oidc/authorize";
|
||||
|
||||
const debuglog = (...args: any[]): void => {
|
||||
if (SettingsStore.getValue("debug_registration")) {
|
||||
logger.log.call(console, "Registration debuglog:", ...args);
|
||||
}
|
||||
};
|
||||
|
||||
export interface MobileRegistrationResponse {
|
||||
user_id: string;
|
||||
home_server: string;
|
||||
access_token: string;
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
serverConfig: ValidatedServerConfig;
|
||||
defaultDeviceDisplayName?: string;
|
||||
email?: string;
|
||||
brand?: string;
|
||||
clientSecret?: string;
|
||||
sessionId?: string;
|
||||
idSid?: string;
|
||||
fragmentAfterLogin?: string;
|
||||
mobileRegister?: boolean;
|
||||
// Called when the user has logged in. Params:
|
||||
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
|
||||
// - The user's password, if available and applicable (may be cached in memory
|
||||
// for a short time so the user is not required to re-enter their password
|
||||
// for operations like uploading cross-signing keys).
|
||||
onLoggedIn(params: IMatrixClientCreds, password: string): Promise<void>;
|
||||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick(): void;
|
||||
onServerConfigChange(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// true if we're waiting for the user to complete
|
||||
busy: boolean;
|
||||
errorText?: ReactNode;
|
||||
// We remember the values entered by the user because
|
||||
// the registration form will be unmounted during the
|
||||
// course of registration, but if there's an error we
|
||||
// want to bring back the registration form with the
|
||||
// values the user entered still in it. We can keep
|
||||
// them in this component's state since this component
|
||||
// persist for the duration of the registration process.
|
||||
formVals: Record<string, string | undefined>;
|
||||
// user-interactive auth
|
||||
// If we've been given a session ID, we're resuming
|
||||
// straight back into UI auth
|
||||
doingUIAuth: boolean;
|
||||
// If set, we've registered but are not going to log
|
||||
// the user in to their new account automatically.
|
||||
completedNoSignin: boolean;
|
||||
flows:
|
||||
| {
|
||||
stages: string[];
|
||||
}[]
|
||||
| null;
|
||||
// We perform liveliness checks later, but for now suppress the errors.
|
||||
// We also track the server dead errors independently of the regular errors so
|
||||
// that we can render it differently, and override any other error the user may
|
||||
// be seeing.
|
||||
serverIsAlive: boolean;
|
||||
serverErrorIsFatal: boolean;
|
||||
serverDeadError?: ReactNode;
|
||||
|
||||
// Our matrix client - part of state because we can't render the UI auth
|
||||
// component without it.
|
||||
matrixClient?: MatrixClient;
|
||||
// The user ID we've just registered
|
||||
registeredUsername?: string;
|
||||
// if a different user ID to the one we just registered is logged in,
|
||||
// this is the user ID that's logged in.
|
||||
differentLoggedInUserId?: string;
|
||||
// the SSO flow definition, this is fetched from /login as that's the only
|
||||
// place it is exposed.
|
||||
ssoFlow?: SSOFlow;
|
||||
// the OIDC native login flow, when supported and enabled
|
||||
// if present, must be used for registration
|
||||
oidcNativeFlow?: OidcNativeFlow;
|
||||
}
|
||||
|
||||
export default class Registration extends React.Component<IProps, IState> {
|
||||
private readonly loginLogic: Login;
|
||||
// `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows
|
||||
private latestServerConfig?: ValidatedServerConfig;
|
||||
// cache value from settings store
|
||||
private oidcNativeFlowEnabled = false;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
busy: false,
|
||||
errorText: null,
|
||||
formVals: {
|
||||
email: this.props.email,
|
||||
},
|
||||
doingUIAuth: Boolean(this.props.sessionId),
|
||||
flows: null,
|
||||
completedNoSignin: false,
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
serverDeadError: "",
|
||||
};
|
||||
|
||||
// only set on a config level, so we don't need to watch
|
||||
this.oidcNativeFlowEnabled = SettingsStore.getValue(Features.OidcNativeFlow);
|
||||
|
||||
const { hsUrl, isUrl, delegatedAuthentication } = this.props.serverConfig;
|
||||
this.loginLogic = new Login(hsUrl, isUrl, null, {
|
||||
defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used
|
||||
// if native OIDC is enabled in the client pass the server's delegated auth settings
|
||||
delegatedAuthentication: this.oidcNativeFlowEnabled ? delegatedAuthentication : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
//triggers a confirmation dialog for data loss before page unloads/refreshes
|
||||
window.addEventListener("beforeunload", this.unloadCallback);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
window.removeEventListener("beforeunload", this.unloadCallback);
|
||||
}
|
||||
|
||||
private unloadCallback = (event: BeforeUnloadEvent): string | undefined => {
|
||||
if (this.state.doingUIAuth) {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
public componentDidUpdate(prevProps: IProps): void {
|
||||
if (
|
||||
prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
|
||||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
|
||||
) {
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceClient(serverConfig: ValidatedServerConfig): Promise<void> {
|
||||
this.latestServerConfig = serverConfig;
|
||||
const { hsUrl, isUrl } = serverConfig;
|
||||
|
||||
this.setState({
|
||||
errorText: null,
|
||||
serverDeadError: null,
|
||||
serverErrorIsFatal: false,
|
||||
// busy while we do live-ness check (we need to avoid trying to render
|
||||
// the UI auth component while we don't have a matrix client)
|
||||
busy: true,
|
||||
});
|
||||
|
||||
// Do a liveliness check on the URLs
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
|
||||
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
serverErrorIsFatal: false,
|
||||
});
|
||||
} catch (e) {
|
||||
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
|
||||
this.setState({
|
||||
busy: false,
|
||||
...AutoDiscoveryUtils.authComponentStateForError(e, "register"),
|
||||
});
|
||||
if (this.state.serverErrorIsFatal) {
|
||||
return; // Server is dead - do not continue.
|
||||
}
|
||||
}
|
||||
|
||||
const cli = createClient({
|
||||
baseUrl: hsUrl,
|
||||
idBaseUrl: isUrl,
|
||||
});
|
||||
|
||||
this.loginLogic.setHomeserverUrl(hsUrl);
|
||||
this.loginLogic.setIdentityServerUrl(isUrl);
|
||||
// if native OIDC is enabled in the client pass the server's delegated auth settings
|
||||
const delegatedAuthentication = this.oidcNativeFlowEnabled ? serverConfig.delegatedAuthentication : undefined;
|
||||
|
||||
this.loginLogic.setDelegatedAuthentication(delegatedAuthentication);
|
||||
|
||||
let ssoFlow: SSOFlow | undefined;
|
||||
let oidcNativeFlow: OidcNativeFlow | undefined;
|
||||
try {
|
||||
const loginFlows = await this.loginLogic.getFlows(true);
|
||||
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
|
||||
ssoFlow = loginFlows.find((f) => f.type === "m.login.sso" || f.type === "m.login.cas") as SSOFlow;
|
||||
oidcNativeFlow = loginFlows.find((f) => f.type === "oidcNativeFlow") as OidcNativeFlow;
|
||||
} catch (e) {
|
||||
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
|
||||
logger.error("Failed to get login flows to check for SSO support", e);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.setState(
|
||||
({ flows }) => ({
|
||||
matrixClient: cli,
|
||||
ssoFlow,
|
||||
oidcNativeFlow,
|
||||
// if we are using oidc native we won't continue with flow discovery on HS
|
||||
// so set an empty array to indicate flows are no longer loading
|
||||
flows: oidcNativeFlow ? [] : flows,
|
||||
busy: false,
|
||||
}),
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
|
||||
// don't need to check with homeserver for login flows
|
||||
// since we are going to use OIDC native flow
|
||||
if (oidcNativeFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// We do the first registration request ourselves to discover whether we need to
|
||||
// do SSO instead. If we've already started the UI Auth process though, we don't
|
||||
// need to.
|
||||
if (!this.state.doingUIAuth) {
|
||||
await this.makeRegisterRequest(null);
|
||||
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
|
||||
// This should never succeed since we specified no auth object.
|
||||
logger.log("Expecting 401 from register request but got success!");
|
||||
}
|
||||
} catch (e) {
|
||||
if (serverConfig !== this.latestServerConfig) return; // discard, serverConfig changed from under us
|
||||
if (e instanceof MatrixError && e.httpStatus === 401) {
|
||||
this.setState({
|
||||
flows: e.data.flows,
|
||||
});
|
||||
} else if (e instanceof MatrixError && (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN")) {
|
||||
// Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN.
|
||||
// At this point registration is pretty much disabled, but before we do that let's
|
||||
// quickly check to see if the server supports SSO instead. If it does, we'll send
|
||||
// the user off to the login page to figure their account out.
|
||||
if (ssoFlow) {
|
||||
// Redirect to login page - server probably expects SSO only
|
||||
dis.dispatch({ action: "start_login" });
|
||||
} else {
|
||||
this.setState({
|
||||
serverErrorIsFatal: true, // fatal because user cannot continue on this server
|
||||
errorText: _t("auth|registration_disabled"),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.log("Unable to query for supported registration methods.", e);
|
||||
this.setState({
|
||||
errorText: _t("auth|failed_query_registration_methods"),
|
||||
// add empty flows array to get rid of spinner
|
||||
flows: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onFormSubmit = async (formVals: Record<string, string>): Promise<void> => {
|
||||
this.setState({
|
||||
errorText: "",
|
||||
busy: true,
|
||||
formVals,
|
||||
doingUIAuth: true,
|
||||
});
|
||||
};
|
||||
|
||||
private requestEmailToken = (
|
||||
emailAddress: string,
|
||||
clientSecret: string,
|
||||
sendAttempt: number,
|
||||
sessionId: string,
|
||||
): Promise<IRequestTokenResponse> => {
|
||||
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
|
||||
return this.state.matrixClient.requestRegisterEmailToken(emailAddress, clientSecret, sendAttempt);
|
||||
};
|
||||
|
||||
private onUIAuthFinished: InteractiveAuthCallback<RegisterResponse> = async (success, response): Promise<void> => {
|
||||
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
|
||||
|
||||
debuglog("Registration: ui authentication finished: ", { success, response });
|
||||
if (!success) {
|
||||
let errorText: ReactNode = (response as Error).message || (response as Error).toString();
|
||||
// can we give a better error message?
|
||||
if (response instanceof MatrixError && response.errcode === "M_RESOURCE_LIMIT_EXCEEDED") {
|
||||
const errorTop = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact,
|
||||
resourceLimitStrings,
|
||||
);
|
||||
const errorDetail = messageForResourceLimitError(
|
||||
response.data.limit_type,
|
||||
response.data.admin_contact,
|
||||
adminContactStrings,
|
||||
);
|
||||
errorText = (
|
||||
<div>
|
||||
<p>{errorTop}</p>
|
||||
<p>{errorDetail}</p>
|
||||
</div>
|
||||
);
|
||||
} else if ((response as IAuthData).flows?.some((flow) => flow.stages.includes(AuthType.Msisdn))) {
|
||||
const flows = (response as IAuthData).flows ?? [];
|
||||
const msisdnAvailable = flows.some((flow) => flow.stages.includes(AuthType.Msisdn));
|
||||
if (!msisdnAvailable) {
|
||||
errorText = _t("auth|unsupported_auth_msisdn");
|
||||
}
|
||||
} else if (response instanceof MatrixError && response.errcode === "M_USER_IN_USE") {
|
||||
errorText = _t("auth|username_in_use");
|
||||
} else if (response instanceof MatrixError && response.errcode === "M_THREEPID_IN_USE") {
|
||||
errorText = _t("auth|3pid_in_use");
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
errorText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (response as RegisterResponse).user_id;
|
||||
const accessToken = (response as RegisterResponse).access_token;
|
||||
if (!userId || !accessToken) throw new Error("Registration failed");
|
||||
|
||||
MatrixClientPeg.setJustRegisteredUserId(userId);
|
||||
|
||||
const newState: Partial<IState> = {
|
||||
doingUIAuth: false,
|
||||
registeredUsername: userId,
|
||||
differentLoggedInUserId: undefined,
|
||||
completedNoSignin: false,
|
||||
// we're still busy until we get unmounted: don't show the registration form again
|
||||
busy: true,
|
||||
};
|
||||
|
||||
// The user came in through an email validation link. To avoid overwriting
|
||||
// their session, check to make sure the session isn't someone else, and
|
||||
// isn't a guest user since we'll usually have set a guest user session before
|
||||
// starting the registration process. This isn't perfect since it's possible
|
||||
// the user had a separate guest session they didn't actually mean to replace.
|
||||
const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner();
|
||||
if (sessionOwner && !sessionIsGuest && sessionOwner !== userId) {
|
||||
logger.log(`Found a session for ${sessionOwner} but ${userId} has just registered.`);
|
||||
newState.differentLoggedInUserId = sessionOwner;
|
||||
}
|
||||
|
||||
// if we don't have an email at all, only one client can be involved in this flow, and we can directly log in.
|
||||
//
|
||||
// if we've got an email, it needs to be verified. in that case, two clients can be involved in this flow, the
|
||||
// original client starting the process and the client that submitted the verification token. After the token
|
||||
// has been submitted, it can not be used again.
|
||||
//
|
||||
// we can distinguish them based on whether the client has form values saved (if so, it's the one that started
|
||||
// the registration), or whether it doesn't have any form values saved (in which case it's the client that
|
||||
// verified the email address)
|
||||
//
|
||||
// as the client that started registration may be gone by the time we've verified the email, and only the client
|
||||
// that verified the email is guaranteed to exist, we'll always do the login in that client.
|
||||
const hasEmail = Boolean(this.state.formVals.email);
|
||||
const hasAccessToken = Boolean(accessToken);
|
||||
debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken });
|
||||
// don’t log in if we found a session for a different user
|
||||
if (hasAccessToken && !newState.differentLoggedInUserId) {
|
||||
if (this.props.mobileRegister) {
|
||||
const mobileResponse: MobileRegistrationResponse = {
|
||||
user_id: userId,
|
||||
home_server: this.state.matrixClient.getHomeserverUrl(),
|
||||
access_token: accessToken,
|
||||
device_id: (response as RegisterResponse).device_id!,
|
||||
};
|
||||
const event = new CustomEvent<MobileRegistrationResponse>("mobileregistrationresponse", {
|
||||
detail: mobileResponse,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
newState.busy = false;
|
||||
newState.completedNoSignin = true;
|
||||
} else {
|
||||
await this.props.onLoggedIn(
|
||||
{
|
||||
userId,
|
||||
deviceId: (response as RegisterResponse).device_id!,
|
||||
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
|
||||
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
|
||||
accessToken,
|
||||
},
|
||||
this.state.formVals.password!,
|
||||
);
|
||||
|
||||
this.setupPushers();
|
||||
}
|
||||
} else {
|
||||
newState.busy = false;
|
||||
newState.completedNoSignin = true;
|
||||
}
|
||||
|
||||
this.setState(newState as IState);
|
||||
};
|
||||
|
||||
private setupPushers(): Promise<void> {
|
||||
if (!this.props.brand) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const matrixClient = MatrixClientPeg.safeGet();
|
||||
return matrixClient.getPushers().then(
|
||||
(resp) => {
|
||||
const pushers = resp.pushers;
|
||||
for (let i = 0; i < pushers.length; ++i) {
|
||||
if (pushers[i].kind === "email") {
|
||||
const emailPusher = pushers[i];
|
||||
emailPusher.data = { brand: this.props.brand };
|
||||
matrixClient.setPusher(emailPusher).then(
|
||||
() => {
|
||||
logger.log("Set email branding to " + this.props.brand);
|
||||
},
|
||||
(error) => {
|
||||
logger.error("Couldn't set email branding: " + error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
logger.error("Couldn't get pushers: " + error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private onLoginClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onLoginClick();
|
||||
};
|
||||
|
||||
private onGoToFormClicked = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.replaceClient(this.props.serverConfig);
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
private makeRegisterRequest = (auth: AuthDict | null): Promise<RegisterResponse> => {
|
||||
if (!this.state.matrixClient) throw new Error("Matrix client has not yet been loaded");
|
||||
|
||||
const registerParams: IRegisterRequestParams = {
|
||||
username: this.state.formVals.username,
|
||||
password: this.state.formVals.password,
|
||||
initial_device_display_name: this.props.defaultDeviceDisplayName,
|
||||
auth: undefined,
|
||||
// we still want to avoid the race conditions involved with multiple clients handling registration, but
|
||||
// we'll handle these after we've received the access_token in onUIAuthFinished
|
||||
inhibit_login: undefined,
|
||||
};
|
||||
if (auth) registerParams.auth = auth;
|
||||
debuglog("Registration: sending registration request:", auth);
|
||||
return this.state.matrixClient.registerRequest(registerParams);
|
||||
};
|
||||
|
||||
private getUIAuthInputs(): IInputs {
|
||||
return {
|
||||
emailAddress: this.state.formVals.email,
|
||||
phoneCountry: this.state.formVals.phoneCountry,
|
||||
phoneNumber: this.state.formVals.phoneNumber,
|
||||
};
|
||||
}
|
||||
|
||||
// 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: ButtonEvent): Promise<boolean> => {
|
||||
ev.preventDefault();
|
||||
|
||||
const sessionLoaded = await Lifecycle.loadSession({ ignoreGuest: true });
|
||||
if (!sessionLoaded) {
|
||||
// ok fine, there's still no session: really go to the login page
|
||||
this.props.onLoginClick();
|
||||
}
|
||||
|
||||
return sessionLoaded;
|
||||
};
|
||||
|
||||
private renderRegisterComponent(): ReactNode {
|
||||
if (this.state.matrixClient && this.state.doingUIAuth) {
|
||||
return (
|
||||
<InteractiveAuth
|
||||
matrixClient={this.state.matrixClient}
|
||||
makeRequest={this.makeRegisterRequest}
|
||||
onAuthFinished={this.onUIAuthFinished}
|
||||
inputs={this.getUIAuthInputs()}
|
||||
requestEmailToken={this.requestEmailToken}
|
||||
sessionId={this.props.sessionId}
|
||||
clientSecret={this.props.clientSecret}
|
||||
emailSid={this.props.idSid}
|
||||
poll={true}
|
||||
/>
|
||||
);
|
||||
} else if (!this.state.matrixClient && !this.state.busy) {
|
||||
return null;
|
||||
} else if (this.state.busy || !this.state.flows) {
|
||||
return (
|
||||
<div className="mx_AuthBody_spinner">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.matrixClient && this.state.oidcNativeFlow) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_Login_fullWidthButton"
|
||||
kind="primary"
|
||||
onClick={async () => {
|
||||
await startOidcLogin(
|
||||
this.props.serverConfig.delegatedAuthentication!,
|
||||
this.state.oidcNativeFlow!.clientId,
|
||||
this.props.serverConfig.hsUrl,
|
||||
this.props.serverConfig.isUrl,
|
||||
true /* isRegistration */,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{_t("action|continue")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (this.state.matrixClient && this.state.flows.length) {
|
||||
let ssoSection: JSX.Element | undefined;
|
||||
if (!this.props.mobileRegister && this.state.ssoFlow) {
|
||||
let continueWithSection;
|
||||
const providers = this.state.ssoFlow.identity_providers || [];
|
||||
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text
|
||||
if (providers.length > 1) {
|
||||
// i18n: ssoButtons is a placeholder to help translators understand context
|
||||
continueWithSection = (
|
||||
<h2 className="mx_AuthBody_centered">
|
||||
{_t("auth|continue_with_sso", { ssoButtons: "" }).trim()}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
// i18n: ssoButtons & usernamePassword are placeholders to help translators understand context
|
||||
ssoSection = (
|
||||
<React.Fragment>
|
||||
{continueWithSection}
|
||||
<SSOButtons
|
||||
matrixClient={this.loginLogic.createTemporaryClient()}
|
||||
flow={this.state.ssoFlow}
|
||||
loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
action={SSOAction.REGISTER}
|
||||
/>
|
||||
<h2 className="mx_AuthBody_centered">
|
||||
{_t("auth|sso_or_username_password", {
|
||||
ssoButtons: "",
|
||||
usernamePassword: "",
|
||||
}).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}
|
||||
mobileRegister={this.props.mobileRegister}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let errorText;
|
||||
const err = this.state.errorText;
|
||||
if (err) {
|
||||
errorText = <div className="mx_Login_error">{err}</div>;
|
||||
}
|
||||
|
||||
let serverDeadSection;
|
||||
if (!this.state.serverIsAlive) {
|
||||
const classes = classNames({
|
||||
mx_Login_error: true,
|
||||
mx_Login_serverError: true,
|
||||
mx_Login_serverErrorNonFatal: !this.state.serverErrorIsFatal,
|
||||
});
|
||||
serverDeadSection = <div className={classes}>{this.state.serverDeadError}</div>;
|
||||
}
|
||||
|
||||
const signIn = (
|
||||
<span className="mx_AuthBody_changeFlow">
|
||||
{_t(
|
||||
"auth|sign_in_instead_prompt",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onLoginClick}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Only show the 'go back' button if you're not looking at the form
|
||||
let goBack;
|
||||
if (this.state.doingUIAuth) {
|
||||
goBack = (
|
||||
<AccessibleButton kind="link" className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked}>
|
||||
{_t("action|go_back")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let body;
|
||||
if (this.state.completedNoSignin) {
|
||||
let regDoneText;
|
||||
if (this.props.mobileRegister) {
|
||||
regDoneText = undefined;
|
||||
} else if (this.state.differentLoggedInUserId) {
|
||||
regDoneText = (
|
||||
<div>
|
||||
<p>
|
||||
{_t("auth|account_clash", {
|
||||
newAccountId: this.state.registeredUsername,
|
||||
loggedInUserId: this.state.differentLoggedInUserId,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
<AccessibleButton
|
||||
kind="link_inline"
|
||||
onClick={async (event: ButtonEvent): Promise<void> => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_welcome_page" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{_t("auth|account_clash_previous_account")}
|
||||
</AccessibleButton>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// regardless of whether we're the client that started the registration or not, we should
|
||||
// try our credentials anyway
|
||||
regDoneText = (
|
||||
<h2>
|
||||
{_t(
|
||||
"auth|log_in_new_account",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton
|
||||
kind="link_inline"
|
||||
onClick={async (event: ButtonEvent): Promise<void> => {
|
||||
const sessionLoaded = await this.onLoginClickWithCheck(event);
|
||||
if (sessionLoaded) {
|
||||
dis.dispatch({ action: "view_home_page" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
body = (
|
||||
<div>
|
||||
<h1>{_t("auth|registration_successful")}</h1>
|
||||
{regDoneText}
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.mobileRegister) {
|
||||
body = (
|
||||
<Fragment>
|
||||
<h1>{_t("auth|mobile_create_account_title", { hsName: this.props.serverConfig.hsName })}</h1>
|
||||
{errorText}
|
||||
{serverDeadSection}
|
||||
{this.renderRegisterComponent()}
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<Fragment>
|
||||
<div className="mx_Register_mainContent">
|
||||
<AuthHeaderDisplay
|
||||
title={_t("auth|create_account_title")}
|
||||
serverPicker={
|
||||
<ServerPicker
|
||||
title={_t("auth|server_picker_title_registration")}
|
||||
dialogTitle={_t("auth|server_picker_dialog_title")}
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={
|
||||
this.state.doingUIAuth ? undefined : this.props.onServerConfigChange
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{errorText}
|
||||
{serverDeadSection}
|
||||
</AuthHeaderDisplay>
|
||||
{this.renderRegisterComponent()}
|
||||
</div>
|
||||
<div className="mx_Register_footerActions">
|
||||
{goBack}
|
||||
{signIn}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
if (this.props.mobileRegister) {
|
||||
return (
|
||||
<div className="mx_MobileRegister_body" data-testid="mobile-register">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthHeaderProvider>
|
||||
<AuthBody flex>{body}</AuthBody>
|
||||
</AuthHeaderProvider>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
27
src/components/structures/auth/SessionLockStolenView.tsx
Normal file
27
src/components/structures/auth/SessionLockStolenView.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import SplashPage from "../SplashPage";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
/**
|
||||
* Component shown by {@link MatrixChat} when another session is started in the same browser.
|
||||
*/
|
||||
export function SessionLockStolenView(): JSX.Element {
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
return (
|
||||
<SplashPage className="mx_SessionLockStolenView">
|
||||
<h1>{_t("error_app_open_in_another_tab_title", { brand })}</h1>
|
||||
<h2>{_t("error_app_open_in_another_tab", { brand })}</h2>
|
||||
</SplashPage>
|
||||
);
|
||||
}
|
267
src/components/structures/auth/SetupEncryptionBody.tsx
Normal file
267
src/components/structures/auth/SetupEncryptionBody.tsx
Normal file
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { KeyBackupInfo, VerificationRequest } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage";
|
||||
|
||||
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, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
|
||||
function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean {
|
||||
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
onFinished: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
phase?: Phase;
|
||||
verificationRequest: VerificationRequest | null;
|
||||
backupInfo: KeyBackupInfo | null;
|
||||
lostKeys: boolean;
|
||||
}
|
||||
|
||||
export default class SetupEncryptionBody extends React.Component<IProps, IState> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.on("update", this.onStoreUpdate);
|
||||
store.start();
|
||||
this.state = {
|
||||
phase: store.phase,
|
||||
// this serves dual purpose as the object for the request logic and
|
||||
// the presence of it indicating that we're in 'verify mode'.
|
||||
// Because of the latter, it lives in the state.
|
||||
verificationRequest: store.verificationRequest,
|
||||
backupInfo: store.backupInfo,
|
||||
lostKeys: store.lostKeys(),
|
||||
};
|
||||
}
|
||||
|
||||
private onStoreUpdate = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
if (store.phase === Phase.Finished) {
|
||||
this.props.onFinished();
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
phase: store.phase,
|
||||
verificationRequest: store.verificationRequest,
|
||||
backupInfo: store.backupInfo,
|
||||
lostKeys: store.lostKeys(),
|
||||
});
|
||||
};
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.off("update", this.onStoreUpdate);
|
||||
store.stop();
|
||||
}
|
||||
|
||||
private onUsePassphraseClick = async (): Promise<void> => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.usePassPhrase();
|
||||
};
|
||||
|
||||
private onVerifyClick = (): void => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const userId = cli.getSafeUserId();
|
||||
const requestPromise = cli.getCrypto()!.requestOwnUserVerification();
|
||||
|
||||
// We need to call onFinished now to close this dialog, and
|
||||
// again later to signal that the verification is complete.
|
||||
this.props.onFinished();
|
||||
Modal.createDialog(VerificationRequestDialog, {
|
||||
verificationRequestPromise: requestPromise,
|
||||
member: cli.getUser(userId) ?? undefined,
|
||||
onFinished: async (): Promise<void> => {
|
||||
const request = await requestPromise;
|
||||
request.cancel();
|
||||
this.props.onFinished();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onSkipConfirmClick = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.skipConfirm();
|
||||
};
|
||||
|
||||
private onSkipBackClick = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.returnAfterSkip();
|
||||
};
|
||||
|
||||
private onResetClick = (ev: ButtonEvent): void => {
|
||||
ev.preventDefault();
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.reset();
|
||||
};
|
||||
|
||||
private onResetConfirmClick = (): void => {
|
||||
this.props.onFinished();
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.resetConfirm();
|
||||
};
|
||||
|
||||
private onResetBackClick = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.returnAfterReset();
|
||||
};
|
||||
|
||||
private onDoneClick = (): void => {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
store.done();
|
||||
};
|
||||
|
||||
private onEncryptionPanelClose = (): void => {
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const { phase, lostKeys } = this.state;
|
||||
|
||||
if (this.state.verificationRequest && cli.getUser(this.state.verificationRequest.otherUserId)) {
|
||||
return (
|
||||
<EncryptionPanel
|
||||
layout="dialog"
|
||||
verificationRequest={this.state.verificationRequest}
|
||||
onClose={this.onEncryptionPanelClose}
|
||||
member={cli.getUser(this.state.verificationRequest.otherUserId)!}
|
||||
isRoomEncrypted={false}
|
||||
/>
|
||||
);
|
||||
} else if (phase === Phase.Intro) {
|
||||
if (lostKeys) {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t("encryption|verification|no_key_or_device")}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
|
||||
{_t("encryption|verification|reset_proceed_prompt")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const store = SetupEncryptionStore.sharedInstance();
|
||||
let recoveryKeyPrompt;
|
||||
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
|
||||
recoveryKeyPrompt = _t("encryption|verification|verify_using_key_or_phrase");
|
||||
} else if (store.keyInfo) {
|
||||
recoveryKeyPrompt = _t("encryption|verification|verify_using_key");
|
||||
}
|
||||
|
||||
let useRecoveryKeyButton;
|
||||
if (recoveryKeyPrompt) {
|
||||
useRecoveryKeyButton = (
|
||||
<AccessibleButton kind="primary" onClick={this.onUsePassphraseClick}>
|
||||
{recoveryKeyPrompt}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let verifyButton;
|
||||
if (store.hasDevicesToVerifyAgainst) {
|
||||
verifyButton = (
|
||||
<AccessibleButton kind="primary" onClick={this.onVerifyClick}>
|
||||
{_t("encryption|verification|verify_using_device")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{_t("encryption|verification|verification_description")}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
{verifyButton}
|
||||
{useRecoveryKeyButton}
|
||||
</div>
|
||||
<div className="mx_SetupEncryptionBody_reset">
|
||||
{_t("encryption|reset_all_button", undefined, {
|
||||
a: (sub) => (
|
||||
<AccessibleButton
|
||||
kind="link_inline"
|
||||
className="mx_SetupEncryptionBody_reset_link"
|
||||
onClick={this.onResetClick}
|
||||
>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (phase === Phase.Done) {
|
||||
let message: JSX.Element;
|
||||
if (this.state.backupInfo) {
|
||||
message = <p>{_t("encryption|verification|verification_success_with_backup")}</p>;
|
||||
} else {
|
||||
message = <p>{_t("encryption|verification|verification_success_without_backup")}</p>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_CompleteSecurity_heroIcon mx_E2EIcon_verified" />
|
||||
{message}
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="primary" onClick={this.onDoneClick}>
|
||||
{_t("action|done")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === Phase.ConfirmSkip) {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t("encryption|verification|verification_skip_warning")}</p>
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="danger_outline" onClick={this.onSkipConfirmClick}>
|
||||
{_t("encryption|verification|verify_later")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={this.onSkipBackClick}>
|
||||
{_t("action|go_back")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === Phase.ConfirmReset) {
|
||||
return (
|
||||
<div>
|
||||
<p>{_t("encryption|verification|verify_reset_warning_1")}</p>
|
||||
<p>{_t("encryption|verification|verify_reset_warning_2")}</p>
|
||||
|
||||
<div className="mx_CompleteSecurity_actionRow">
|
||||
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
|
||||
{_t("encryption|verification|reset_proceed_prompt")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
|
||||
{_t("action|go_back")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (phase === Phase.Busy || phase === Phase.Loading) {
|
||||
return <Spinner />;
|
||||
} else {
|
||||
logger.log(`SetupEncryptionBody: Unknown phase ${phase}`);
|
||||
}
|
||||
}
|
||||
}
|
330
src/components/structures/auth/SoftLogout.tsx
Normal file
330
src/components/structures/auth/SoftLogout.tsx
Normal file
|
@ -0,0 +1,330 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent, SyntheticEvent } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
import { LoginFlow, MatrixError, SSOAction, SSOFlow } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import * as Lifecycle from "../../../Lifecycle";
|
||||
import Modal from "../../../Modal";
|
||||
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { sendLoginRequest } from "../../../Login";
|
||||
import AuthPage from "../../views/auth/AuthPage";
|
||||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform";
|
||||
import SSOButtons from "../../views/elements/SSOButtons";
|
||||
import ConfirmWipeDeviceDialog from "../../views/dialogs/ConfirmWipeDeviceDialog";
|
||||
import Field from "../../views/elements/Field";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import Spinner from "../../views/elements/Spinner";
|
||||
import AuthHeader from "../../views/auth/AuthHeader";
|
||||
import AuthBody from "../../views/auth/AuthBody";
|
||||
import { SDKContext } from "../../../contexts/SDKContext";
|
||||
|
||||
enum LoginView {
|
||||
Loading,
|
||||
Password,
|
||||
CAS, // SSO, but old
|
||||
SSO,
|
||||
PasswordWithSocialSignOn,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
const STATIC_FLOWS_TO_VIEWS: Record<string, LoginView> = {
|
||||
"m.login.password": LoginView.Password,
|
||||
"m.login.cas": LoginView.CAS,
|
||||
"m.login.sso": LoginView.SSO,
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
// Query parameters from MatrixChat
|
||||
realQueryParams: {
|
||||
loginToken?: string;
|
||||
};
|
||||
fragmentAfterLogin?: string;
|
||||
|
||||
// Called when the SSO login completes
|
||||
onTokenLoginCompleted: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
loginView: LoginView;
|
||||
busy: boolean;
|
||||
password: string;
|
||||
errorText: string;
|
||||
flows: LoginFlow[];
|
||||
}
|
||||
|
||||
export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
public static contextType = SDKContext;
|
||||
public declare context: React.ContextType<typeof SDKContext>;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
loginView: LoginView.Loading,
|
||||
busy: false,
|
||||
password: "",
|
||||
errorText: "",
|
||||
flows: [],
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
// We've ended up here when we don't need to - navigate to login
|
||||
if (!Lifecycle.isSoftLogout()) {
|
||||
dis.dispatch({ action: "start_login" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.initLogin();
|
||||
}
|
||||
|
||||
private onClearAll = (): void => {
|
||||
Modal.createDialog(ConfirmWipeDeviceDialog, {
|
||||
onFinished: (wipeData) => {
|
||||
if (!wipeData) return;
|
||||
|
||||
logger.log("Clearing data from soft-logged-out session");
|
||||
Lifecycle.logout(this.context.oidcClientStore);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async initLogin(): Promise<void> {
|
||||
const queryParams = this.props.realQueryParams;
|
||||
const hasAllParams = queryParams?.["loginToken"];
|
||||
if (hasAllParams) {
|
||||
this.setState({ loginView: LoginView.Loading });
|
||||
|
||||
const loggedIn = await this.trySsoLogin();
|
||||
if (loggedIn) return;
|
||||
}
|
||||
|
||||
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
|
||||
// care about login flows here, unless it is the single flow we support.
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const flows = (await client.loginFlows()).flows;
|
||||
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 chosenView = isSocialSignOn ? LoginView.PasswordWithSocialSignOn : firstView;
|
||||
this.setState({ flows, loginView: chosenView });
|
||||
}
|
||||
|
||||
private onPasswordChange = (ev: ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({ password: ev.target.value });
|
||||
};
|
||||
|
||||
private onForgotPassword = (): void => {
|
||||
dis.dispatch({ action: "start_password_recovery" });
|
||||
};
|
||||
|
||||
private onPasswordLogin = async (ev: SyntheticEvent): Promise<void> => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.setState({ busy: true });
|
||||
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const hsUrl = cli.getHomeserverUrl();
|
||||
const isUrl = cli.getIdentityServerUrl();
|
||||
const loginType = "m.login.password";
|
||||
const loginParams = {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: cli.getUserId(),
|
||||
},
|
||||
password: this.state.password,
|
||||
device_id: cli.getDeviceId() ?? undefined,
|
||||
};
|
||||
|
||||
let credentials: IMatrixClientCreds;
|
||||
try {
|
||||
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
|
||||
} catch (e) {
|
||||
let errorText = _t("auth|failed_soft_logout_homeserver");
|
||||
if (
|
||||
e instanceof MatrixError &&
|
||||
e.errcode === "M_FORBIDDEN" &&
|
||||
(e.httpStatus === 401 || e.httpStatus === 403)
|
||||
) {
|
||||
errorText = _t("auth|incorrect_password");
|
||||
}
|
||||
|
||||
this.setState({
|
||||
busy: false,
|
||||
errorText: errorText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Lifecycle.setLoggedIn(credentials).catch((e) => {
|
||||
logger.error(e);
|
||||
this.setState({ busy: false, errorText: _t("auth|failed_soft_logout_auth") });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to login via SSO
|
||||
* @returns A promise that resolves to a boolean - true when sso login was successful
|
||||
*/
|
||||
private async trySsoLogin(): Promise<boolean> {
|
||||
this.setState({ busy: true });
|
||||
|
||||
const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
|
||||
if (!hsUrl) {
|
||||
logger.error("Homeserver URL unknown for SSO login callback");
|
||||
this.setState({ busy: false, loginView: LoginView.Unsupported });
|
||||
return false;
|
||||
}
|
||||
|
||||
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.safeGet().getIdentityServerUrl();
|
||||
const loginType = "m.login.token";
|
||||
const loginParams = {
|
||||
token: this.props.realQueryParams["loginToken"],
|
||||
device_id: MatrixClientPeg.safeGet().getDeviceId() ?? undefined,
|
||||
};
|
||||
|
||||
let credentials: IMatrixClientCreds;
|
||||
try {
|
||||
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
this.setState({ busy: false, loginView: LoginView.Unsupported });
|
||||
return false;
|
||||
}
|
||||
|
||||
return Lifecycle.setLoggedIn(credentials)
|
||||
.then(() => {
|
||||
if (this.props.onTokenLoginCompleted) {
|
||||
this.props.onTokenLoginCompleted();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
this.setState({ busy: false, loginView: LoginView.Unsupported });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private renderPasswordForm(introText: Optional<string>): JSX.Element {
|
||||
let error: JSX.Element | undefined;
|
||||
if (this.state.errorText) {
|
||||
error = <span className="mx_Login_error">{this.state.errorText}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={this.onPasswordLogin}>
|
||||
{introText ? <p>{introText}</p> : null}
|
||||
{error}
|
||||
<Field
|
||||
type="password"
|
||||
label={_t("common|password")}
|
||||
onChange={this.onPasswordChange}
|
||||
value={this.state.password}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
<AccessibleButton
|
||||
onClick={this.onPasswordLogin}
|
||||
kind="primary"
|
||||
type="submit"
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("action|sign_in")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this.onForgotPassword} kind="link">
|
||||
{_t("auth|forgot_password_prompt")}
|
||||
</AccessibleButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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 SSOFlow;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{introText ? <p>{introText}</p> : null}
|
||||
<SSOButtons
|
||||
matrixClient={MatrixClientPeg.safeGet()}
|
||||
flow={flow}
|
||||
loginType={loginType}
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
primary={!this.state.flows.find((flow) => flow.type === "m.login.password")}
|
||||
action={SSOAction.LOGIN}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderSignInSection(): JSX.Element {
|
||||
if (this.state.loginView === LoginView.Loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (this.state.loginView === LoginView.Password) {
|
||||
return this.renderPasswordForm(_t("auth|soft_logout_intro_password"));
|
||||
}
|
||||
|
||||
if (this.state.loginView === LoginView.SSO || this.state.loginView === LoginView.CAS) {
|
||||
return this.renderSsoForm(_t("auth|soft_logout_intro_sso"));
|
||||
}
|
||||
|
||||
if (this.state.loginView === LoginView.PasswordWithSocialSignOn) {
|
||||
// We render both forms with no intro/error to ensure the layout looks reasonably
|
||||
// okay enough.
|
||||
//
|
||||
// Note: "mx_AuthBody_centered" text taken from registration page.
|
||||
return (
|
||||
<>
|
||||
<p>{_t("auth|soft_logout_intro_sso")}</p>
|
||||
{this.renderSsoForm(null)}
|
||||
<h2 className="mx_AuthBody_centered">
|
||||
{_t("auth|sso_or_username_password", {
|
||||
ssoButtons: "",
|
||||
usernamePassword: "",
|
||||
}).trim()}
|
||||
</h2>
|
||||
{this.renderPasswordForm(null)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: assume unsupported/error
|
||||
return <p>{_t("auth|soft_logout_intro_unsupported_auth")}</p>;
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthHeader />
|
||||
<AuthBody>
|
||||
<h1>{_t("auth|soft_logout_heading")}</h1>
|
||||
|
||||
<h2>{_t("action|sign_in")}</h2>
|
||||
<div>{this.renderSignInSection()}</div>
|
||||
|
||||
<h2>{_t("auth|soft_logout_subheading")}</h2>
|
||||
<p>{_t("auth|soft_logout_warning")}</p>
|
||||
<div>
|
||||
<AccessibleButton onClick={this.onClearAll} kind="danger">
|
||||
{_t("auth|soft_logout|clear_data_button")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</AuthBody>
|
||||
</AuthPage>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import AccessibleButton from "../../../views/elements/AccessibleButton";
|
||||
import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
|
||||
import { Icon as RetryIcon } from "../../../../../res/img/compound/retry-16px.svg";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
|
||||
import { ErrorMessage } from "../../ErrorMessage";
|
||||
|
||||
interface CheckEmailProps {
|
||||
email: string;
|
||||
errorText: string | ReactNode | null;
|
||||
onReEnterEmailClick: () => void;
|
||||
onResendClick: () => Promise<boolean>;
|
||||
onSubmitForm: (ev: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component renders the email verification view of the forgot password flow.
|
||||
*/
|
||||
export const CheckEmail: React.FC<CheckEmailProps> = ({
|
||||
email,
|
||||
errorText,
|
||||
onReEnterEmailClick,
|
||||
onSubmitForm,
|
||||
onResendClick,
|
||||
}) => {
|
||||
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
|
||||
|
||||
const onResendClickFn = async (): Promise<void> => {
|
||||
await onResendClick();
|
||||
toggleTooltipVisible();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EMailPromptIcon className="mx_AuthBody_emailPromptIcon--shifted" />
|
||||
<h1>{_t("auth|uia|email_auth_header")}</h1>
|
||||
<div className="mx_AuthBody_text">
|
||||
<p>{_t("auth|check_email_explainer", { email: email }, { b: (t) => <strong>{t}</strong> })}</p>
|
||||
<div className="mx_AuthBody_did-not-receive">
|
||||
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_wrong_email_prompt")}</span>
|
||||
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
|
||||
{_t("auth|check_email_wrong_email_button")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
{errorText && <ErrorMessage message={errorText} />}
|
||||
<input onClick={onSubmitForm} type="button" className="mx_Login_submit" value={_t("action|next")} />
|
||||
<div className="mx_AuthBody_did-not-receive">
|
||||
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_resend_prompt")}</span>
|
||||
<Tooltip description={_t("auth|check_email_resend_tooltip")} placement="top" open={tooltipVisible}>
|
||||
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
|
||||
<RetryIcon className="mx_Icon mx_Icon_16" />
|
||||
{_t("action|resend")}
|
||||
</AccessibleButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useRef } from "react";
|
||||
import { EmailSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t, _td } from "../../../../languageHandler";
|
||||
import EmailField from "../../../views/auth/EmailField";
|
||||
import { ErrorMessage } from "../../ErrorMessage";
|
||||
import Spinner from "../../../views/elements/Spinner";
|
||||
import Field from "../../../views/elements/Field";
|
||||
import AccessibleButton, { ButtonEvent } from "../../../views/elements/AccessibleButton";
|
||||
|
||||
interface EnterEmailProps {
|
||||
email: string;
|
||||
errorText: string | ReactNode | null;
|
||||
homeserver: string;
|
||||
loading: boolean;
|
||||
onInputChanged: (stateKey: "email", ev: React.FormEvent<HTMLInputElement>) => void;
|
||||
onLoginClick: () => void;
|
||||
onSubmitForm: (ev: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component renders the email input view of the forgot password flow.
|
||||
*/
|
||||
export const EnterEmail: React.FC<EnterEmailProps> = ({
|
||||
email,
|
||||
errorText,
|
||||
homeserver,
|
||||
loading,
|
||||
onInputChanged,
|
||||
onLoginClick,
|
||||
onSubmitForm,
|
||||
}) => {
|
||||
const submitButtonChild = loading ? <Spinner w={16} h={16} /> : _t("auth|forgot_password_send_email");
|
||||
|
||||
const emailFieldRef = useRef<Field>(null);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent): Promise<void> => {
|
||||
if (await emailFieldRef.current?.validate({ allowEmpty: false })) {
|
||||
onSubmitForm(event);
|
||||
return;
|
||||
}
|
||||
|
||||
emailFieldRef.current?.focus();
|
||||
emailFieldRef.current?.validate({ allowEmpty: false, focused: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmailSolidIcon className="mx_AuthBody_icon" />
|
||||
<h1>{_t("auth|enter_email_heading")}</h1>
|
||||
<p className="mx_AuthBody_text">
|
||||
{_t("auth|enter_email_explainer", { homeserver }, { b: (t) => <strong>{t}</strong> })}
|
||||
</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<fieldset disabled={loading}>
|
||||
<div className="mx_AuthBody_fieldRow">
|
||||
<EmailField
|
||||
name="reset_email" // define a name so browser's password autofill gets less confused
|
||||
label={_td("common|email_address")}
|
||||
labelRequired={_td("auth|forgot_password_email_required")}
|
||||
labelInvalid={_td("auth|forgot_password_email_invalid")}
|
||||
value={email}
|
||||
autoFocus={true}
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) => onInputChanged("email", event)}
|
||||
fieldRef={emailFieldRef}
|
||||
/>
|
||||
</div>
|
||||
{errorText && <ErrorMessage message={errorText} />}
|
||||
<button type="submit" className="mx_Login_submit">
|
||||
{submitButtonChild}
|
||||
</button>
|
||||
<div className="mx_AuthBody_button-container">
|
||||
<AccessibleButton
|
||||
className="mx_AuthBody_sign-in-instead-button"
|
||||
element="button"
|
||||
kind="link"
|
||||
onClick={(e: ButtonEvent) => {
|
||||
e.preventDefault();
|
||||
onLoginClick();
|
||||
}}
|
||||
>
|
||||
{_t("auth|sign_in_instead")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import AccessibleButton from "../../../views/elements/AccessibleButton";
|
||||
import { Icon as RetryIcon } from "../../../../../res/img/compound/retry-16px.svg";
|
||||
import { Icon as EmailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
|
||||
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
|
||||
import { ErrorMessage } from "../../ErrorMessage";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
errorText: ReactNode | null;
|
||||
onFinished(): void; // This modal is weird in that the way you close it signals intent
|
||||
onCloseClick: () => void;
|
||||
onReEnterEmailClick: () => void;
|
||||
onResendClick: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const VerifyEmailModal: React.FC<Props> = ({
|
||||
email,
|
||||
errorText,
|
||||
onCloseClick,
|
||||
onReEnterEmailClick,
|
||||
onResendClick,
|
||||
}) => {
|
||||
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
|
||||
|
||||
const onResendClickFn = async (): Promise<void> => {
|
||||
await onResendClick();
|
||||
toggleTooltipVisible();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmailPromptIcon className="mx_AuthBody_emailPromptIcon" />
|
||||
<h1>{_t("auth|verify_email_heading")}</h1>
|
||||
<p>
|
||||
{_t(
|
||||
"auth|verify_email_explainer",
|
||||
{
|
||||
email,
|
||||
},
|
||||
{
|
||||
b: (sub) => <strong>{sub}</strong>,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mx_AuthBody_did-not-receive">
|
||||
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_resend_prompt")}</span>
|
||||
<Tooltip description={_t("auth|check_email_resend_tooltip")} placement="top" open={tooltipVisible}>
|
||||
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onResendClickFn}>
|
||||
<RetryIcon className="mx_Icon mx_Icon_16" />
|
||||
{_t("action|resend")}
|
||||
</AccessibleButton>
|
||||
</Tooltip>
|
||||
{errorText && <ErrorMessage message={errorText} />}
|
||||
</div>
|
||||
|
||||
<div className="mx_AuthBody_did-not-receive">
|
||||
<span className="mx_VerifyEMailDialog_text-light">{_t("auth|check_email_wrong_email_prompt")}</span>
|
||||
<AccessibleButton className="mx_AuthBody_resend-button" kind="link" onClick={onReEnterEmailClick}>
|
||||
{_t("auth|check_email_wrong_email_button")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<AccessibleButton
|
||||
onClick={onCloseClick}
|
||||
className="mx_Dialog_cancelButton"
|
||||
aria-label={_t("dialog_close_label")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
18
src/components/structures/auth/header/AuthHeaderContext.tsx
Normal file
18
src/components/structures/auth/header/AuthHeaderContext.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, Dispatch, ReducerAction, ReducerState } from "react";
|
||||
|
||||
import type { AuthHeaderReducer } from "./AuthHeaderProvider";
|
||||
|
||||
interface AuthHeaderContextType {
|
||||
state: ReducerState<AuthHeaderReducer>;
|
||||
dispatch: Dispatch<ReducerAction<AuthHeaderReducer>>;
|
||||
}
|
||||
|
||||
export const AuthHeaderContext = createContext<AuthHeaderContextType | undefined>(undefined);
|
33
src/components/structures/auth/header/AuthHeaderDisplay.tsx
Normal file
33
src/components/structures/auth/header/AuthHeaderDisplay.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { Fragment, PropsWithChildren, ReactNode, useContext } from "react";
|
||||
|
||||
import { AuthHeaderContext } from "./AuthHeaderContext";
|
||||
|
||||
interface Props {
|
||||
title: ReactNode;
|
||||
icon?: ReactNode;
|
||||
serverPicker: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthHeaderDisplay({ title, icon, serverPicker, children }: PropsWithChildren<Props>): JSX.Element {
|
||||
const context = useContext(AuthHeaderContext);
|
||||
if (!context) {
|
||||
return <></>;
|
||||
}
|
||||
const current = context.state[0] ?? null;
|
||||
return (
|
||||
<Fragment>
|
||||
{current?.icon ?? icon}
|
||||
<h1>{current?.title ?? title}</h1>
|
||||
{children}
|
||||
{current?.hideServerPicker !== true && serverPicker}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
31
src/components/structures/auth/header/AuthHeaderModifier.tsx
Normal file
31
src/components/structures/auth/header/AuthHeaderModifier.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ReactNode, useContext, useEffect } from "react";
|
||||
|
||||
import { AuthHeaderContext } from "./AuthHeaderContext";
|
||||
import { AuthHeaderActionType } from "./AuthHeaderProvider";
|
||||
|
||||
interface Props {
|
||||
title: ReactNode;
|
||||
icon?: ReactNode;
|
||||
hideServerPicker?: boolean;
|
||||
}
|
||||
|
||||
export function AuthHeaderModifier(props: Props): null {
|
||||
const context = useContext(AuthHeaderContext);
|
||||
const dispatch = context?.dispatch ?? null;
|
||||
useEffect(() => {
|
||||
if (!dispatch) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: AuthHeaderActionType.Add, value: props });
|
||||
return () => dispatch({ type: AuthHeaderActionType.Remove, value: props });
|
||||
}, [props, dispatch]);
|
||||
return null;
|
||||
}
|
40
src/components/structures/auth/header/AuthHeaderProvider.tsx
Normal file
40
src/components/structures/auth/header/AuthHeaderProvider.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { isEqual } from "lodash";
|
||||
import React, { ComponentProps, PropsWithChildren, Reducer, useReducer } from "react";
|
||||
|
||||
import { AuthHeaderContext } from "./AuthHeaderContext";
|
||||
import { AuthHeaderModifier } from "./AuthHeaderModifier";
|
||||
|
||||
export enum AuthHeaderActionType {
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
interface AuthHeaderAction {
|
||||
type: AuthHeaderActionType;
|
||||
value: ComponentProps<typeof AuthHeaderModifier>;
|
||||
}
|
||||
|
||||
export type AuthHeaderReducer = Reducer<ComponentProps<typeof AuthHeaderModifier>[], AuthHeaderAction>;
|
||||
|
||||
export function AuthHeaderProvider({ children }: PropsWithChildren<{}>): JSX.Element {
|
||||
const [state, dispatch] = useReducer<AuthHeaderReducer>(
|
||||
(state: ComponentProps<typeof AuthHeaderModifier>[], action: AuthHeaderAction) => {
|
||||
switch (action.type) {
|
||||
case AuthHeaderActionType.Add:
|
||||
return [action.value, ...state];
|
||||
case AuthHeaderActionType.Remove:
|
||||
return state.length && isEqual(state[0], action.value) ? state.slice(1) : state;
|
||||
}
|
||||
},
|
||||
[] as ComponentProps<typeof AuthHeaderModifier>[],
|
||||
);
|
||||
return <AuthHeaderContext.Provider value={{ state, dispatch }}>{children}</AuthHeaderContext.Provider>;
|
||||
}
|
51
src/components/structures/grouper/BaseGrouper.ts
Normal file
51
src/components/structures/grouper/BaseGrouper.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MessagePanel, { WrappedEvent } from "../MessagePanel";
|
||||
|
||||
/* Grouper classes determine when events can be grouped together in a summary.
|
||||
* Groupers should have the following methods:
|
||||
* - canStartGroup (static): determines if a new group should be started with the
|
||||
* given event
|
||||
* - shouldGroup: determines if the given event should be added to an existing group
|
||||
* - add: adds an event to an existing group (should only be called if shouldGroup
|
||||
* return true)
|
||||
* - getTiles: returns the tiles that represent the group
|
||||
* - getNewPrevEvent: returns the event that should be used as the new prevEvent
|
||||
* when determining things such as whether a date separator is necessary
|
||||
*/
|
||||
export abstract class BaseGrouper {
|
||||
public static canStartGroup = (_panel: MessagePanel, _ev: WrappedEvent): boolean => true;
|
||||
|
||||
public events: WrappedEvent[] = [];
|
||||
// events that we include in the group but then eject out and place above the group.
|
||||
public ejectedEvents: WrappedEvent[] = [];
|
||||
public readMarker: ReactNode;
|
||||
|
||||
public constructor(
|
||||
public readonly panel: MessagePanel,
|
||||
public readonly firstEventAndShouldShow: WrappedEvent,
|
||||
public readonly prevEvent: MatrixEvent | null,
|
||||
public readonly lastShownEvent: MatrixEvent | undefined,
|
||||
public readonly nextEvent: WrappedEvent | null,
|
||||
public readonly nextEventTile?: MatrixEvent | null,
|
||||
) {
|
||||
this.readMarker = panel.readMarkerForEvent(
|
||||
firstEventAndShouldShow.event.getId()!,
|
||||
firstEventAndShouldShow.event === lastShownEvent,
|
||||
);
|
||||
}
|
||||
|
||||
public abstract shouldGroup(ev: WrappedEvent): boolean;
|
||||
public abstract add(ev: WrappedEvent): void;
|
||||
public abstract getTiles(): ReactNode[];
|
||||
public abstract getNewPrevEvent(): MatrixEvent;
|
||||
}
|
161
src/components/structures/grouper/CreationGrouper.tsx
Normal file
161
src/components/structures/grouper/CreationGrouper.tsx
Normal file
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { EventType, M_BEACON_INFO, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { BaseGrouper } from "./BaseGrouper";
|
||||
import MessagePanel, { WrappedEvent } from "../MessagePanel";
|
||||
import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import DateSeparator from "../../views/messages/DateSeparator";
|
||||
import NewRoomIntro from "../../views/rooms/NewRoomIntro";
|
||||
import GenericEventListSummary from "../../views/elements/GenericEventListSummary";
|
||||
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
|
||||
|
||||
// Wrap initial room creation events into a GenericEventListSummary
|
||||
// Grouping only events sent by the same user that sent the `m.room.create` and only until
|
||||
// the first non-state event, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event
|
||||
|
||||
export class CreationGrouper extends BaseGrouper {
|
||||
public static canStartGroup = function (_panel: MessagePanel, { event }: WrappedEvent): boolean {
|
||||
return event.getType() === EventType.RoomCreate;
|
||||
};
|
||||
|
||||
public shouldGroup({ event, shouldShow }: WrappedEvent): boolean {
|
||||
const panel = this.panel;
|
||||
const createEvent = this.firstEventAndShouldShow.event;
|
||||
if (!shouldShow) {
|
||||
return true;
|
||||
}
|
||||
if (panel.wantsSeparator(this.firstEventAndShouldShow.event, event) === SeparatorKind.Date) {
|
||||
return false;
|
||||
}
|
||||
const eventType = event.getType();
|
||||
if (
|
||||
eventType === EventType.RoomMember &&
|
||||
(event.getStateKey() !== createEvent.getSender() ||
|
||||
event.getContent()["membership"] !== KnownMembership.Join)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// beacons are not part of room creation configuration
|
||||
// should be shown in timeline
|
||||
if (M_BEACON_INFO.matches(eventType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (VoiceBroadcastInfoEventType === eventType) {
|
||||
// always show voice broadcast info events in timeline
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.isState() && event.getSender() === createEvent.getSender()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public add(wrappedEvent: WrappedEvent): void {
|
||||
const { event: ev, shouldShow } = wrappedEvent;
|
||||
const panel = this.panel;
|
||||
this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent);
|
||||
if (!shouldShow) {
|
||||
return;
|
||||
}
|
||||
if (ev.getType() === EventType.RoomEncryption) {
|
||||
this.ejectedEvents.push(wrappedEvent);
|
||||
} else {
|
||||
this.events.push(wrappedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
public getTiles(): ReactNode[] {
|
||||
// If we don't have any events to group, don't even try to group them. The logic
|
||||
// below assumes that we have a group of events to deal with, but we might not if
|
||||
// the events we were supposed to group were redacted.
|
||||
if (!this.events || !this.events.length) return [];
|
||||
|
||||
const panel = this.panel;
|
||||
const ret: ReactNode[] = [];
|
||||
const isGrouped = true;
|
||||
const createEvent = this.firstEventAndShouldShow;
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
|
||||
if (panel.wantsSeparator(this.prevEvent, createEvent.event) === SeparatorKind.Date) {
|
||||
const ts = createEvent.event.getTs();
|
||||
ret.push(
|
||||
<li key={ts + "~"}>
|
||||
<DateSeparator roomId={createEvent.event.getRoomId()!} ts={ts} />
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
|
||||
// If this m.room.create event should be shown (room upgrade) then show it before the summary
|
||||
if (createEvent.shouldShow) {
|
||||
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
|
||||
ret.push(...panel.getTilesForEvent(createEvent.event, createEvent));
|
||||
}
|
||||
|
||||
for (const ejected of this.ejectedEvents) {
|
||||
ret.push(
|
||||
...panel.getTilesForEvent(createEvent.event, ejected, createEvent.event === lastShownEvent, isGrouped),
|
||||
);
|
||||
}
|
||||
|
||||
const eventTiles = this.events
|
||||
.map((e) => {
|
||||
// In order to prevent DateSeparators from appearing in the expanded form
|
||||
// of GenericEventListSummary, render each member event as if the previous
|
||||
// one was itself. This way, the timestamp of the previous event === the
|
||||
// timestamp of the current event, and no DateSeparator is inserted.
|
||||
return panel.getTilesForEvent(e.event, e, e.event === lastShownEvent, isGrouped);
|
||||
})
|
||||
.reduce((a, b) => a.concat(b), []);
|
||||
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
|
||||
const ev = this.events[this.events.length - 1].event;
|
||||
|
||||
let summaryText: string;
|
||||
const roomId = ev.getRoomId();
|
||||
const creator = ev.sender?.name ?? ev.getSender();
|
||||
if (roomId && DMRoomMap.shared().getUserIdForRoomId(roomId)) {
|
||||
summaryText = _t("timeline|creation_summary_dm", { creator });
|
||||
} else {
|
||||
summaryText = _t("timeline|creation_summary_room", { creator });
|
||||
}
|
||||
|
||||
ret.push(<NewRoomIntro key="newroomintro" />);
|
||||
|
||||
ret.push(
|
||||
<GenericEventListSummary
|
||||
key="roomcreationsummary"
|
||||
events={this.events.map((e) => e.event)}
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
summaryMembers={ev.sender ? [ev.sender] : undefined}
|
||||
summaryText={summaryText}
|
||||
layout={this.panel.props.layout}
|
||||
>
|
||||
{eventTiles}
|
||||
</GenericEventListSummary>,
|
||||
);
|
||||
|
||||
if (this.readMarker) {
|
||||
ret.push(this.readMarker);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public getNewPrevEvent(): MatrixEvent {
|
||||
return this.firstEventAndShouldShow.event;
|
||||
}
|
||||
}
|
35
src/components/structures/grouper/LateEventGrouper.ts
Normal file
35
src/components/structures/grouper/LateEventGrouper.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
const UNSIGNED_KEY = "io.element.late_event";
|
||||
|
||||
/**
|
||||
* This metadata describes when events arrive late after a net-split to offer improved UX.
|
||||
*/
|
||||
interface UnsignedLateEventInfo {
|
||||
/**
|
||||
* Milliseconds since epoch representing the time the event was received by the server
|
||||
*/
|
||||
received_ts: number;
|
||||
/**
|
||||
* An opaque identifier representing the group the server has put the late arriving event into
|
||||
*/
|
||||
group_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get io.element.late_event metadata from unsigned as sent by the server.
|
||||
*
|
||||
* @experimental this is not in the Matrix spec and needs special server support
|
||||
* @param mxEvent the Matrix Event to get UnsignedLateEventInfo on
|
||||
*/
|
||||
export function getLateEventInfo(mxEvent: MatrixEvent): UnsignedLateEventInfo | undefined {
|
||||
return mxEvent.getUnsigned()[UNSIGNED_KEY];
|
||||
}
|
191
src/components/structures/grouper/MainGrouper.tsx
Normal file
191
src/components/structures/grouper/MainGrouper.tsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type MessagePanel from "../MessagePanel";
|
||||
import type { WrappedEvent } from "../MessagePanel";
|
||||
import { BaseGrouper } from "./BaseGrouper";
|
||||
import { hasText } from "../../../TextForEvent";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import DateSeparator from "../../views/messages/DateSeparator";
|
||||
import HistoryTile from "../../views/rooms/HistoryTile";
|
||||
import EventListSummary from "../../views/elements/EventListSummary";
|
||||
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
|
||||
|
||||
const groupedStateEvents = [
|
||||
EventType.RoomMember,
|
||||
EventType.RoomThirdPartyInvite,
|
||||
EventType.RoomServerAcl,
|
||||
EventType.RoomPinnedEvents,
|
||||
];
|
||||
|
||||
// Wrap consecutive grouped events in a ListSummary
|
||||
export class MainGrouper extends BaseGrouper {
|
||||
public static canStartGroup = function (panel: MessagePanel, { event: ev, shouldShow }: WrappedEvent): boolean {
|
||||
if (!shouldShow) return false;
|
||||
|
||||
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ev.isRedacted()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (panel.showHiddenEvents && !panel.shouldShowEvent(ev, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
public constructor(
|
||||
public readonly panel: MessagePanel,
|
||||
public readonly firstEventAndShouldShow: WrappedEvent,
|
||||
public readonly prevEvent: MatrixEvent | null,
|
||||
public readonly lastShownEvent: MatrixEvent | undefined,
|
||||
nextEvent: WrappedEvent | null,
|
||||
nextEventTile: MatrixEvent | null,
|
||||
) {
|
||||
super(panel, firstEventAndShouldShow, prevEvent, lastShownEvent, nextEvent, nextEventTile);
|
||||
this.events = [firstEventAndShouldShow];
|
||||
}
|
||||
|
||||
public shouldGroup({ event: ev, shouldShow }: WrappedEvent): boolean {
|
||||
if (!shouldShow) {
|
||||
// absorb hidden events so that they do not break up streams of messages & redaction events being grouped
|
||||
return true;
|
||||
}
|
||||
if (this.panel.wantsSeparator(this.events[0].event, ev) === SeparatorKind.Date) {
|
||||
return false;
|
||||
}
|
||||
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
|
||||
return true;
|
||||
}
|
||||
if (ev.isRedacted()) {
|
||||
return true;
|
||||
}
|
||||
if (this.panel.showHiddenEvents && !this.panel.shouldShowEvent(ev, true)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public add(wrappedEvent: WrappedEvent): void {
|
||||
const { event: ev, shouldShow } = wrappedEvent;
|
||||
if (ev.getType() === EventType.RoomMember) {
|
||||
// We can ignore any events that don't actually have a message to display
|
||||
if (!hasText(ev, MatrixClientPeg.safeGet(), this.panel.showHiddenEvents)) return;
|
||||
}
|
||||
this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent);
|
||||
if (!this.panel.showHiddenEvents && !shouldShow) {
|
||||
// absorb hidden events to not split the summary
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.getType() === EventType.RoomPinnedEvents) {
|
||||
// If pinned messages are disabled, don't show the summary
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.push(wrappedEvent);
|
||||
}
|
||||
|
||||
private generateKey(): string {
|
||||
return "eventlistsummary-" + this.events[0].event.getId();
|
||||
}
|
||||
|
||||
public getTiles(): ReactNode[] {
|
||||
// If we don't have any events to group, don't even try to group them. The logic
|
||||
// below assumes that we have a group of events to deal with, but we might not if
|
||||
// the events we were supposed to group were redacted.
|
||||
if (!this.events?.length) return [];
|
||||
|
||||
const isGrouped = true;
|
||||
const panel = this.panel;
|
||||
const lastShownEvent = this.lastShownEvent;
|
||||
const ret: ReactNode[] = [];
|
||||
|
||||
if (panel.wantsSeparator(this.prevEvent, this.events[0].event) === SeparatorKind.Date) {
|
||||
const ts = this.events[0].event.getTs();
|
||||
ret.push(
|
||||
<li key={ts + "~"}>
|
||||
<DateSeparator roomId={this.events[0].event.getRoomId()!} ts={ts} />
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure that the key of the EventListSummary does not change with new events in either direction.
|
||||
// This will prevent it from being re-created unnecessarily, and instead will allow new props to be provided.
|
||||
// In turn, the shouldComponentUpdate method on ELS can be used to prevent unnecessary renderings.
|
||||
const keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e.event));
|
||||
const key =
|
||||
keyEvent && this.panel.grouperKeyMap.has(keyEvent.event)
|
||||
? this.panel.grouperKeyMap.get(keyEvent.event)!
|
||||
: this.generateKey();
|
||||
if (!keyEvent) {
|
||||
// Populate the weak map with the key.
|
||||
// Note that we only set the key on the specific event it refers to, since this group might get
|
||||
// split up in the future by other intervening events. If we were to set the key on all events
|
||||
// currently in the group, we would risk later giving the same key to multiple groups.
|
||||
this.panel.grouperKeyMap.set(this.events[0].event, key);
|
||||
}
|
||||
|
||||
let highlightInSummary = false;
|
||||
let eventTiles: ReactNode[] | null = this.events
|
||||
.map((e, i) => {
|
||||
if (e.event.getId() === panel.props.highlightedEventId) {
|
||||
highlightInSummary = true;
|
||||
}
|
||||
return panel.getTilesForEvent(
|
||||
i === 0 ? this.prevEvent : this.events[i - 1].event,
|
||||
e,
|
||||
e.event === lastShownEvent,
|
||||
isGrouped,
|
||||
this.nextEvent,
|
||||
this.nextEventTile,
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a.concat(b), []);
|
||||
|
||||
if (eventTiles.length === 0) {
|
||||
eventTiles = null;
|
||||
}
|
||||
|
||||
// If a membership event is the start of visible history, tell the user
|
||||
// why they can't see earlier messages
|
||||
if (!this.panel.props.canBackPaginate && !this.prevEvent) {
|
||||
ret.push(<HistoryTile key="historytile" />);
|
||||
}
|
||||
|
||||
ret.push(
|
||||
<EventListSummary
|
||||
key={key}
|
||||
data-testid={key}
|
||||
events={this.events.map((e) => e.event)}
|
||||
onToggle={panel.onHeightChanged} // Update scroll state
|
||||
startExpanded={highlightInSummary}
|
||||
layout={this.panel.props.layout}
|
||||
>
|
||||
{eventTiles}
|
||||
</EventListSummary>,
|
||||
);
|
||||
|
||||
if (this.readMarker) {
|
||||
ret.push(this.readMarker);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public getNewPrevEvent(): MatrixEvent {
|
||||
return this.events[this.events.length - 1].event;
|
||||
}
|
||||
}
|
18
src/components/structures/static-page-vars.ts
Normal file
18
src/components/structures/static-page-vars.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
// 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;
|
||||
|
||||
/**
|
||||
* Intended to replace $matrixLogo in the welcome page.
|
||||
*/
|
||||
export const MATRIX_LOGO_HTML = `<a href="https://matrix.org" target="_blank" rel="noreferrer noopener">
|
||||
<img width="79" height="34" alt="Matrix" style="padding-left: 1px;vertical-align: middle" src="${matrixSvg}"/>
|
||||
</a>`;
|
Loading…
Add table
Add a link
Reference in a new issue