Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/19010

 Conflicts:
	src/components/structures/SpaceRoomView.tsx
	src/components/views/rooms/MemberList.tsx
	src/components/views/rooms/RoomList.tsx
This commit is contained in:
Michael Telatynski 2021-10-14 11:05:29 +01:00
commit 020eca6922
177 changed files with 6615 additions and 2121 deletions

View file

@ -19,6 +19,7 @@ limitations under the License.
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 { Key } from "../../Keyboard";
import { Writeable } from "../../@types/common";
@ -43,8 +44,6 @@ function getOrCreateContainer(): HTMLDivElement {
return container;
}
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
export interface IPosition {
top?: number;
bottom?: number;
@ -84,6 +83,10 @@ export interface IProps extends IPosition {
// 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;
// Function to be called on menu close
onFinished();
// on resize callback
@ -99,7 +102,7 @@ interface IState {
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement;
private readonly initialFocus: HTMLElement;
static defaultProps = {
hasBackground: true,
@ -108,6 +111,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
constructor(props, context) {
super(props, context);
this.state = {
contextMenuElem: null,
};
@ -121,14 +125,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
this.initialFocus.focus();
}
private collectContextMenuRect = (element) => {
private collectContextMenuRect = (element: HTMLDivElement) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
let first = element.querySelector('[role^="menuitem"]');
if (!first) {
first = element.querySelector('[tab-index]');
}
const first = element.querySelector<HTMLElement>('[role^="menuitem"]')
|| element.querySelector<HTMLElement>('[tab-index]');
if (first) {
first.focus();
}
@ -205,7 +208,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
descending = true;
}
}
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
} while (element && !element.getAttribute("role")?.startsWith("menuitem"));
if (element) {
(element as HTMLElement).focus();
@ -226,6 +229,11 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
}
};
private onClick = (ev: React.MouseEvent) => {
// Don't allow clicks to escape the context menu wrapper
ev.stopPropagation();
};
private onKeyDown = (ev: React.KeyboardEvent) => {
// don't let keyboard handling escape the context menu
ev.stopPropagation();
@ -378,11 +386,23 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
);
}
let body = <>
{ chevron }
{ props.children }
</>;
if (props.focusLock) {
body = <FocusLock>
{ body }
</FocusLock>;
}
return (
<div
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
style={{ ...position, ...wrapperStyle }}
onKeyDown={this.onKeyDown}
onClick={this.onClick}
onContextMenu={this.onContextMenuPreventBubbling}
>
<div
@ -391,8 +411,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined}
>
{ chevron }
{ props.children }
{ body }
</div>
{ background }
</div>

View file

@ -37,6 +37,7 @@ import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout";
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
interface IProps {
roomId: string;
@ -57,6 +58,7 @@ class FilePanel extends React.Component<IProps, IState> {
// added to the timeline.
private decryptingEvents = new Set<string>();
public noRoom: boolean;
static contextType = RoomContext;
state = {
timelineSet: null,
@ -249,38 +251,46 @@ class FilePanel extends React.Component<IProps, IState> {
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
if (this.state.timelineSet) {
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
return (
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer
>
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview={false}
onPaginationRequest={this.onPaginationRequest}
tileShape={TileShape.FileGrid}
resizeNotifier={this.props.resizeNotifier}
empty={emptyState}
layout={Layout.Group}
/>
</BaseCard>
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer
>
<DesktopBuildsNotice isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview={false}
onPaginationRequest={this.onPaginationRequest}
tileShape={TileShape.FileGrid}
resizeNotifier={this.props.resizeNotifier}
empty={emptyState}
layout={Layout.Group}
/>
</BaseCard>
</RoomContext.Provider>
);
} else {
return (
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<Spinner />
</BaseCard>
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.File,
}}>
<BaseCard
className="mx_FilePanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
<Spinner />
</BaseCard>
</RoomContext.Provider>
);
}
}

View file

@ -84,7 +84,7 @@ interface IState {
stageState?: IStageStatus;
busy: boolean;
errorText?: string;
stageErrorText?: string;
errorCode?: string;
submitButtonEnabled: boolean;
}
@ -103,7 +103,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
authStage: null,
busy: false,
errorText: null,
stageErrorText: null,
errorCode: null,
submitButtonEnabled: false,
};
@ -145,6 +145,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
const msg = error.message || error.toString();
this.setState({
errorText: msg,
errorCode: error.errcode,
});
});
}
@ -186,6 +187,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
authStage: stageType,
stageState: stageState,
errorText: stageState.error,
errorCode: stageState.errcode,
}, () => {
if (oldStage !== stageType) {
this.setFocus();
@ -208,7 +210,7 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
errorCode: null,
});
}
// The JS SDK eagerly reports itself as "not busy" right after any
@ -235,7 +237,15 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
};
private renderCurrentStage(): JSX.Element {
private onAuthStageFailed = (e: Error): void => {
this.props.onAuthFinished(false, e);
};
private setEmailSid = (sid: string): void => {
this.authLogic.setEmailSid(sid);
};
render() {
const stage = this.state.authStage;
if (!stage) {
if (this.state.busy) {
@ -255,7 +265,8 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
clientSecret={this.authLogic.getClientSecret()}
stageParams={this.authLogic.getStageParams(stage)}
submitAuthDict={this.submitAuthDict}
errorText={this.state.stageErrorText}
errorText={this.state.errorText}
errorCode={this.state.errorCode}
busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
@ -269,32 +280,4 @@ export default class InteractiveAuthComponent extends React.Component<IProps, IS
/>
);
}
private onAuthStageFailed = (e: Error): void => {
this.props.onAuthFinished(false, e);
};
private setEmailSid = (sid: string): void => {
this.authLogic.setEmailSid(sid);
};
render() {
let error = null;
if (this.state.errorText) {
error = (
<div className="error">
{ this.state.errorText }
</div>
);
}
return (
<div>
<div>
{ this.renderCurrentStage() }
{ error }
</div>
</div>
);
}
}

View file

@ -42,7 +42,7 @@ import linkifyMatrix from "../../linkify-matrix";
import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions
import '../../stores/LifecycleStore';
import PageTypes from '../../PageTypes';
import PageType from '../../PageTypes';
import createRoom, { IOpts } from "../../createRoom";
import { _t, _td, getCurrentLanguage } from '../../languageHandler';
@ -208,7 +208,7 @@ interface IState {
view: Views;
// What the LoggedInView would be showing if visible
// eslint-disable-next-line camelcase
page_type?: PageTypes;
page_type?: PageType;
// The ID of the room we're viewing. This is either populated directly
// in the case where we view a room by ID or by RoomView when it resolves
// what ID an alias points at.
@ -724,7 +724,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break;
}
case 'view_my_groups':
this.setPage(PageTypes.MyGroups);
this.setPage(PageType.MyGroups);
this.notifyNewScreen('groups');
break;
case 'view_group':
@ -763,7 +763,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
localStorage.setItem("mx_seenSpacesBeta", "1");
// We just dispatch the page change rather than have to worry about
// what the logic is for each of these branches.
if (this.state.page_type === PageTypes.MyGroups) {
if (this.state.page_type === PageType.MyGroups) {
dis.dispatch({ action: 'view_last_screen' });
} else {
dis.dispatch({ action: 'view_my_groups' });
@ -849,7 +849,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
};
private setPage(pageType: string) {
private setPage(pageType: PageType) {
this.setState({
page_type: pageType,
});
@ -956,7 +956,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setState({
view: Views.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageTypes.RoomView,
page_type: PageType.RoomView,
threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data,
ready: true,
@ -984,7 +984,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
currentGroupId: groupId,
currentGroupIsNew: payload.group_is_new,
});
this.setPage(PageTypes.GroupView);
this.setPage(PageType.GroupView);
this.notifyNewScreen('group/' + groupId);
}
@ -1027,7 +1027,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
justRegistered,
currentRoomId: null,
});
this.setPage(PageTypes.HomePage);
this.setPage(PageType.HomePage);
this.notifyNewScreen('home');
ThemeController.isLogin = false;
this.themeWatcher.recheck();
@ -1045,7 +1045,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
this.notifyNewScreen('user/' + userId);
this.setState({ currentUserId: userId });
this.setPage(PageTypes.UserView);
this.setPage(PageType.UserView);
});
}

View file

@ -48,6 +48,8 @@ import Spinner from "../views/elements/Spinner";
import TileErrorBoundary from '../views/messages/TileErrorBoundary';
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import EditorStateTransfer from "../../utils/EditorStateTransfer";
import { logger } from 'matrix-js-sdk/src/logger';
import { Action } from '../../dispatcher/actions';
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
@ -60,7 +62,7 @@ const groupedEvents = [
// check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
function shouldFormContinuation(
export function shouldFormContinuation(
prevEvent: MatrixEvent,
mxEvent: MatrixEvent,
showHiddenEvents: boolean,
@ -287,6 +289,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
ghostReadMarkers,
});
}
const pendingEditItem = this.pendingEditItem;
if (!this.props.editState && this.props.room && pendingEditItem) {
defaultDispatcher.dispatch({
action: Action.EditEvent,
event: this.props.room.findEventById(pendingEditItem),
timelineRenderingType: this.context.timelineRenderingType,
});
}
}
private calculateRoomMembersCount = (): void => {
@ -550,10 +561,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return { nextEvent, nextTile };
}
private get roomHasPendingEdit(): string {
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`);
private get pendingEditItem(): string | undefined {
try {
return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`);
} catch (err) {
logger.error(err);
return undefined;
}
}
private getEventTiles(): ReactNode[] {
this.eventNodes = {};
@ -663,13 +678,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}
}
if (!this.props.editState && this.roomHasPendingEdit) {
defaultDispatcher.dispatch({
action: "edit_event",
event: this.props.room.findEventById(this.roomHasPendingEdit),
});
}
if (grouper) {
ret.push(...grouper.getTiles());
}

View file

@ -24,6 +24,7 @@ import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
interface IProps {
onClose(): void;
@ -34,6 +35,7 @@ interface IProps {
*/
@replaceableComponent("structures.NotificationPanel")
export default class NotificationPanel extends React.PureComponent<IProps> {
static contextType = RoomContext;
render() {
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{ _t('Youre all caught up') }</h2>
@ -61,8 +63,13 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
content = <Spinner />;
}
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
{ content }
</BaseCard>;
return <RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Notification,
}}>
<BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
{ content }
</BaseCard>
</RoomContext.Provider>;
}
}

View file

@ -48,8 +48,8 @@ import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import MatrixClientContext, { withMatrixClientHOC, MatrixClientProps } from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions";
import { IMatrixClientCreds } from "../../MatrixClientPeg";
@ -91,6 +91,7 @@ import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore";
import { logger } from "matrix-js-sdk/src/logger";
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -102,7 +103,7 @@ if (DEBUG) {
debuglog = logger.log.bind(console);
}
interface IProps {
interface IRoomProps extends MatrixClientProps {
threepidInvite: IThreepidInvite;
oobData?: IOOBData;
@ -113,7 +114,7 @@ interface IProps {
onRegistered?(credentials: IMatrixClientCreds): void;
}
export interface IState {
export interface IRoomState {
room?: Room;
roomId?: string;
roomAlias?: string;
@ -187,10 +188,12 @@ export interface IState {
// if it did we don't want the room to be marked as read as soon as it is loaded.
wasContextSwitch?: boolean;
editState?: EditorStateTransfer;
timelineRenderingType: TimelineRenderingType;
liveTimeline?: EventTimeline;
}
@replaceableComponent("structures.RoomView")
export default class RoomView extends React.Component<IProps, IState> {
export class RoomView extends React.Component<IRoomProps, IRoomState> {
private readonly dispatcherRef: string;
private readonly roomStoreToken: EventSubscription;
private readonly rightPanelStoreToken: EventSubscription;
@ -247,6 +250,8 @@ export default class RoomView extends React.Component<IProps, IState> {
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
};
this.dispatcherRef = dis.register(this.onAction);
@ -336,7 +341,7 @@ export default class RoomView extends React.Component<IProps, IState> {
const roomId = RoomViewStore.getRoomId();
const newState: Pick<IState, any> = {
const newState: Pick<IRoomState, any> = {
roomId,
roomAlias: RoomViewStore.getRoomAlias(),
roomLoading: RoomViewStore.isRoomLoading(),
@ -808,7 +813,9 @@ export default class RoomView extends React.Component<IProps, IState> {
this.onSearchClick();
break;
case "edit_event": {
case Action.EditEvent: {
// Quit early if we're trying to edit events in wrong rendering context
if (payload.timelineRenderingType !== this.state.timelineRenderingType) return;
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({ editState }, () => {
if (payload.event) {
@ -932,6 +939,10 @@ export default class RoomView extends React.Component<IProps, IState> {
this.updateE2EStatus(room);
this.updatePermissions(room);
this.checkWidgets(room);
this.setState({
liveTimeline: room.getLiveTimeline(),
});
};
private async calculateRecommendedVersion(room: Room) {
@ -2086,3 +2097,6 @@ export default class RoomView extends React.Component<IProps, IState> {
);
}
}
const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView);
export default RoomViewWithMatrixClient;

View file

@ -15,17 +15,17 @@ limitations under the License.
*/
import React, {
Dispatch,
KeyboardEvent,
KeyboardEventHandler,
ReactNode,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
KeyboardEvent,
KeyboardEventHandler,
useContext,
SetStateAction,
Dispatch,
} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
@ -33,7 +33,8 @@ import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { sortBy } from "lodash";
import { sortBy, uniqBy } from "lodash";
import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials";
import dis from "../../dispatcher/dispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -333,6 +334,30 @@ interface IHierarchyLevelProps {
onToggleClick?(parentId: string, childId: string): void;
}
const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom => {
const history = cli.getRoomUpgradeHistory(room.room_id, true);
const cliRoom = history[history.length - 1];
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(),
canonical_alias: cliRoom.getCanonicalAlias(),
aliases: cliRoom.getAltAliases(),
world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent()
.history_visibility === HistoryVisibility.WorldReadable,
guest_can_join: cliRoom.currentState.getStateEvents(EventType.RoomGuestAccess, "")?.getContent()
.guest_access === GuestAccess.CanJoin,
num_joined_members: cliRoom.getJoinedMemberCount(),
};
}
return room;
};
export const HierarchyLevel = ({
root,
roomSet,
@ -353,7 +378,7 @@ export const HierarchyLevel = ({
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(room);
result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room));
}
return result;
}, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]);
@ -361,7 +386,7 @@ export const HierarchyLevel = ({
const newParents = new Set(parents).add(root.room_id);
return <React.Fragment>
{
childRooms.map(room => (
uniqBy(childRooms, "room_id").map(room => (
<Tile
key={room.room_id}
room={room}
@ -410,50 +435,39 @@ export const HierarchyLevel = ({
const INITIAL_PAGE_SIZE = 20;
export const useSpaceSummary = (space: Room): {
export const useRoomHierarchy = (space: Room): {
loading: boolean;
rooms: IHierarchyRoom[];
hierarchy: RoomHierarchy;
loadMore(pageSize?: number): Promise <void>;
} => {
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
const [loading, setLoading] = useState(true);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
const resetHierarchy = useCallback(() => {
const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE);
setHierarchy(hierarchy);
let discard = false;
hierarchy.load().then(() => {
if (discard) return;
if (space !== hierarchy.root) return; // discard stale results
setRooms(hierarchy.rooms);
setLoading(false);
});
return () => {
discard = true;
};
setHierarchy(hierarchy);
}, [space]);
useEffect(resetHierarchy, [resetHierarchy]);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setLoading(true);
setRooms([]); // TODO
resetHierarchy();
}
}));
const loadMore = useCallback(async (pageSize?: number) => {
if (!hierarchy.canLoadMore || hierarchy.noSupport) return;
setLoading(true);
if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport) return;
await hierarchy.load(pageSize);
setRooms(hierarchy.rooms);
setLoading(false);
}, [hierarchy]);
const loading = hierarchy?.loading ?? true;
return { loading, rooms, hierarchy, loadMore };
};
@ -587,7 +601,7 @@ const SpaceHierarchy = ({
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space);
const { loading, rooms, hierarchy, loadMore } = useRoomHierarchy(space);
const filteredRoomSet = useMemo<Set<IHierarchyRoom>>(() => {
if (!rooms?.length) return new Set();
@ -648,8 +662,6 @@ const SpaceHierarchy = ({
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content: JSX.Element;
let loader: JSX.Element;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
@ -671,19 +683,20 @@ const SpaceHierarchy = ({
}}
/>
</>;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
} else {
} else if (!hierarchy.canLoadMore) {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
let loader: JSX.Element;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>

View file

@ -82,6 +82,8 @@ import GroupAvatar from "../views/avatars/GroupAvatar";
import { useDispatcher } from "../../hooks/useDispatcher";
import { logger } from "matrix-js-sdk/src/logger";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
interface IProps {
space: Room;
@ -412,7 +414,9 @@ const SpaceLanding = ({ space }: { space: Room }) => {
const userId = cli.getUserId();
let inviteButton;
if ((myMembership === "join" && space.canInvite(userId)) || space.getJoinRule() === JoinRule.Public) {
if (((myMembership === "join" && space.canInvite(userId)) || space.getJoinRule() === JoinRule.Public) &&
shouldShowComponent(UIComponent.InviteUsers)
) {
inviteButton = (
<AccessibleButton
kind="primary"
@ -730,7 +734,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill />
{ _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>,

View file

@ -34,6 +34,8 @@ import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPan
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';
interface IProps {
room: Room;
@ -47,10 +49,14 @@ interface IProps {
interface IState {
replyToEvent?: MatrixEvent;
thread?: Thread;
editState?: EditorStateTransfer;
}
@replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> {
static contextType = RoomContext;
private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
@ -90,6 +96,23 @@ export default class ThreadView extends React.Component<IProps, IState> {
this.setupThread(payload.event);
}
}
switch (payload.action) {
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;
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({ editState }, () => {
if (payload.event) {
this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId());
}
});
break;
}
default:
break;
}
};
private setupThread = (mxEv: MatrixEvent) => {
@ -124,44 +147,53 @@ export default class ThreadView extends React.Component<IProps, IState> {
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
{ this.state.thread && (
<TimelinePanel
ref={this.timelinePanelRef}
showReadReceipts={false} // No RR support in thread's MVP
manageReadReceipts={false} // No RR support in thread's MVP
manageReadMarkers={false} // No RM support in thread's MVP
sendReadReceiptOnLoad={false} // No RR support in thread's MVP
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={true}
tileShape={TileShape.Thread}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel mx_GroupLayout"
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Thread,
liveTimeline: this.state?.thread?.timelineSet?.getLiveTimeline(),
}}>
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
{ this.state.thread && (
<TimelinePanel
ref={this.timelinePanelRef}
showReadReceipts={false} // No RR support in thread's MVP
manageReadReceipts={false} // No RR support in thread's MVP
manageReadMarkers={false} // No RM support in thread's MVP
sendReadReceiptOnLoad={false} // No RR support in thread's MVP
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={true}
tileShape={TileShape.Thread}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel mx_GroupLayout"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
/>
) }
{ this.state?.thread?.timelineSet && (<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
replyInThread={true}
replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
/>
) }
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
replyInThread={true}
replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
</BaseCard>
e2eStatus={this.props.e2eStatus}
compact={true}
/>) }
</BaseCard>
</RoomContext.Provider>
);
}
}

View file

@ -20,6 +20,7 @@ import * as sdk from '../../../index';
import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore';
import SetupEncryptionBody from "./SetupEncryptionBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import AccessibleButton from '../../views/elements/AccessibleButton';
interface IProps {
onFinished: () => void;
@ -27,6 +28,7 @@ interface IProps {
interface IState {
phase: Phase;
lostKeys: boolean;
}
@replaceableComponent("structures.auth.CompleteSecurity")
@ -36,12 +38,17 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
const store = SetupEncryptionStore.sharedInstance();
store.on("update", this.onStoreUpdate);
store.start();
this.state = { phase: store.phase };
this.state = { phase: store.phase, lostKeys: store.lostKeys() };
}
private onStoreUpdate = (): void => {
const store = SetupEncryptionStore.sharedInstance();
this.setState({ phase: store.phase });
this.setState({ phase: store.phase, lostKeys: store.lostKeys() });
};
private onSkipClick = (): void => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
};
public componentWillUnmount(): void {
@ -53,15 +60,20 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
public render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody");
const { phase } = this.state;
const { phase, lostKeys } = this.state;
let icon;
let title;
if (phase === Phase.Loading) {
return null;
} else if (phase === Phase.Intro) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
if (lostKeys) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Unable to verify this login");
} else {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
}
} else if (phase === Phase.Done) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_verified" />;
title = _t("Session verified");
@ -71,16 +83,29 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
} else if (phase === Phase.Busy) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Verify this login");
} else if (phase === Phase.ConfirmReset) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("Really reset verification keys?");
} 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}`);
}
let skipButton;
if (phase === Phase.Intro || phase === Phase.ConfirmReset) {
skipButton = (
<AccessibleButton onClick={this.onSkipClick} className="mx_CompleteSecurity_skip" aria-label={_t("Skip verification for now")} />
);
}
return (
<AuthPage>
<CompleteSecurityBody>
<h2 className="mx_CompleteSecurity_header">
{ icon }
{ title }
{ skipButton }
</h2>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} />

View file

@ -46,6 +46,7 @@ interface IState {
phase: Phase;
verificationRequest: VerificationRequest;
backupInfo: IKeyBackupInfo;
lostKeys: boolean;
}
@replaceableComponent("structures.auth.SetupEncryptionBody")
@ -62,6 +63,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
// Because of the latter, it lives in the state.
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
};
}
@ -75,6 +77,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
phase: store.phase,
verificationRequest: store.verificationRequest,
backupInfo: store.backupInfo,
lostKeys: store.lostKeys(),
});
};
@ -105,11 +108,6 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
});
};
private onSkipClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skip();
};
private onSkipConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.skipConfirm();
@ -120,6 +118,22 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
store.returnAfterSkip();
};
private onResetClick = (ev: React.MouseEvent<HTMLAnchorElement>) => {
ev.preventDefault();
const store = SetupEncryptionStore.sharedInstance();
store.reset();
};
private onResetConfirmClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.resetConfirm();
};
private onResetBackClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.returnAfterReset();
};
private onDoneClick = () => {
const store = SetupEncryptionStore.sharedInstance();
store.done();
@ -132,6 +146,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
public render() {
const {
phase,
lostKeys,
} = this.state;
if (this.state.verificationRequest) {
@ -143,43 +158,67 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
isRoomEncrypted={false}
/>;
} else if (phase === Phase.Intro) {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Use Security Key or Phrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("Use Security Key");
}
if (lostKeys) {
return (
<div>
<p>{ _t(
"It looks like you don't have a Security Key or any other devices you can " +
"verify against. This device will not be able to access old encrypted messages. " +
"In order to verify your identity on this device, you'll need to reset " +
"your verification keys.",
) }</p>
let useRecoveryKeyButton;
if (recoveryKeyPrompt) {
useRecoveryKeyButton = <AccessibleButton kind="link" onClick={this.onUsePassphraseClick}>
{ recoveryKeyPrompt }
</AccessibleButton>;
}
let verifyButton;
if (store.hasDevicesToVerifyAgainst) {
verifyButton = <AccessibleButton kind="primary" onClick={this.onVerifyClick}>
{ _t("Use another login") }
</AccessibleButton>;
}
return (
<div>
<p>{ _t(
"Verify your identity to access encrypted messages and prove your identity to others.",
) }</p>
<div className="mx_CompleteSecurity_actionRow">
{ verifyButton }
{ useRecoveryKeyButton }
<AccessibleButton kind="danger" onClick={this.onSkipClick}>
{ _t("Skip") }
</AccessibleButton>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
</AccessibleButton>
</div>
</div>
</div>
);
);
} else {
const store = SetupEncryptionStore.sharedInstance();
let recoveryKeyPrompt;
if (store.keyInfo && keyHasPassphrase(store.keyInfo)) {
recoveryKeyPrompt = _t("Verify with Security Key or Phrase");
} else if (store.keyInfo) {
recoveryKeyPrompt = _t("Verify with Security 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("Verify with another login") }
</AccessibleButton>;
}
return (
<div>
<p>{ _t(
"Verify your identity to access encrypted messages and prove your identity to others.",
) }</p>
<div className="mx_CompleteSecurity_actionRow">
{ verifyButton }
{ useRecoveryKeyButton }
</div>
<div className="mx_SetupEncryptionBody_reset">
{ _t("Forgotten or lost all recovery methods? <a>Reset all</a>", null, {
a: (sub) => <a
href=""
onClick={this.onResetClick}
className="mx_SetupEncryptionBody_reset_link">{ sub }</a>,
}) }
</div>
</div>
);
}
} else if (phase === Phase.Done) {
let message;
if (this.state.backupInfo) {
@ -215,14 +254,13 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
) }</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton
className="warning"
kind="secondary"
kind="danger_outline"
onClick={this.onSkipConfirmClick}
>
{ _t("Skip") }
{ _t("I'll verify later") }
</AccessibleButton>
<AccessibleButton
kind="danger"
kind="primary"
onClick={this.onSkipBackClick}
>
{ _t("Go Back") }
@ -230,6 +268,30 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
</div>
</div>
);
} else if (phase === Phase.ConfirmReset) {
return (
<div>
<p>{ _t(
"Resetting your verification keys cannot be undone. After resetting, " +
"you won't have access to old encrypted messages, and any friends who " +
"have previously verified you will see security warnings until you " +
"re-verify with them.",
) }</p>
<p>{ _t(
"Please only proceed if you're sure you've lost all of your other " +
"devices and your security key.",
) }</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
{ _t("Proceed with reset") }
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
{ _t("Go Back") }
</AccessibleButton>
</div>
</div>
);
} else if (phase === Phase.Busy || phase === Phase.Loading) {
return <Spinner />;
} else {