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

 Conflicts:
	src/components/views/right_panel/UserInfo.tsx
This commit is contained in:
Michael Telatynski 2021-10-08 13:00:20 +01:00
commit 111ae75874
133 changed files with 5419 additions and 1655 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

@ -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';
@ -207,7 +207,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.
@ -723,7 +723,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':
@ -756,7 +756,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' });
@ -842,7 +842,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
};
private setPage(pageType: string) {
private setPage(pageType: PageType) {
this.setState({
page_type: pageType,
});
@ -949,7 +949,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,
@ -977,7 +977,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);
}
@ -1020,7 +1020,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();
@ -1038,7 +1038,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

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

@ -277,8 +277,15 @@ export default class ScrollPanel extends React.Component<IProps> {
// 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.
// so check difference <= 1;
return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1;
//
// 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

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

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

View file

@ -33,7 +33,7 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
resizeMethod?: ResizeMethod;
// The onClick to give the avatar
onClick?: React.MouseEventHandler;
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
// Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean;
title?: string;
style?: any;

View file

@ -39,6 +39,8 @@ import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import TagOrderActions from "../../../actions/TagOrderActions";
import { inviteUsersToRoom } from "../../../RoomInvite";
import ProgressBar from "../elements/ProgressBar";
interface IProps {
matrixClient: MatrixClient;
@ -90,10 +92,22 @@ export interface IGroupSummary {
}
/* eslint-enable camelcase */
enum Progress {
NotStarted,
ValidatingInputs,
FetchingData,
CreatingSpace,
InvitingUsers,
// anything beyond here is inviting user n - 4
}
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(null);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(Progress.NotStarted);
const [numInvites, setNumInvites] = useState(0);
const busy = progress > 0;
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
const [name, setName] = useState("");
@ -122,30 +136,34 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
if (busy) return;
setError(null);
setBusy(true);
setProgress(Progress.ValidatingInputs);
// require & validate the space name field
if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
setBusy(false);
setProgress(0);
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) {
setBusy(false);
setProgress(0);
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
return;
}
try {
setProgress(Progress.FetchingData);
const [rooms, members, invitedMembers] = await Promise.all([
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
]);
setNumInvites(members.length + invitedMembers.length);
const viaMap = new Map<string, string[]>();
for (const { roomId, canonicalAlias } of rooms) {
const room = cli.getRoom(roomId);
@ -167,6 +185,8 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
}
}
setProgress(Progress.CreatingSpace);
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
creation_content: {
@ -179,11 +199,16 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
via: viaMap.get(roomId) || [],
},
})),
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
// we do not specify the inviters here because Synapse applies a limit and this may cause it to trip
}, {
andView: false,
});
setProgress(Progress.InvitingUsers);
const userIds = [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId());
await inviteUsersToRoom(roomId, userIds, () => setProgress(p => p + 1));
// eagerly remove it from the community panel
dis.dispatch(TagOrderActions.removeTag(cli, groupId));
@ -250,7 +275,7 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
setError(e);
}
setBusy(false);
setProgress(Progress.NotStarted);
};
let footer;
@ -267,13 +292,41 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
{ _t("Retry") }
</AccessibleButton>
</>;
} else if (busy) {
let description: string;
switch (progress) {
case Progress.ValidatingInputs:
case Progress.FetchingData:
description = _t("Fetching data...");
break;
case Progress.CreatingSpace:
description = _t("Creating Space...");
break;
case Progress.InvitingUsers:
default:
description = _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: numInvites,
progress,
});
break;
}
footer = <span>
<ProgressBar
value={progress > Progress.FetchingData ? progress : 0}
max={numInvites + Progress.InvitingUsers}
/>
<div className="mx_CreateSpaceFromCommunityDialog_progressText">
{ description }
</div>
</span>;
} else {
footer = <>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}>
<AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}>
{ busy ? _t("Creating...") : _t("Create Space") }
<AccessibleButton kind="primary" onClick={onCreateSpaceClick}>
{ _t("Create Space") }
</AccessibleButton>
</>;
}

View file

@ -0,0 +1,397 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useRef, useState } from "react";
import { Room } from "matrix-js-sdk/src";
import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import Field from "../elements/Field";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import StyledCheckbox from "../elements/StyledCheckbox";
import {
ExportFormat,
ExportType,
textForFormat,
textForType,
} from "../../../utils/exportUtils/exportUtils";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import HTMLExporter from "../../../utils/exportUtils/HtmlExport";
import JSONExporter from "../../../utils/exportUtils/JSONExport";
import PlainTextExporter from "../../../utils/exportUtils/PlainTextExport";
import { useStateCallback } from "../../../hooks/useStateCallback";
import Exporter from "../../../utils/exportUtils/Exporter";
import Spinner from "../elements/Spinner";
import InfoDialog from "./InfoDialog";
interface IProps extends IDialogProps {
room: Room;
}
const ExportDialog: React.FC<IProps> = ({ room, onFinished }) => {
const [exportFormat, setExportFormat] = useState(ExportFormat.Html);
const [exportType, setExportType] = useState(ExportType.Timeline);
const [includeAttachments, setAttachments] = useState(false);
const [isExporting, setExporting] = useState(false);
const [numberOfMessages, setNumberOfMessages] = useState<number>(100);
const [sizeLimit, setSizeLimit] = useState<number | null>(8);
const sizeLimitRef = useRef<Field>();
const messageCountRef = useRef<Field>();
const [exportProgressText, setExportProgressText] = useState("Processing...");
const [displayCancel, setCancelWarning] = useState(false);
const [exportCancelled, setExportCancelled] = useState(false);
const [exportSuccessful, setExportSuccessful] = useState(false);
const [exporter, setExporter] = useStateCallback<Exporter>(
null,
async (exporter: Exporter) => {
await exporter?.export().then(() => {
if (!exportCancelled) setExportSuccessful(true);
});
},
);
const startExport = async () => {
const exportOptions = {
numberOfMessages,
attachmentsIncluded: includeAttachments,
maxSize: sizeLimit * 1024 * 1024,
};
switch (exportFormat) {
case ExportFormat.Html:
setExporter(
new HTMLExporter(
room,
ExportType[exportType],
exportOptions,
setExportProgressText,
),
);
break;
case ExportFormat.Json:
setExporter(
new JSONExporter(
room,
ExportType[exportType],
exportOptions,
setExportProgressText,
),
);
break;
case ExportFormat.PlainText:
setExporter(
new PlainTextExporter(
room,
ExportType[exportType],
exportOptions,
setExportProgressText,
),
);
break;
default:
console.error("Unknown export format");
return;
}
};
const onExportClick = async () => {
const isValidSize = await sizeLimitRef.current.validate({
focused: false,
});
if (!isValidSize) {
sizeLimitRef.current.validate({ focused: true });
return;
}
if (exportType === ExportType.LastNMessages) {
const isValidNumberOfMessages =
await messageCountRef.current.validate({ focused: false });
if (!isValidNumberOfMessages) {
messageCountRef.current.validate({ focused: true });
return;
}
}
setExporting(true);
await startExport();
};
const validateSize = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t("Enter a number between %(min)s and %(max)s", {
min,
max,
});
},
}, {
key: "number",
test: ({ value }) => {
const parsedSize = parseFloat(value);
const min = 1;
const max = 2000;
return !(isNaN(parsedSize) || min > parsedSize || parsedSize > max);
},
invalid: () => {
const min = 1;
const max = 2000;
return _t(
"Size can only be a number between %(min)s MB and %(max)s MB",
{ min, max },
);
},
},
],
});
const onValidateSize = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await validateSize(fieldState);
return result;
};
const validateNumberOfMessages = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t("Enter a number between %(min)s and %(max)s", {
min,
max,
});
},
}, {
key: "number",
test: ({ value }) => {
const parsedSize = parseFloat(value);
const min = 1;
const max = 10 ** 8;
if (isNaN(parsedSize)) return false;
return !(min > parsedSize || parsedSize > max);
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t(
"Number of messages can only be a number between %(min)s and %(max)s",
{ min, max },
);
},
},
],
});
const onValidateNumberOfMessages = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await validateNumberOfMessages(fieldState);
return result;
};
const onCancel = async () => {
if (isExporting) setCancelWarning(true);
else onFinished(false);
};
const confirmCanel = async () => {
await exporter?.cancelExport();
setExportCancelled(true);
setExporting(false);
setExporter(null);
};
const exportFormatOptions = Object.keys(ExportFormat).map((format) => ({
value: ExportFormat[format],
label: textForFormat(ExportFormat[format]),
}));
const exportTypeOptions = Object.keys(ExportType).map((type) => {
return (
<option key={type} value={ExportType[type]}>
{ textForType(ExportType[type]) }
</option>
);
});
let messageCount = null;
if (exportType === ExportType.LastNMessages) {
messageCount = (
<Field
element="input"
type="number"
value={numberOfMessages.toString()}
ref={messageCountRef}
onValidate={onValidateNumberOfMessages}
label={_t("Number of messages")}
onChange={(e) => {
setNumberOfMessages(parseInt(e.target.value));
}}
/>
);
}
const sizePostFix = <span>{ _t("MB") }</span>;
if (exportCancelled) {
// Display successful cancellation message
return (
<InfoDialog
title={_t("Export Successful")}
description={_t("The export was cancelled successfully")}
hasCloseButton={true}
onFinished={onFinished}
/>
);
} else if (exportSuccessful) {
// Display successful export message
return (
<InfoDialog
title={_t("Export Successful")}
description={_t(
"Your export was successful. Find it in your Downloads folder.",
)}
hasCloseButton={true}
onFinished={onFinished}
/>
);
} else if (displayCancel) {
// Display cancel warning
return (
<BaseDialog
title={_t("Warning")}
className="mx_ExportDialog"
contentId="mx_Dialog_content"
onFinished={onFinished}
fixedWidth={true}
>
<p>
{ _t(
"Are you sure you want to stop exporting your data? If you do, you'll need to start over.",
) }
</p>
<DialogButtons
primaryButton={_t("Stop")}
primaryButtonClass="danger"
hasCancel={true}
cancelButton={_t("Continue")}
onCancel={() => setCancelWarning(false)}
onPrimaryButtonClick={confirmCanel}
/>
</BaseDialog>
);
} else {
// Display export settings
return (
<BaseDialog
title={isExporting ? _t("Exporting your data") : _t("Export Chat")}
className={`mx_ExportDialog ${isExporting && "mx_ExportDialog_Exporting"}`}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={onFinished}
fixedWidth={true}
>
{ !isExporting ? <p>
{ _t(
"Select from the options below to export chats from your timeline",
) }
</p> : null }
<span className="mx_ExportDialog_subheading">
{ _t("Format") }
</span>
<div className="mx_ExportDialog_options">
<StyledRadioGroup
name="exportFormat"
value={exportFormat}
onChange={(key) => setExportFormat(ExportFormat[key])}
definitions={exportFormatOptions}
/>
<span className="mx_ExportDialog_subheading">
{ _t("Messages") }
</span>
<Field
element="select"
value={exportType}
onChange={(e) => {
setExportType(ExportType[e.target.value]);
}}
>
{ exportTypeOptions }
</Field>
{ messageCount }
<span className="mx_ExportDialog_subheading">
{ _t("Size Limit") }
</span>
<Field
type="number"
autoComplete="off"
onValidate={onValidateSize}
element="input"
ref={sizeLimitRef}
value={sizeLimit.toString()}
postfixComponent={sizePostFix}
onChange={(e) => setSizeLimit(parseInt(e.target.value))}
/>
<StyledCheckbox
checked={includeAttachments}
onChange={(e) =>
setAttachments(
(e.target as HTMLInputElement).checked,
)
}
>
{ _t("Include Attachments") }
</StyledCheckbox>
</div>
{ isExporting ? (
<div className="mx_ExportDialog_progress">
<Spinner w={24} h={24} />
<p>
{ exportProgressText }
</p>
<DialogButtons
primaryButton={_t("Cancel")}
primaryButtonClass="danger"
hasCancel={false}
onPrimaryButtonClick={onCancel}
/>
</div>
) : (
<DialogButtons
primaryButton={_t("Export")}
onPrimaryButtonClick={onExportClick}
onCancel={() => onFinished(false)}
/>
) }
</BaseDialog>
);
}
};
export default ExportDialog;

View file

@ -44,18 +44,31 @@ interface IProps {
initialTabId?: string;
}
interface IState {
roomName: string;
}
@replaceableComponent("views.dialogs.RoomSettingsDialog")
export default class RoomSettingsDialog extends React.Component<IProps> {
export default class RoomSettingsDialog extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.state = { roomName: '' };
}
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
this.onRoomName();
}
public componentWillUnmount() {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
}
private onAction = (payload): void => {
@ -66,6 +79,12 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
}
};
private onRoomName = (): void => {
this.setState({
roomName: MatrixClientPeg.get().getRoom(this.props.roomId).name,
});
};
private getTabs(): Tab[] {
const tabs: Tab[] = [];
@ -122,7 +141,7 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
}
render() {
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
const roomName = this.state.roomName;
return (
<BaseDialog
className='mx_RoomSettingsDialog'

View file

@ -268,7 +268,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
};
const buttonRect = handle.current.getBoundingClientRect();
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu}>
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu} focusLock>
<div className="mx_NetworkDropdown_menu">
{ options }
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>

View file

@ -178,6 +178,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev;
};
private onChevronClick = (ev: React.MouseEvent) => {
if (this.state.expanded) {
this.setState({ expanded: false });
ev.stopPropagation();
ev.preventDefault();
}
};
private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return;
@ -375,7 +383,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown}
>
{ currentValue }
<span className="mx_Dropdown_arrow" />
<span onClick={this.onChevronClick} className="mx_Dropdown_arrow" />
{ menu }
</AccessibleButton>
</div>;

View file

@ -53,6 +53,7 @@ interface IProps {
layout?: Layout;
// Whether to always show a timestamp
alwaysShowTimestamps?: boolean;
forExport?: boolean;
isQuoteExpanded?: boolean;
setQuoteExpanded: (isExpanded: boolean) => void;
}
@ -381,6 +382,17 @@ export default class ReplyThread extends React.Component<IProps, IState> {
})
}
</blockquote>;
} else if (this.props.forExport) {
const eventId = ReplyThread.getParentEventId(this.props.parentEv);
header = <p className="mx_ReplyThread_Export">
{ _t("In reply to <a>this message</a>",
{},
{ a: (sub) => (
<a className="mx_reply_anchor" href={`#${eventId}`} scroll-to={eventId}> { sub } </a>
),
})
}
</p>;
} else if (this.state.loading) {
header = <Spinner w={16} h={16} />;
}

View file

@ -35,12 +35,17 @@ function getDaysArray(): string[] {
interface IProps {
ts: number;
forExport?: boolean;
}
@replaceableComponent("views.messages.DateSeparator")
export default class DateSeparator extends React.Component<IProps> {
private getLabel() {
const date = new Date(this.props.ts);
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
if (this.props.forExport) return formatFullDateNoTime(date);
const today = new Date();
const yesterday = new Date();
const days = getDaysArray();

View file

@ -33,6 +33,7 @@ export interface IBodyProps {
onHeightChanged: () => void;
showUrlPreview?: boolean;
forExport?: boolean;
tileShape: TileShape;
maxImageHeight?: number;
replacingEventId?: string;

View file

@ -90,6 +90,17 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
);
}
if (this.props.forExport) {
const content = this.props.mxEvent.getContent();
// During export, the content url will point to the MSC, which will later point to a local url
const contentUrl = content.file?.url || content.url;
return (
<span className="mx_MAudioBody">
<audio src={contentUrl} controls />
</span>
);
}
if (!this.state.playback) {
return (
<span className="mx_MAudioBody">

View file

@ -123,6 +123,11 @@ export default class MFileBody extends React.Component<IProps, IState> {
this.state = {};
}
private getContentUrl(): string | null {
if (this.props.forExport) return null;
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
}
private get content(): IMediaEventContent {
return this.props.mxEvent.getContent<IMediaEventContent>();
}
@ -149,11 +154,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
});
}
private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
}
public componentDidUpdate(prevProps, prevState) {
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
this.props.onHeightChanged();
@ -213,6 +213,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
);
}
if (this.props.forExport) {
const content = this.props.mxEvent.getContent();
// During export, the content url will point to the MSC, which will later point to a local url
return <span className="mx_MFileBody">
<a href={content.file?.url || content.url}>
{ placeholder }
</a>
</span>;
}
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
if (isEncrypted) {

View file

@ -179,6 +179,9 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
};
protected getContentUrl(): string {
const content: IMediaEventContent = this.props.mxEvent.getContent();
// During export, the content url will point to the MSC, which will later point to a local url
if (this.props.forExport) return content.url || content.file?.url;
if (this.media.isEncrypted) {
return this.state.decryptedUrl;
} else {
@ -372,7 +375,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
let placeholder = null;
let gifLabel = null;
if (!this.state.imgLoaded) {
if (!this.props.forExport && !this.state.imgLoaded) {
placeholder = this.getPlaceholder(maxWidth, maxHeight);
}
@ -462,7 +465,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody
protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
return <a href={contentUrl} onClick={this.onClick}>
return <a href={contentUrl} target={this.props.forExport ? "_blank" : undefined} onClick={this.onClick}>
{ children }
</a>;
}
@ -490,6 +493,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody
protected getFileBody(): string | JSX.Element {
if (this.props.forExport) return null;
// We only ever need the download bar if we're appearing outside of the timeline
if (this.props.tileShape) {
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
@ -510,7 +514,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
const contentUrl = this.getContentUrl();
let thumbUrl;
if (this.isGif() && SettingsStore.getValue("autoplayGifs")) {
if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifs"))) {
thumbUrl = contentUrl;
} else {
thumbUrl = this.getThumbUrl();

View file

@ -79,7 +79,10 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
}
private getContentUrl(): string|null {
const media = mediaFromContent(this.props.mxEvent.getContent());
const content = this.props.mxEvent.getContent<IMediaEventContent>();
// During export, the content url will point to the MSC, which will later point to a local url
if (this.props.forExport) return content.file?.url || content.url;
const media = mediaFromContent(content);
if (media.isEncrypted) {
return this.state.decryptedUrl;
} else {
@ -93,6 +96,9 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
}
private getThumbUrl(): string|null {
// there's no need of thumbnail when the content is local
if (this.props.forExport) return null;
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content);
@ -209,6 +215,11 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
this.props.onHeightChanged();
};
private getFileBody = () => {
if (this.props.forExport) return null;
return this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} />;
};
render() {
const content = this.props.mxEvent.getContent();
const autoplay = SettingsStore.getValue("autoplayVideo");
@ -222,8 +233,8 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
);
}
// Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster.
if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
// Important: If we aren't autoplaying and we haven't decrypted it yet, show a video with a poster.
if (!this.props.forExport && content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
@ -254,6 +265,8 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
preload = "none";
}
}
const fileBody = this.getFileBody();
return (
<span className="mx_MVideoBody">
<video
@ -270,7 +283,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
poster={poster}
onPlay={this.videoOnPlay}
/>
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
{ fileBody }
</span>
);
}

View file

@ -24,7 +24,7 @@ import { isVoiceMessage } from "../../../utils/EventUtils";
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
public render() {
if (isVoiceMessage(this.props.mxEvent)) {
if (!this.props.forExport && isVoiceMessage(this.props.mxEvent)) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;

View file

@ -27,7 +27,7 @@ import { Action } from '../../../dispatcher/actions';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -128,11 +128,6 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
</React.Fragment>;
};
export enum ActionBarRenderingContext {
Room,
Thread
}
interface IMessageActionBarProps {
mxEvent: MatrixEvent;
reactions?: Relations;
@ -142,7 +137,6 @@ interface IMessageActionBarProps {
permalinkCreator?: RoomPermalinkCreator;
onFocusChange?: (menuDisplayed: boolean) => void;
toggleThreadExpanded: () => void;
renderingContext?: ActionBarRenderingContext;
isQuoteExpanded?: boolean;
}
@ -150,10 +144,6 @@ interface IMessageActionBarProps {
export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> {
public static contextType = RoomContext;
public static defaultProps = {
renderingContext: ActionBarRenderingContext.Room,
};
public componentDidMount(): void {
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on("Event.status", this.onSent);
@ -217,8 +207,9 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
private onEditClick = (ev: React.MouseEvent): void => {
dis.dispatch({
action: 'edit_event',
action: Action.EditEvent,
event: this.props.mxEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
};
@ -298,7 +289,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply && this.props.renderingContext === ActionBarRenderingContext.Room) {
if (this.context.canReply && this.context.timelineRenderingType === TimelineRenderingType.Room) {
toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"

View file

@ -136,6 +136,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
forExport={this.props.forExport}
maxImageHeight={this.props.maxImageHeight}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}

View file

@ -29,7 +29,6 @@ interface IProps {
const RedactedBody = React.forwardRef<any, IProps | IBodyProps>(({ mxEvent }, ref) => {
const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("Message deleted");
const unsigned = mxEvent.getUnsigned();
const redactedBecauseUserId = unsigned && unsigned.redacted_because && unsigned.redacted_because.sender;

View file

@ -49,16 +49,18 @@ const EncryptionInfo: React.FC<IProps> = ({
isSelfVerification,
}: IProps) => {
let content: JSX.Element;
if (waitingForOtherParty || waitingForNetwork) {
if (waitingForOtherParty && isSelfVerification) {
content = (
<div>
{ _t("To proceed, please accept the verification request on your other login.") }
</div>
);
} else if (waitingForOtherParty || waitingForNetwork) {
let text: string;
if (waitingForOtherParty) {
if (isSelfVerification) {
text = _t("Accept on your other login…");
} else {
text = _t("Waiting for %(displayName)s to accept…", {
displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
});
}
text = _t("Waiting for %(displayName)s to accept…", {
displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
});
} else {
text = _t("Accepting…");
}

View file

@ -47,6 +47,7 @@ import { useRoomMemberCount } from "../../../hooks/useRoomMembers";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import RoomName from "../elements/RoomName";
import UIStore from "../../../stores/UIStore";
import ExportDialog from "../dialogs/ExportDialog";
interface IProps {
room: Room;
@ -240,6 +241,12 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
});
};
const onRoomExportClick = async () => {
Modal.createTrackedDialog('export room dialog', '', ExportDialog, {
room,
});
};
const isRoomEncrypted = useIsEncrypted(cli, room);
const roomContext = useContext(RoomContext);
const e2eStatus = roomContext.e2eStatus;
@ -280,6 +287,9 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
{ _t("Show files") }
</Button>
<Button className="mx_RoomSummaryCard_icon_export" onClick={onRoomExportClick}>
{ _t("Export chat") }
</Button>
{ SettingsStore.getValue("feature_thread") && (
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
{ _t("Show threads") }

View file

@ -871,7 +871,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
const isMe = me.userId === member.userId;
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton
room={room}
member={member}
@ -884,7 +884,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
}
if (canAffectUser && me.powerLevel >= banPowerLevel) {
if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton
room={room}
member={member}
@ -892,7 +892,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
stopUpdating={stopUpdating}
/>;
}
if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}

View file

@ -35,7 +35,7 @@ interface IState {
avatarFile: File;
originalTopic: string;
topic: string;
enableProfileSave: boolean;
profileFieldsTouched: Record<string, boolean>;
canSetName: boolean;
canSetTopic: boolean;
canSetAvatar: boolean;
@ -71,7 +71,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
avatarFile: null,
originalTopic: topic,
topic: topic,
enableProfileSave: false,
profileFieldsTouched: {},
canSetName: room.currentState.maySendStateEvent('m.room.name', client.getUserId()),
canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()),
canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()),
@ -88,17 +88,24 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({
avatarUrl: null,
avatarFile: null,
enableProfileSave: true,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
private isSaveEnabled = () => {
return Boolean(Object.values(this.state.profileFieldsTouched).length);
};
private cancelProfileChanges = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
if (!this.isSaveEnabled()) return;
this.setState({
enableProfileSave: false,
profileFieldsTouched: {},
displayName: this.state.originalDisplayName,
topic: this.state.originalTopic,
avatarUrl: this.state.originalAvatarUrl,
@ -110,8 +117,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
e.stopPropagation();
e.preventDefault();
if (!this.state.enableProfileSave) return;
this.setState({ enableProfileSave: false });
if (!this.isSaveEnabled()) return;
this.setState({ profileFieldsTouched: {} });
const client = MatrixClientPeg.get();
@ -156,18 +163,38 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
private onDisplayNameChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ displayName: e.target.value });
if (this.state.originalDisplayName === e.target.value) {
this.setState({ enableProfileSave: false });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
name: false,
},
});
} else {
this.setState({ enableProfileSave: true });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
name: true,
},
});
}
};
private onTopicChanged = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ topic: e.target.value });
if (this.state.originalTopic === e.target.value) {
this.setState({ enableProfileSave: false });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
topic: false,
},
});
} else {
this.setState({ enableProfileSave: true });
this.setState({
profileFieldsTouched: {
...this.state.profileFieldsTouched,
topic: true,
},
});
}
};
@ -176,7 +203,10 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({
avatarUrl: this.state.originalAvatarUrl,
avatarFile: null,
enableProfileSave: false,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: false,
},
});
return;
}
@ -187,7 +217,10 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
this.setState({
avatarUrl: String(ev.target.result),
avatarFile: file,
enableProfileSave: true,
profileFieldsTouched: {
...this.state.profileFieldsTouched,
avatar: true,
},
});
};
reader.readAsDataURL(file);
@ -205,14 +238,14 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
<AccessibleButton
onClick={this.cancelProfileChanges}
kind="link"
disabled={!this.state.enableProfileSave}
disabled={!this.isSaveEnabled()}
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
onClick={this.saveProfile}
kind="primary"
disabled={!this.state.enableProfileSave}
disabled={!this.isSaveEnabled()}
>
{ _t("Save") }
</AccessibleButton>

View file

@ -28,7 +28,6 @@ import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics";
@ -36,7 +35,7 @@ import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindin
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SendHistoryManager from '../../../SendHistoryManager';
import Modal from '../../../Modal';
import { MsgType } from 'matrix-js-sdk/src/@types/event';
import { MsgType, UNSTABLE_ELEMENT_REPLY_IN_THREAD } from 'matrix-js-sdk/src/@types/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
@ -46,6 +45,8 @@ import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
import SettingsStore from "../../../settings/SettingsStore";
import { logger } from "matrix-js-sdk/src/logger";
import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext';
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
@ -66,7 +67,11 @@ function getTextReplyFallback(mxEvent: MatrixEvent): string {
return "";
}
function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IContent {
function createEditContent(
model: EditorModel,
editedEvent: MatrixEvent,
renderingContext?: TimelineRenderingType,
): IContent {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
@ -99,41 +104,49 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
contentBody.formatted_body = `${htmlPrefix} * ${formattedBody}`;
}
return Object.assign({
const relation = {
"m.new_content": newContent,
"m.relates_to": {
"rel_type": "m.replace",
"event_id": editedEvent.getId(),
},
}, contentBody);
};
if (renderingContext === TimelineRenderingType.Thread) {
relation['m.relates_to'][UNSTABLE_ELEMENT_REPLY_IN_THREAD.name] = true;
}
return Object.assign(relation, contentBody);
}
interface IProps {
interface IEditMessageComposerProps extends MatrixClientProps {
editState: EditorStateTransfer;
className?: string;
}
interface IState {
saveDisabled: boolean;
}
@replaceableComponent("views.rooms.EditMessageComposer")
export default class EditMessageComposer extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
class EditMessageComposer extends React.Component<IEditMessageComposerProps, IState> {
static contextType = RoomContext;
context!: React.ContextType<typeof RoomContext>;
private readonly editorRef = createRef<BasicMessageComposer>();
private readonly dispatcherRef: string;
private model: EditorModel = null;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
constructor(props: IEditMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above
const isRestored = this.createEditorModel();
const ev = this.props.editState.getEvent();
const renderingContext = this.context.timelineRenderingType;
const editContent = createEditContent(this.model, ev, renderingContext);
this.state = {
saveDisabled: !isRestored || !this.isContentModified(createEditContent(this.model, ev)["m.new_content"]),
saveDisabled: !isRestored || !this.isContentModified(editContent["m.new_content"]),
};
window.addEventListener("beforeunload", this.saveStoredEditorState);
@ -141,7 +154,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
}
private getRoom(): Room {
return this.context.getRoom(this.props.editState.getEvent().getRoomId());
return this.props.mxClient.getRoom(this.props.editState.getEvent().getRoomId());
}
private onKeyDown = (event: KeyboardEvent): void => {
@ -162,10 +175,17 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) {
return;
}
const previousEvent = findEditableEvent(this.getRoom(), false,
this.props.editState.getEvent().getId());
const previousEvent = findEditableEvent({
events: this.events,
isForward: false,
fromEventId: this.props.editState.getEvent().getId(),
});
if (previousEvent) {
dis.dispatch({ action: 'edit_event', event: previousEvent });
dis.dispatch({
action: Action.EditEvent,
event: previousEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
event.preventDefault();
}
break;
@ -174,12 +194,24 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) {
return;
}
const nextEvent = findEditableEvent(this.getRoom(), true, this.props.editState.getEvent().getId());
const nextEvent = findEditableEvent({
events: this.events,
isForward: true,
fromEventId: this.props.editState.getEvent().getId(),
});
if (nextEvent) {
dis.dispatch({ action: 'edit_event', event: nextEvent });
dis.dispatch({
action: Action.EditEvent,
event: nextEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
} else {
this.clearStoredEditorState();
dis.dispatch({ action: 'edit_event', event: null });
dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer);
}
event.preventDefault();
@ -189,16 +221,27 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
};
private get editorRoomKey(): string {
return `mx_edit_room_${this.getRoom().roomId}`;
return `mx_edit_room_${this.getRoom().roomId}_${this.context.timelineRenderingType}`;
}
private get editorStateKey(): string {
return `mx_edit_state_${this.props.editState.getEvent().getId()}`;
}
private get events(): MatrixEvent[] {
const liveTimelineEvents = this.context.liveTimeline.getEvents();
const pendingEvents = this.getRoom().getPendingEvents();
const isInThread = Boolean(this.props.editState.getEvent().getThread());
return liveTimelineEvents.concat(isInThread ? [] : pendingEvents);
}
private cancelEdit = (): void => {
this.clearStoredEditorState();
dis.dispatch({ action: "edit_event", event: null });
dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer);
};
@ -326,8 +369,8 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const editContent = createEditContent(this.model, editedEvent);
const renderingContext = this.context.timelineRenderingType;
const editContent = createEditContent(this.model, editedEvent, renderingContext);
const newContent = editContent["m.new_content"];
let shouldSend = true;
@ -381,7 +424,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
}
if (shouldSend) {
this.cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent);
const prom = this.props.mxClient.sendMessage(roomId, editContent);
this.clearStoredEditorState();
dis.dispatch({ action: "message_sent" });
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
@ -389,7 +432,11 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
}
// close the event editing and focus composer
dis.dispatch({ action: "edit_event", event: null });
dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer);
};
@ -400,7 +447,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
previousEdit.status === EventStatus.QUEUED ||
previousEdit.status === EventStatus.NOT_SENT
)) {
this.context.cancelPendingEvent(previousEdit);
this.props.mxClient.cancelPendingEvent(previousEdit);
}
}
@ -428,7 +475,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private createEditorModel(): boolean {
const { editState } = this.props;
const room = this.getRoom();
const partCreator = new CommandPartCreator(room, this.context);
const partCreator = new CommandPartCreator(room, this.props.mxClient);
let parts;
let isRestored = false;
@ -493,3 +540,6 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
</div>);
}
}
const EditMessageComposerWithMatrixClient = withMatrixClientHOC(EditMessageComposer);
export default EditMessageComposerWithMatrixClient;

View file

@ -53,7 +53,7 @@ import SenderProfile from '../messages/SenderProfile';
import MessageTimestamp from '../messages/MessageTimestamp';
import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar, { ActionBarRenderingContext } from "../messages/MessageActionBar";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
@ -264,6 +264,8 @@ interface IProps {
// for now.
tileShape?: TileShape;
forExport?: boolean;
// show twelve hour timestamps
isTwelveHour?: boolean;
@ -340,6 +342,7 @@ export default class EventTile extends React.Component<IProps, IState> {
static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
onHeightChanged: function() {},
forExport: false,
layout: Layout.Group,
};
@ -382,7 +385,7 @@ export default class EventTile extends React.Component<IProps, IState> {
* or 'sent' receipt, for example.
* @returns {boolean}
*/
private get isEligibleForSpecialReceipt() {
private get isEligibleForSpecialReceipt(): boolean {
// First, if there are other read receipts then just short-circuit this.
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
if (!this.props.mxEvent) return false;
@ -453,16 +456,18 @@ export default class EventTile extends React.Component<IProps, IState> {
componentDidMount() {
this.suppressReadReceiptAnimation = false;
const client = this.context;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
}
if (!this.props.forExport) {
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
}
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
}
if (SettingsStore.getValue("feature_thread")) {
@ -698,6 +703,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}
shouldHighlight() {
if (this.props.forExport) return false;
const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent);
if (!actions || !actions.tweaks) { return false; }
@ -1056,17 +1062,14 @@ export default class EventTile extends React.Component<IProps, IState> {
}
}
const renderingContext = this.props.tileShape === TileShape.Thread
? ActionBarRenderingContext.Thread
: ActionBarRenderingContext.Room;
const actionBar = !isEditing ? <MessageActionBar
const showMessageActionBar = !isEditing && !this.props.forExport;
const actionBar = showMessageActionBar ? <MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile}
getReplyThread={this.getReplyThread}
onFocusChange={this.onActionBarFocusChange}
renderingContext={renderingContext}
isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
/> : undefined;
@ -1171,6 +1174,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape}
editState={this.props.editState}
/>
</div>,
]);
@ -1204,6 +1208,8 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape}
editState={this.props.editState}
replacingEventId={this.props.replacingEventId}
/>
{ actionBar }
</div>,
@ -1224,6 +1230,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged}
editState={this.props.editState}
/>
</div>,
<a
@ -1247,6 +1254,7 @@ export default class EventTile extends React.Component<IProps, IState> {
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}
ref={this.replyThread}
forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
@ -1280,6 +1288,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ thread }
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
forExport={this.props.forExport}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}
highlights={this.props.highlights}
@ -1305,7 +1314,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// XXX this'll eventually be dynamic based on the fields once we have extensible event types
const messageTypes = ['m.room.message', 'm.sticker'];
function isMessageEvent(ev) {
function isMessageEvent(ev: MatrixEvent): boolean {
return (messageTypes.includes(ev.getType()));
}

View file

@ -45,7 +45,7 @@ import { RecordingState } from "../../../audio/VoiceRecording";
import Tooltip, { Alignment } from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer";
import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
@ -219,8 +219,8 @@ interface IState {
@replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number;
@ -378,14 +378,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
private sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton) {
if (this.state.haveRecording && this.voiceRecordingButton.current) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
await this.voiceRecordingButton.send();
await this.voiceRecordingButton.current?.send();
return;
}
this.messageComposerInput.sendMessage();
this.messageComposerInput.current?.sendMessage();
};
private onChange = (model: EditorModel) => {
@ -460,7 +460,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
buttons.push(
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voiceMessage"
onClick={() => this.voiceRecordingButton?.onRecordStartEndClick()}
onClick={() => this.voiceRecordingButton.current?.onRecordStartEndClick()}
title={_t("Send voice message")}
/>,
);
@ -521,7 +521,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
if (!this.state.tombstone && this.state.canSendMessages) {
controls.push(
<SendMessageComposer
ref={(c) => this.messageComposerInput = c}
ref={this.messageComposerInput}
key="controls_input"
room={this.props.room}
placeholder={this.renderPlaceholderText()}
@ -535,7 +535,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
ref={c => this.voiceRecordingButton = c}
ref={this.voiceRecordingButton}
room={this.props.room} />);
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];

View file

@ -35,6 +35,8 @@ import InviteReason from "../elements/InviteReason";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
import { UIFeature } from "../../../settings/UIFeature";
import SettingsStore from "../../../settings/SettingsStore";
const MemberEventHtmlReasonField = "io.element.html_reason";
@ -339,8 +341,10 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
}
case MessageCase.NotLoggedIn: {
title = _t("Join the conversation with an account");
primaryActionLabel = _t("Sign Up");
primaryActionHandler = this.onRegisterClick;
if (SettingsStore.getValue(UIFeature.Registration)) {
primaryActionLabel = _t("Sign Up");
primaryActionHandler = this.onRegisterClick;
}
secondaryActionLabel = _t("Sign In");
secondaryActionHandler = this.onLoginClick;
if (this.props.previewLoading) {

View file

@ -29,10 +29,9 @@ import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextM
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs";
import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import NotificationBadge from "./NotificationBadge";
import { Volume } from "../../../RoomNotifsTypes";
import RoomListStore from "../../../stores/room-list/RoomListStore";
import RoomListActions from "../../../actions/RoomListActions";
import { ActionPayload } from "../../../dispatcher/payloads";
@ -364,7 +363,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.setState({ generalMenuPosition: null }); // hide the menu
};
private async saveNotifState(ev: ButtonEvent, newState: Volume) {
private async saveNotifState(ev: ButtonEvent, newState: RoomNotifState) {
ev.preventDefault();
ev.stopPropagation();
if (MatrixClientPeg.get().isGuest()) return;
@ -378,10 +377,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
}
}
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
private onClickAlertMe = ev => this.saveNotifState(ev, ALL_MESSAGES_LOUD);
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
private onClickMute = ev => this.saveNotifState(ev, MUTE);
private onClickAllNotifs = ev => this.saveNotifState(ev, RoomNotifState.AllMessages);
private onClickAlertMe = ev => this.saveNotifState(ev, RoomNotifState.AllMessagesLoud);
private onClickMentions = ev => this.saveNotifState(ev, RoomNotifState.MentionsOnly);
private onClickMute = ev => this.saveNotifState(ev, RoomNotifState.Mute);
private renderNotificationsMenu(isActive: boolean): React.ReactElement {
if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived ||
@ -404,25 +403,25 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
<IconizedContextMenuOptionList first>
<IconizedContextMenuRadio
label={_t("Use default")}
active={state === ALL_MESSAGES}
active={state === RoomNotifState.AllMessages}
iconClassName="mx_RoomTile_iconBell"
onClick={this.onClickAllNotifs}
/>
<IconizedContextMenuRadio
label={_t("All messages")}
active={state === ALL_MESSAGES_LOUD}
active={state === RoomNotifState.AllMessagesLoud}
iconClassName="mx_RoomTile_iconBellDot"
onClick={this.onClickAlertMe}
/>
<IconizedContextMenuRadio
label={_t("Mentions & Keywords")}
active={state === MENTIONS_ONLY}
active={state === RoomNotifState.MentionsOnly}
iconClassName="mx_RoomTile_iconBellMentions"
onClick={this.onClickMentions}
/>
<IconizedContextMenuRadio
label={_t("None")}
active={state === MUTE}
active={state === RoomNotifState.Mute}
iconClassName="mx_RoomTile_iconBellCrossed"
onClick={this.onClickMute}
/>
@ -432,14 +431,14 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
const classes = classNames("mx_RoomTile_notificationsButton", {
// Show bell icon for the default case too.
mx_RoomTile_iconBell: state === ALL_MESSAGES,
mx_RoomTile_iconBellDot: state === ALL_MESSAGES_LOUD,
mx_RoomTile_iconBellMentions: state === MENTIONS_ONLY,
mx_RoomTile_iconBellCrossed: state === MUTE,
mx_RoomTile_iconBell: state === RoomNotifState.AllMessages,
mx_RoomTile_iconBellDot: state === RoomNotifState.AllMessagesLoud,
mx_RoomTile_iconBellMentions: state === RoomNotifState.MentionsOnly,
mx_RoomTile_iconBellCrossed: state === RoomNotifState.Mute,
// Only show the icon by default if the room is overridden to muted.
// TODO: [FTUE Notifications] Probably need to detect global mute state
mx_RoomTile_notificationsButton_show: state === MUTE,
mx_RoomTile_notificationsButton_show: state === RoomNotifState.Mute,
});
return (

View file

@ -19,6 +19,7 @@ import EMOJI_REGEX from 'emojibase-regex';
import { IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { DebouncedFunc, throttle } from 'lodash';
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger";
import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model';
@ -40,7 +41,7 @@ import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import Modal from '../../../Modal';
import { _t, _td } from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions";
import { containsEmoji } from "../../../effects/utils";
import { CHAT_EFFECTS } from '../../../effects';
@ -55,8 +56,7 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
import { logger } from "matrix-js-sdk/src/logger";
import RoomContext from '../../../contexts/RoomContext';
function addReplyToMessageContent(
content: IContent,
@ -130,7 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean {
return false;
}
interface IProps {
interface ISendMessageComposerProps extends MatrixClientProps {
room: Room;
placeholder?: string;
permalinkCreator: RoomPermalinkCreator;
@ -141,10 +141,8 @@ interface IProps {
}
@replaceableComponent("views.rooms.SendMessageComposer")
export default class SendMessageComposer extends React.Component<IProps> {
static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
static contextType = RoomContext;
private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
private readonly editorRef = createRef<BasicMessageComposer>();
private model: EditorModel = null;
@ -152,26 +150,25 @@ export default class SendMessageComposer extends React.Component<IProps> {
private dispatcherRef: string;
private sendHistoryManager: SendHistoryManager;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
constructor(props: ISendMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above
if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) {
if (this.props.mxClient.isCryptoEnabled() && this.props.mxClient.isRoomEncrypted(this.props.room.roomId)) {
this.prepareToEncrypt = throttle(() => {
this.context.prepareToEncrypt(this.props.room);
this.props.mxClient.prepareToEncrypt(this.props.room);
}, 60000, { leading: true, trailing: false });
}
window.addEventListener("beforeunload", this.saveStoredEditorState);
}
public componentDidUpdate(prevProps: IProps): void {
public componentDidUpdate(prevProps: ISendMessageComposerProps): void {
const replyToEventChanged = this.props.replyInThread && (this.props.replyToEvent !== prevProps.replyToEvent);
if (replyToEventChanged) {
this.model.reset([]);
}
if (this.props.replyInThread && this.props.replyToEvent && (!prevProps.replyToEvent || replyToEventChanged)) {
const partCreator = new CommandPartCreator(this.props.room, this.context);
const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient);
const parts = this.restoreStoredEditorState(partCreator) || [];
this.model.reset(parts);
this.editorRef.current?.focus();
@ -202,13 +199,20 @@ export default class SendMessageComposer extends React.Component<IProps> {
case MessageComposerAction.EditPrevMessage:
// selection must be collapsed and caret at start
if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) {
const editEvent = findEditableEvent(this.props.room, false);
const events =
this.context.liveTimeline.getEvents()
.concat(this.props.replyInThread ? [] : this.props.room.getPendingEvents());
const editEvent = findEditableEvent({
events,
isForward: false,
});
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
event.preventDefault();
dis.dispatch({
action: 'edit_event',
action: Action.EditEvent,
event: editEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
}
}
@ -275,7 +279,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
}
private sendQuickReaction(): void {
const timeline = this.props.room.getLiveTimeline();
const timeline = this.context.liveTimeline();
const events = timeline.getEvents();
const reaction = this.model.parts[1].text;
for (let i = events.length - 1; i >= 0; i--) {
@ -448,7 +452,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
decorateStartSendingTime(content);
}
const prom = this.context.sendMessage(roomId, content);
const prom = this.props.mxClient.sendMessage(roomId, content);
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
@ -465,7 +469,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
});
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then(resp => {
sendRoundTripMetric(this.context, roomId, resp.event_id);
sendRoundTripMetric(this.props.mxClient, roomId, resp.event_id);
});
}
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
@ -490,7 +494,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line
const partCreator = new CommandPartCreator(this.props.room, this.context);
const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient);
const parts = this.restoreStoredEditorState(partCreator) || [];
this.model = new EditorModel(parts, partCreator);
this.dispatcherRef = dis.register(this.onAction);
@ -577,7 +581,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
// it puts the filename in as text/plain which we want to ignore.
if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) {
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(clipboardData.files), this.props.room.roomId, this.context,
Array.from(clipboardData.files), this.props.room.roomId, this.props.mxClient,
);
return true; // to skip internal onPaste handler
}
@ -608,3 +612,6 @@ export default class SendMessageComposer extends React.Component<IProps> {
);
}
}
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
export default SendMessageComposerWithMatrixClient;

View file

@ -97,7 +97,7 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
if (roomSupportsRestricted || preferredRestrictionVersion || joinRule === JoinRule.Restricted) {
let upgradeRequiredPill;
if (preferredRestrictionVersion) {
upgradeRequiredPill = <span className="mx_SecurityRoomSettingsTab_upgradeRequired">
upgradeRequiredPill = <span className="mx_JoinRuleSettings_upgradeRequired">
{ _t("Upgrade required") }
</span>;
}
@ -159,13 +159,14 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
disabled={disabled}
onClick={onEditRestrictedClick}
kind="link"
className="mx_JoinRuleSettings_linkButton"
>
{ sub }
</AccessibleButton>,
}) }
</span>
<div className="mx_SecurityRoomSettingsTab_spacesWithAccess">
<div className="mx_JoinRuleSettings_spacesWithAccess">
<h4>{ _t("Spaces with access") }</h4>
{ shownSpaces.map(room => {
return <span key={room.roomId}>
@ -286,6 +287,7 @@ const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSet
onChange={onChange}
definitions={definitions}
disabled={disabled}
className="mx_JoinRuleSettings_radioButton"
/>
);
};

View file

@ -17,9 +17,8 @@ limitations under the License.
import React, { ComponentProps, RefObject, SyntheticEvent, KeyboardEvent, useContext, useRef, useState } from "react";
import classNames from "classnames";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
@ -361,9 +360,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
wrapperClassName="mx_SpaceCreateMenu_wrapper"
managed={false}
>
<FocusLock returnFocus={true}>
{ body }
</FocusLock>
{ body }
</ContextMenu>;
};

View file

@ -121,24 +121,24 @@ export default class VerificationShowSas extends React.Component<IProps, IState>
}
let confirm;
if (this.state.pending || this.state.cancelling) {
if (this.state.pending && this.props.isSelf) {
let text;
// device shouldn't be null in this situation but it can be, eg. if the device is
// logged out during verification
if (this.props.device) {
text = _t("Waiting for you to verify on your other session, %(deviceName)s (%(deviceId)s)…", {
deviceName: this.props.device ? this.props.device.getDisplayName() : '',
deviceId: this.props.device ? this.props.device.deviceId : '',
});
} else {
text = _t("Waiting for you to verify on your other session…");
}
confirm = <p>{ text }</p>;
} else if (this.state.pending || this.state.cancelling) {
let text;
if (this.state.pending) {
if (this.props.isSelf) {
// device shouldn't be null in this situation but it can be, eg. if the device is
// logged out during verification
if (this.props.device) {
text = _t("Waiting for your other session, %(deviceName)s (%(deviceId)s), to verify…", {
deviceName: this.props.device ? this.props.device.getDisplayName() : '',
deviceId: this.props.device ? this.props.device.deviceId : '',
});
} else {
text = _t("Waiting for your other session to verify…");
}
} else {
const { displayName } = this.props;
text = _t("Waiting for %(displayName)s to verify…", { displayName });
}
const { displayName } = this.props;
text = _t("Waiting for %(displayName)s to verify…", { displayName });
} else {
text = _t("Cancelling…");
}