Merge branch 'develop' into sort-imports

Signed-off-by: Aaron Raimist <aaron@raim.ist>
This commit is contained in:
Aaron Raimist 2021-12-09 08:34:20 +00:00
commit 7b94e13a84
642 changed files with 30052 additions and 8035 deletions

View file

@ -21,7 +21,6 @@ import { EventEmitter } from 'events';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
export enum CallEventGrouperEvent {
StateChanged = "state_changed",
@ -53,8 +52,8 @@ export default class CallEventGrouper extends EventEmitter {
constructor() {
super();
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
CallHandler.instance.addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
}
private get invite(): MatrixEvent {
@ -115,7 +114,7 @@ export default class CallEventGrouper extends EventEmitter {
}
private onSilencedCallsChanged = () => {
const newState = CallHandler.sharedInstance().isCallSilenced(this.callId);
const newState = CallHandler.instance.isCallSilenced(this.callId);
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
};
@ -123,33 +122,23 @@ export default class CallEventGrouper extends EventEmitter {
this.emit(CallEventGrouperEvent.LengthChanged, length);
};
public answerCall = () => {
defaultDispatcher.dispatch({
action: 'answer',
room_id: this.roomId,
});
public answerCall = (): void => {
CallHandler.instance.answerCall(this.roomId);
};
public rejectCall = () => {
defaultDispatcher.dispatch({
action: 'reject',
room_id: this.roomId,
});
public rejectCall = (): void => {
CallHandler.instance.hangupOrReject(this.roomId, true);
};
public callBack = () => {
defaultDispatcher.dispatch({
action: 'place_call',
type: this.isVoice ? CallType.Voice : CallType.Video,
room_id: this.roomId,
});
public callBack = (): void => {
CallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
};
public toggleSilenced = () => {
const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId);
const silenced = CallHandler.instance.isCallSilenced(this.callId);
silenced ?
CallHandler.sharedInstance().unSilenceCall(this.callId) :
CallHandler.sharedInstance().silenceCall(this.callId);
CallHandler.instance.unSilenceCall(this.callId) :
CallHandler.instance.silenceCall(this.callId);
};
private setCallListeners() {
@ -175,7 +164,7 @@ export default class CallEventGrouper extends EventEmitter {
private setCall = () => {
if (this.call) return;
this.call = CallHandler.sharedInstance().getCallById(this.callId);
this.call = CallHandler.instance.getCallById(this.callId);
this.setCallListeners();
this.setState();
};

View file

@ -25,6 +25,7 @@ import { Key } from "../../Keyboard";
import { Writeable } from "../../@types/common";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
import { getInputableElement } from "./LoggedInView";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -49,6 +50,8 @@ export interface IPosition {
bottom?: number;
left?: number;
right?: number;
rightAligned?: boolean;
bottomAligned?: boolean;
}
export enum ChevronFace {
@ -101,7 +104,7 @@ interface IState {
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> {
export default class ContextMenu extends React.PureComponent<IProps, IState> {
private readonly initialFocus: HTMLElement;
static defaultProps = {
@ -246,6 +249,9 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
return;
}
// only handle escape when in an input field
if (ev.key !== Key.ESCAPE && getInputableElement(ev.target as HTMLElement)) return;
let handled = true;
switch (ev.key) {
@ -346,6 +352,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
'mx_ContextualMenu_rightAligned': this.props.rightAligned === true,
'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true,
});
const menuStyle: CSSProperties = {};
@ -407,6 +415,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
onClick={this.onClick}
onContextMenu={this.onContextMenuPreventBubbling}
>
{ background }
<div
className={menuClasses}
style={menuStyle}
@ -415,7 +424,6 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
>
{ body }
</div>
{ background }
</div>
);
}
@ -526,30 +534,22 @@ export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<
return [isOpen, button, open, close, setIsOpen];
};
@replaceableComponent("structures.LegacyContextMenu")
export default class LegacyContextMenu extends ContextMenu {
render() {
return this.renderMenu(false);
}
}
// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs.
export function createMenu(ElementClass, props) {
const onFinished = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
if (props && props.onFinished) {
props.onFinished.apply(null, args);
}
props?.onFinished?.apply(null, args);
};
const menu = <LegacyContextMenu
const menu = <ContextMenu
{...props}
mountAsChild={true}
hasBackground={false}
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
>
<ElementClass {...props} onFinished={onFinished} />
</LegacyContextMenu>;
</ContextMenu>;
ReactDOM.render(menu, getOrCreateContainer());

View file

@ -35,7 +35,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from '../views/rooms/EventTile';
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
interface IProps {

View file

@ -15,21 +15,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import type { EventSubscription } from "fbemitter";
import React from 'react';
import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore';
import GroupActions from '../../actions/GroupActions';
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher';
import { _t } from '../../languageHandler';
import classNames from 'classnames';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar";
import SettingsStore from "../../settings/SettingsStore";
import UserTagTile from "../views/elements/UserTagTile";
import { replaceableComponent } from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
import DNDTagTile from "../views/elements/DNDTagTile";
import ActionButton from "../views/elements/ActionButton";
interface IGroupFilterPanelProps {
@ -127,9 +130,6 @@ class GroupFilterPanel extends React.Component<IGroupFilterPanelProps, IGroupFil
}
public render() {
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const ActionButton = sdk.getComponent('elements.ActionButton');
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
key={tag}

View file

@ -174,7 +174,7 @@ class FeaturedRoom extends React.Component {
e.stopPropagation();
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_alias: this.props.summaryInfo.profile.canonical_alias,
room_id: this.props.summaryInfo.room_id,
});

View file

@ -114,7 +114,7 @@ const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
introSection = <React.Fragment>
<img src={logoUrl} alt={config.brand} />
<h1>{ _t("Welcome to %(appName)s", { appName: config.brand }) }</h1>
<h4>{ _t("Liberate your communication") }</h4>
<h4>{ _t("Own your conversations.") }</h4>
</React.Fragment>;
}

View file

@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import React, { ComponentProps, createRef } from "react";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { replaceableComponent } from "../../utils/replaceableComponent";
interface IProps {
interface IProps extends Omit<ComponentProps<typeof AutoHideScrollbar>, "onWheel"> {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element.
@ -56,6 +55,7 @@ export default class IndicatorScrollbar extends React.Component<IProps, IState>
}
private collectScroller = (scroller: HTMLDivElement): void => {
this.props.wrappedRef?.(scroller);
if (scroller && !this.scrollElement) {
this.scrollElement = scroller;
// Using the passive option to not block the main thread
@ -186,10 +186,10 @@ export default class IndicatorScrollbar extends React.Component<IProps, IState>
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar
{...otherProps}
ref={this.autoHideScrollbar}
wrappedRef={this.collectScroller}
onWheel={this.onMouseWheel}
{...otherProps}
>
{ leftOverflowIndicator }
{ children }

View file

@ -17,7 +17,6 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
@ -25,31 +24,41 @@ import RoomList from "../views/rooms/RoomList";
import CallHandler from "../../CallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
import RoomListHeader from "../views/rooms/RoomListHeader";
import { Key } from "../../Keyboard";
import RecentlyViewedButton from "../views/rooms/RecentlyViewedButton";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import IndicatorScrollbar from "./IndicatorScrollbar";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import SettingsStore from "../../settings/SettingsStore";
import UserMenu from "./UserMenu";
interface IProps {
isMinimized: boolean;
resizeNotifier: ResizeNotifier;
}
enum BreadcrumbsMode {
Disabled,
Legacy,
Labs,
}
interface IState {
showBreadcrumbs: boolean;
activeSpace?: Room;
showBreadcrumbs: BreadcrumbsMode;
activeSpace: SpaceKey;
}
@replaceableComponent("structures.LeftPanel")
@ -65,8 +74,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
super(props);
this.state = {
showBreadcrumbs: BreadcrumbsStore.instance.visible,
activeSpace: SpaceStore.instance.activeSpace,
showBreadcrumbs: LeftPanel.breadcrumbsMode,
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
@ -74,6 +83,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
}
private static get breadcrumbsMode(): BreadcrumbsMode {
if (!SettingsStore.getValue("breadcrumbs")) return BreadcrumbsMode.Disabled;
return SettingsStore.getValue("feature_breadcrumbs_v2") ? BreadcrumbsMode.Labs : BreadcrumbsMode.Legacy;
}
public componentDidMount() {
UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current);
UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
@ -98,7 +112,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
}
private updateActiveSpace = (activeSpace: Room) => {
private updateActiveSpace = (activeSpace: SpaceKey) => {
this.setState({ activeSpace });
};
@ -116,7 +130,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
};
private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible;
const newVal = LeftPanel.breadcrumbsMode;
if (newVal !== this.state.showBreadcrumbs) {
this.setState({ showBreadcrumbs: newVal });
@ -300,6 +314,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private onRoomListKeydown = (ev: React.KeyboardEvent) => {
if (ev.altKey || ev.ctrlKey || ev.metaKey) return;
// we cannot handle Space as that is an activation key for all focusable elements in this widget
if (ev.key.length === 1) {
ev.preventDefault();
ev.stopPropagation();
this.roomSearchRef.current?.appendChar(ev.key);
} else if (ev.key === Key.BACKSPACE) {
ev.preventDefault();
ev.stopPropagation();
this.roomSearchRef.current?.backspace();
}
};
private selectRoom = () => {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile");
if (firstRoom) {
@ -308,16 +336,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
};
private renderHeader(): React.ReactNode {
return (
<div className="mx_LeftPanel_userHeader">
<UserMenu isMinimized={this.props.isMinimized} />
</div>
);
}
private renderBreadcrumbs(): React.ReactNode {
if (this.state.showBreadcrumbs && !this.props.isMinimized) {
if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) {
return (
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
@ -334,7 +354,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (CallHandler.sharedInstance().getSupportsPstnProtocol()) {
if (CallHandler.instance.getSupportsPstnProtocol()) {
dialPadButton =
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}
@ -343,6 +363,17 @@ export default class LeftPanel extends React.Component<IProps, IState> {
/>;
}
let rightButton: JSX.Element;
if (this.state.showBreadcrumbs === BreadcrumbsMode.Labs) {
rightButton = <RecentlyViewedButton />;
} else if (this.state.activeSpace === MetaSpace.Home) {
rightButton = <AccessibleTooltipButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("Explore rooms")}
/>;
}
return (
<div
className="mx_LeftPanel_filterContainer"
@ -350,6 +381,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
>
{ !SpaceStore.spacesEnabled && <UserMenu isPanelCollapsed={true} /> }
<RoomSearch
isMinimized={this.props.isMinimized}
ref={this.roomSearchRef}
@ -357,16 +389,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
/>
{ dialPadButton }
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_exploreButton", {
mx_LeftPanel_exploreButton_space: !!this.state.activeSpace,
})}
onClick={this.onExplore}
title={this.state.activeSpace
? _t("Explore %(spaceName)s", { spaceName: this.state.activeSpace.name })
: _t("Explore rooms")}
/>
{ rightButton }
</div>
);
}
@ -397,10 +420,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
return (
<div className={containerClasses} ref={this.ref}>
<aside className="mx_LeftPanel_roomListContainer">
{ this.renderHeader() }
{ this.renderSearchDialExplore() }
{ this.renderBreadcrumbs() }
<RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
{ !this.props.isMinimized && (
<RoomListHeader
onVisibilityChange={this.refreshStickyHeaders}
spacePanelDisabled={!SpaceStore.spacesEnabled}
/>
) }
<div className="mx_LeftPanel_roomListWrapper">
<div
className={roomListClasses}
@ -408,6 +435,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
onKeyDown={this.onRoomListKeydown}
>
{ roomList }
</div>

View file

@ -131,7 +131,7 @@ const LeftPanelWidget: React.FC = () => {
}}
className="mx_LeftPanelWidget_maximizeButton"
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
title={_t("Maximize")}
title={_t("Maximise")}
/>*/ }
</div>
</div>

View file

@ -14,11 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import React, { ClipboardEvent } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import { Key } from '../../Keyboard';
import PageTypes from '../../PageTypes';
@ -27,18 +25,16 @@ import { fixupColorFonts } from '../../utils/FontManager';
import dis from '../../dispatcher/dispatcher';
import { IMatrixClientCreds } from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import ResizeHandle from '../views/elements/ResizeHandle';
import { Resizer, CollapseDistributor } from '../../resizer';
import { CollapseDistributor, Resizer } from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext";
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
import HomePage from "./HomePage";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PlatformPeg from "../../PlatformPeg";
import { DefaultTagID } from "../../stores/room-list/models";
import {
showToast as showServerLimitToast,
hideToast as hideServerLimitToast,
} from "../../toasts/ServerLimitToast";
import { hideToast as hideServerLimitToast, showToast as showServerLimitToast } from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions";
import LeftPanel from "./LeftPanel";
import CallContainer from '../views/voip/CallContainer';
@ -54,7 +50,8 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import { replaceableComponent } from "../../utils/replaceableComponent";
import CallHandler from '../../CallHandler';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
@ -64,7 +61,8 @@ import MyGroups from "./MyGroups";
import UserView from "./UserView";
import GroupView from "./GroupView";
import BackdropPanel from "./BackdropPanel";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import classNames from 'classnames';
import GroupFilterPanel from './GroupFilterPanel';
import CustomRoomTagPanel from './CustomRoomTagPanel';
import { mediaFromMxc } from "../../customisations/Media";
@ -75,11 +73,8 @@ import LegacyCommunityPreview from "./LegacyCommunityPreview";
// NB. this is just for server notices rather than pinned messages in general.
const MAX_PINNED_NOTICES_PER_ROOM = 2;
function canElementReceiveInput(el) {
return el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT" ||
!!el.getAttribute("contenteditable");
export function getInputableElement(el: HTMLElement): HTMLElement | null {
return el.closest("input, textarea, select, [contenteditable=true]");
}
interface IProps {
@ -140,7 +135,7 @@ interface IState {
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
*
* Currently it's very tightly coupled with MatrixChat. We should try to do
* Currently, it's very tightly coupled with MatrixChat. We should try to do
* something about that.
*
* Components mounted below us can access the matrix client via the react context.
@ -149,7 +144,6 @@ interface IState {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
private dispatcherRef: string;
protected readonly _matrixClient: MatrixClient;
protected readonly _roomView: React.RefObject<any>;
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
@ -166,7 +160,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
activeCalls: CallHandler.instance.getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
@ -183,7 +177,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
this.dispatcherRef = dis.register(this.onAction);
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState);
this.updateServerNoticeEvents();
@ -214,7 +208,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
dis.unregister(this.dispatcherRef);
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -224,6 +218,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.detach();
}
private onCallState = (): void => {
const activeCalls = CallHandler.instance.getAllActiveCalls();
if (activeCalls === this.state.activeCalls) return;
this.setState({ activeCalls });
};
private refreshBackgroundImage = async (): Promise<void> => {
let backgroundImage = SettingsStore.getValue("RoomList.backgroundImage");
if (backgroundImage) {
@ -235,18 +235,6 @@ class LoggedInView extends React.Component<IProps, IState> {
this.setState({ backgroundImage });
};
private onAction = (payload): void => {
switch (payload.action) {
case 'call_state': {
const activeCalls = CallHandler.sharedInstance().getAllActiveCalls();
if (activeCalls !== this.state.activeCalls) {
this.setState({ activeCalls });
}
break;
}
}
};
public canResetTimelineInRoom = (roomId: string) => {
if (!this._roomView.current) {
return true;
@ -414,15 +402,13 @@ class LoggedInView extends React.Component<IProps, IState> {
});
};
private onPaste = (ev) => {
let canReceiveInput = false;
let element = ev.target;
// test for all parents because the target can be a child of a contenteditable element
while (!canReceiveInput && element) {
canReceiveInput = canElementReceiveInput(element);
element = element.parentElement;
}
if (!canReceiveInput) {
private onPaste = (ev: ClipboardEvent) => {
const element = ev.target as HTMLElement;
const inputableElement = getInputableElement(element);
if (inputableElement) {
inputableElement.focus();
} else {
// refocusing during a paste event will make the
// paste end up in the newly focused element,
// so dispatch synchronously before paste happens
@ -516,6 +502,10 @@ class LoggedInView extends React.Component<IProps, IState> {
Modal.closeCurrentModal("homeKeyboardShortcut");
handled = true;
break;
case NavigationAction.ToggleSpacePanel:
dis.fire(Action.ToggleSpacePanel);
handled = true;
break;
case NavigationAction.ToggleRoomSidePanel:
if (this.props.page_type === "room_view" || this.props.page_type === "group_view") {
dis.dispatch<ToggleRightPanelPayload>({
@ -579,7 +569,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// If the user is entering a printable character outside of an input field
// redirect it to the composer for them.
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
if (!isClickShortcut && isPrintable && !getInputableElement(ev.target as HTMLElement)) {
// synchronous dispatch so we focus before key generates input
dis.fire(Action.FocusSendMessageComposer, true);
ev.stopPropagation();

View file

@ -84,7 +84,7 @@ export default class MainSplit extends React.Component<IProps> {
onResize={this.onResize}
onResizeStop={this.onResizeStop}
className="mx_RightPanel_ResizeWrapper"
handleClasses={{ left: "mx_RightPanel_ResizeHandle" }}
handleClasses={{ left: "mx_ResizeHandle_horizontal" }}
>
{ panelView }
</Resizable>;

View file

@ -17,10 +17,10 @@ limitations under the License.
import React, { ComponentType, createRef } from 'react';
import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import { Error as ErrorEvent } from "matrix-analytics-events/types/typescript/Error";
import { Screen as ScreenEvent } from "matrix-analytics-events/types/typescript/Screen";
import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible';
@ -35,14 +35,15 @@ import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import dis from "../../dispatcher/dispatcher";
import Notifier from '../../Notifier';
import Modal from "../../Modal";
import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite';
import * as Rooms from '../../Rooms';
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 PageType from '../../PageTypes';
import createRoom, { IOpts } from "../../createRoom";
import { _t, _td, getCurrentLanguage } from '../../languageHandler';
import SettingsStore from "../../settings/SettingsStore";
@ -58,11 +59,11 @@ import { storeRoomAliasInCache } from '../../RoomAliasCache';
import ToastStore from "../../stores/ToastStore";
import * as StorageManager from "../../utils/StorageManager";
import type LoggedInViewType from "./LoggedInView";
import { ViewUserPayload } from "../../dispatcher/payloads/ViewUserPayload";
import { Action } from "../../dispatcher/actions";
import {
showToast as showAnalyticsToast,
hideToast as hideAnalyticsToast,
showAnonymousAnalyticsOptInToast,
showPseudonymousAnalyticsOptInToast,
} from "../../toasts/AnalyticsToast";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
@ -77,11 +78,10 @@ import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import DialPadModal from "../views/voip/DialPadModal";
import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
import { shouldUseLoginForWelcome } from "../../utils/pages";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { replaceableComponent } from "../../utils/replaceableComponent";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { RoomUpdateCause } from "../../stores/room-list/models";
import defaultDispatcher from "../../dispatcher/dispatcher";
import SecurityCustomisations from "../../customisations/Security";
import Spinner from "../views/elements/Spinner";
import QuestionDialog from "../views/dialogs/QuestionDialog";
@ -100,6 +100,7 @@ import Registration from './auth/Registration';
import Login from "./auth/Login";
import ErrorBoundary from '../views/elements/ErrorBoundary';
import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout';
@ -107,7 +108,15 @@ import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
import { initSentry } from "../../sentry";
import CallHandler from "../../CallHandler";
import { logger } from "matrix-js-sdk/src/logger";
import { showSpaceInvite } from "../../utils/space";
import GenericToast from "../views/toasts/GenericToast";
import InfoDialog from "../views/dialogs/InfoDialog";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import AccessibleButton from "../views/elements/AccessibleButton";
import { ActionPayload } from "../../dispatcher/payloads";
/** constants for MatrixChat.state.view */
export enum Views {
@ -339,18 +348,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// we don't do it as react state as i'm scared about triggering needless react refreshes.
this.subTitleStatus = '';
// this can technically be done anywhere but doing this here keeps all
// the routing url path logic together.
if (this.onAliasClick) {
linkifyMatrix.onAliasClick = this.onAliasClick;
}
if (this.onUserClick) {
linkifyMatrix.onUserClick = this.onUserClick;
}
if (this.onGroupClick) {
linkifyMatrix.onGroupClick = this.onGroupClick;
}
// the first thing to do is to try the token params in the query-string
// if the session isn't soft logged out (ie: is a clean session being logged in)
if (!Lifecycle.isSoftLogout()) {
@ -389,13 +386,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
if (SettingsStore.getValue("analyticsOptIn")) {
if (SettingsStore.getValue("pseudonymousAnalyticsOptIn")) {
Analytics.enable();
}
PosthogAnalytics.instance.updateAnonymityFromSettings();
PosthogAnalytics.instance.updatePlatformSuperProperties();
CountlyAnalytics.instance.enable(/* anonymous = */ true);
initSentry(SdkConfig.get()["sentry"]);
@ -454,7 +448,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
PosthogAnalytics.instance.trackPageView(durationMs);
this.trackScreenChange(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusSendMessageComposer);
@ -473,6 +467,36 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
}
public trackScreenChange(durationMs: number): void {
const notLoggedInMap = {};
notLoggedInMap[Views.LOADING] = "WebLoading";
notLoggedInMap[Views.WELCOME] = "WebWelcome";
notLoggedInMap[Views.LOGIN] = "WebLogin";
notLoggedInMap[Views.REGISTER] = "WebRegister";
notLoggedInMap[Views.FORGOT_PASSWORD] = "WebForgotPassword";
notLoggedInMap[Views.COMPLETE_SECURITY] = "WebCompleteSecurity";
notLoggedInMap[Views.E2E_SETUP] = "WebE2ESetup";
notLoggedInMap[Views.SOFT_LOGOUT] = "WebSoftLogout";
const loggedInPageTypeMap = {};
loggedInPageTypeMap[PageType.HomePage] = "Home";
loggedInPageTypeMap[PageType.RoomView] = "Room";
loggedInPageTypeMap[PageType.RoomDirectory] = "RoomDirectory";
loggedInPageTypeMap[PageType.UserView] = "User";
loggedInPageTypeMap[PageType.GroupView] = "Group";
loggedInPageTypeMap[PageType.MyGroups] = "MyGroups";
const screenName = this.state.view === Views.LOGGED_IN ?
loggedInPageTypeMap[this.state.page_type] :
notLoggedInMap[this.state.view];
return PosthogAnalytics.instance.trackEvent<ScreenEvent>({
eventName: "Screen",
screenName,
durationMs,
});
}
getFallbackHsUrl() {
if (this.props.serverConfig && this.props.serverConfig.isDefault) {
return this.props.config.fallback_hs_url;
@ -507,8 +531,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else {
dis.dispatch({ action: "view_welcome_page" });
}
} else if (SettingsStore.getValue("analyticsOptIn")) {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
});
// Note we don't catch errors from this: we catch everything within
@ -553,13 +575,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setState(newState);
}
onAction = (payload) => {
private onAction = (payload: ActionPayload) => {
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
// Start the onboarding process for certain actions
if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() &&
ONBOARDING_FLOW_STARTERS.includes(payload.action)
) {
if (MatrixClientPeg.get()?.isGuest() && ONBOARDING_FLOW_STARTERS.includes(payload.action)) {
// This will cause `payload` to be dispatched later, once a
// sync has reached the "prepared" state. Setting a matrix ID
// will cause a full login and sync and finally the deferred
@ -597,11 +617,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
case 'logout':
dis.dispatch({ action: "hangup_all" });
CallHandler.instance.hangupAllCalls();
Lifecycle.logout();
break;
case 'require_registration':
startAnyRegistrationFlow(payload);
startAnyRegistrationFlow(payload as any);
break;
case 'start_registration':
if (Lifecycle.isSoftLogout()) {
@ -672,12 +692,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'view_user_info':
this.viewUser(payload.userId, payload.subAction);
break;
case 'view_room': {
case Action.ViewRoom: {
// Takes either a room ID or room alias: if switching to a room the client is already
// known to be in (eg. user clicks on a room in the recents panel), supply the ID
// If the user is clicking on a room in the context of the alias being presented
// to them, supply the room alias. If both are supplied, the room ID will be ignored.
const promise = this.viewRoom(payload);
const promise = this.viewRoom(payload as any);
if (payload.deferred_action) {
promise.then(() => {
dis.dispatch(payload.deferred_action);
@ -708,16 +728,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break;
}
case Action.ViewRoomDirectory: {
if (SpaceStore.instance.activeSpace) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: SpaceStore.instance.activeSpace.roomId,
});
} else {
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
}
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
initialText: payload.initialText,
}, 'mx_RoomDirectory_dialogWrapper', false, true);
// View the welcome or home page if we need something to look at
this.viewSomethingBehindModal();
@ -830,10 +843,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
hideToSRUsers: false,
});
break;
case 'accept_cookies':
case Action.AnonymousAnalyticsAccept:
hideAnalyticsToast();
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
hideAnalyticsToast();
if (Analytics.canEnable()) {
Analytics.enable();
}
@ -841,10 +854,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
CountlyAnalytics.instance.enable(/* anonymous = */ false);
}
break;
case 'reject_cookies':
case Action.AnonymousAnalyticsReject:
hideAnalyticsToast();
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
break;
case Action.PseudonymousAnalyticsAccept:
hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true);
break;
case Action.PseudonymousAnalyticsReject:
hideAnalyticsToast();
SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, false);
break;
}
};
@ -907,73 +928,73 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// @param {Object=} roomInfo.oob_data Object of additional data about the room
// that has been passed out-of-band (eg.
// room name and avatar from an invite email)
private viewRoom(roomInfo: IRoomInfo) {
private async viewRoom(roomInfo: IRoomInfo) {
this.focusComposer = true;
if (roomInfo.room_alias) {
logger.log(
`Switching to room alias ${roomInfo.room_alias} at event ` +
roomInfo.event_id,
);
logger.log(`Switching to room alias ${roomInfo.room_alias} at event ${roomInfo.event_id}`);
} else {
logger.log(`Switching to room id ${roomInfo.room_id} at event ` +
roomInfo.event_id,
);
logger.log(`Switching to room id ${roomInfo.room_id} at event ${roomInfo.event_id}`);
}
// Wait for the first sync to complete so that if a room does have an alias,
// it would have been retrieved.
let waitFor = Promise.resolve(null);
if (!this.firstSyncComplete) {
if (!this.firstSyncPromise) {
logger.warn('Cannot view a room before first sync. room_id:', roomInfo.room_id);
return;
}
waitFor = this.firstSyncPromise.promise;
await this.firstSyncPromise.promise;
}
return waitFor.then(() => {
let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) {
// Not all timeline events are decrypted ahead of time anymore
// Only the critical ones for a typical UI are
// This will start the decryption process for all events when a
// user views a room
room.decryptAllEvents();
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) {
presentedId = theAlias;
// Store display alias of the presented room in cache to speed future
// navigation.
storeRoomAliasInCache(theAlias, room.roomId);
}
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
if (localStorage) {
localStorage.setItem('mx_last_room_id', room.roomId);
}
let presentedId = roomInfo.room_alias || roomInfo.room_id;
const room = MatrixClientPeg.get().getRoom(roomInfo.room_id);
if (room) {
// Not all timeline events are decrypted ahead of time anymore
// Only the critical ones for a typical UI are
// This will start the decryption process for all events when a
// user views a room
room.decryptAllEvents();
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) {
presentedId = theAlias;
// Store display alias of the presented room in cache to speed future
// navigation.
storeRoomAliasInCache(theAlias, room.roomId);
}
// If we are redirecting to a Room Alias and it is for the room we already showing then replace history item
const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId;
if (roomInfo.event_id && roomInfo.highlighted) {
presentedId += "/" + roomInfo.event_id;
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
if (localStorage) {
localStorage.setItem('mx_last_room_id', room.roomId);
}
this.setState({
view: Views.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageType.RoomView,
threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data,
forceTimeline: roomInfo.forceTimeline,
ready: true,
roomJustCreatedOpts: roomInfo.justCreatedOpts,
}, () => {
this.notifyNewScreen('room/' + presentedId, replaceLast);
});
}
// If we are redirecting to a Room Alias and it is for the room we already showing then replace history item
const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId;
if (roomInfo.room_id === this.state.currentRoomId) {
// if we are re-viewing the same room then copy any state we already know
roomInfo.threepid_invite = roomInfo.threepid_invite ?? this.state.threepidInvite;
roomInfo.oob_data = roomInfo.oob_data ?? this.state.roomOobData;
roomInfo.forceTimeline = roomInfo.forceTimeline ?? this.state.forceTimeline;
roomInfo.justCreatedOpts = roomInfo.justCreatedOpts ?? this.state.roomJustCreatedOpts;
}
if (roomInfo.event_id && roomInfo.highlighted) {
presentedId += "/" + roomInfo.event_id;
}
this.setState({
view: Views.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageType.RoomView,
threepidInvite: roomInfo.threepid_invite,
roomOobData: roomInfo.oob_data,
forceTimeline: roomInfo.forceTimeline,
ready: true,
roomJustCreatedOpts: roomInfo.justCreatedOpts,
}, () => {
this.notifyNewScreen('room/' + presentedId, replaceLast);
});
}
@ -1120,7 +1141,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (dmRooms.length > 0) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: dmRooms[0],
});
} else {
@ -1191,12 +1212,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
button: _t("Leave"),
onFinished: (shouldLeave) => {
if (shouldLeave) {
const d = leaveRoomBehaviour(roomId);
leaveRoomBehaviour(roomId);
// FIXME: controller shouldn't be loading a view :(
const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
d.finally(() => modal.close());
dis.dispatch({
action: "after_leave_room",
room_id: roomId,
@ -1337,13 +1354,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
StorageManager.tryPersistStorage();
// defer the following actions by 30 seconds to not throw them at the user immediately
await sleep(30);
if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) {
showAnalyticsToast(this.props.config.piwik?.policyUrl);
if (PosthogAnalytics.instance.isEnabled()) {
this.initPosthogAnalyticsToast();
} else if (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) {
if (SettingsStore.getValue("showCookieBar") &&
(Analytics.canEnable() || CountlyAnalytics.instance.canEnable())
) {
showAnonymousAnalyticsOptInToast();
}
}
if (SdkConfig.get().mobileGuideToast) {
// The toast contains further logic to detect mobile platforms,
// check if it has been dismissed before, etc.
@ -1351,6 +1371,34 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
}
private showPosthogToast(analyticsOptIn: boolean) {
showPseudonymousAnalyticsOptInToast(analyticsOptIn);
}
private initPosthogAnalyticsToast() {
// Show the analytics toast if necessary
if (SettingsStore.getValue("pseudonymousAnalyticsOptIn") === null) {
this.showPosthogToast(SettingsStore.getValue("analyticsOptIn", null, true));
}
// Listen to changes in settings and show the toast if appropriate - this is necessary because account
// settings can still be changing at this point in app init (due to the initial sync being cached, then
// subsequent syncs being received from the server)
SettingsStore.watchSetting("pseudonymousAnalyticsOptIn", null,
(originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue) => {
if (newValue === null) {
this.showPosthogToast(SettingsStore.getValue("analyticsOptIn", null, true));
} else {
// It's possible for the value to change if a cached sync loads at page load, but then network
// sync contains a new value of the flag with it set to false (e.g. another device set it since last
// loading the page); so hide the toast.
// (this flipping usually happens before first render so the user won't notice it; anyway flicker
// on/off is probably better than showing the toast again when the user already dismissed it)
hideAnalyticsToast();
}
});
}
private showScreenAfterLogin() {
// If screenAfterLogin is set, use that, then null it so that a second login will
// result in view_home_page, _user_settings or _room_directory
@ -1374,7 +1422,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private viewLastRoom() {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: localStorage.getItem('mx_last_room_id'),
});
}
@ -1472,6 +1520,61 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
showNotificationsToast(false);
}
if (!localStorage.getItem("mx_seen_ia_1.1_changes_toast")) {
const key = "IA_1.1_TOAST";
ToastStore.sharedInstance().addOrReplaceToast({
key,
title: _t("Testing small changes"),
props: {
description: _t("Your feedback is wanted as we try out some design changes."),
acceptLabel: _t("More info"),
onAccept: () => {
Modal.createDialog(InfoDialog, {
title: _t("We're testing some design changes"),
description: <>
<img
src={require("../../../res/img/ia-design-changes.png")}
width="636"
height="303"
alt=""
/>
<p>{ _t(
"Your ongoing feedback would be very welcome, so if you see anything " +
"different you want to comment on, <a>please let us know about it</a>. " +
"Click your avatar to find a quick feedback link.",
{},
{
a: sub => <AccessibleButton
kind="link"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog);
}}
>
{ sub }
</AccessibleButton>,
},
) }</p>
<p>{ _t("If you'd like to preview or test some potential upcoming changes, " +
"there's an option in feedback to let us contact you.") }</p>
</>,
}, "mx_DialogDesignChanges_wrapper");
localStorage.setItem("mx_seen_ia_1.1_changes_toast", "true");
ToastStore.sharedInstance().dismissToast(key);
},
rejectLabel: _t("Dismiss"),
onReject: () => {
localStorage.setItem("mx_seen_ia_1.1_changes_toast", "true");
ToastStore.sharedInstance().dismissToast(key);
},
},
icon: "labs",
component: GenericToast,
priority: 9,
});
}
dis.fire(Action.FocusSendMessageComposer);
this.setState({
ready: true,
@ -1524,17 +1627,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const dft = new DecryptionFailureTracker((total, errorCode) => {
Analytics.trackEvent('E2E', 'Decryption failure', errorCode, String(total));
CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total });
PosthogAnalytics.instance.trackEvent<ErrorEvent>({
eventName: "Error",
domain: "E2EE",
name: errorCode,
});
}, (errorCode) => {
// Map JS-SDK error codes to tracker codes for aggregation
switch (errorCode) {
case 'MEGOLM_UNKNOWN_INBOUND_SESSION_ID':
return 'olm_keys_not_sent_error';
return 'OlmKeysNotSentError';
case 'OLM_UNKNOWN_MESSAGE_INDEX':
return 'olm_index_error';
return 'OlmIndexError';
case undefined:
return 'unexpected_error';
return 'OlmUnspecifiedError';
default:
return 'unspecified_error';
return 'UnknownError';
}
});
@ -1789,7 +1897,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
const payload = {
action: 'view_room',
action: Action.ViewRoom,
event_id: eventId,
via_servers: via,
// If an event ID is given in the URL hash, notify RoomViewStore to mark
@ -1842,28 +1950,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
this.setPageSubtitle();
}
onAliasClick(event: MouseEvent, alias: string) {
event.preventDefault();
dis.dispatch({ action: 'view_room', room_alias: alias });
}
onUserClick(event: MouseEvent, userId: string) {
event.preventDefault();
const member = new RoomMember(null, userId);
if (!member) { return; }
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: member,
});
}
onGroupClick(event: MouseEvent, groupId: string) {
event.preventDefault();
dis.dispatch({ action: 'view_group', group_id: groupId });
}
onLogoutClick(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
dis.dispatch({
action: 'logout',

View file

@ -28,7 +28,7 @@ import { wantsDateSeparator } from '../../DateUtils';
import { MatrixClientPeg } from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import { _t } from "../../languageHandler";
import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile";
import { hasText } from "../../TextForEvent";
@ -179,6 +179,7 @@ interface IProps {
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
}
interface IState {
@ -198,6 +199,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
static defaultProps = {
disableGrouping: false,
};
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations
private readonly readReceiptMap: Record<string, object> = {};
@ -652,7 +657,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}
for (const Grouper of groupers) {
if (Grouper.canStartGroup(this, mxEv)) {
if (Grouper.canStartGroup(this, mxEv) && !this.props.disableGrouping) {
grouper = new Grouper(
this,
mxEv,

View file

@ -24,7 +24,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
import { TileShape } from "../views/rooms/EventTile";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
interface IProps {
@ -39,7 +39,7 @@ export default class NotificationPanel extends React.PureComponent<IProps> {
static contextType = RoomContext;
render() {
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{ _t('Youre all caught up') }</h2>
<h2>{ _t("You're all caught up") }</h2>
<p>{ _t('You have no visible notifications.') }</p>
</div>);

View file

@ -21,7 +21,6 @@ import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { throttle } from 'lodash';
import dis from '../../dispatcher/dispatcher';
import GroupStore from '../../stores/GroupStore';
@ -50,10 +49,12 @@ import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import SpaceStore from "../../stores/SpaceStore";
import { throttle } from 'lodash';
import SpaceStore from "../../stores/spaces/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { E2EStatus } from '../../utils/ShieldUtils';
import { dispatchShowThreadsPanelEvent } from '../../dispatcher/dispatch-actions/threads';
import TimelineCard from '../views/right_panel/TimelineCard';
interface IProps {
room?: Room; // if showing panels for a given room, this is set
@ -77,6 +78,7 @@ interface IState {
event: MatrixEvent;
initialEvent?: MatrixEvent;
initialEventHighlighted?: boolean;
searchQuery: string;
}
@replaceableComponent("structures.RightPanel")
@ -92,6 +94,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
phase: this.getPhaseFromProps(),
isUserPrivilegedInGroup: null,
member: this.getUserForPanel(),
searchQuery: "",
};
}
@ -198,7 +201,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
};
private onAction = (payload: ActionPayload) => {
const isChangingRoom = payload.action === 'view_room' && payload.room_id !== this.props.room.roomId;
const isChangingRoom = payload.action === Action.ViewRoom && payload.room_id !== this.props.room.roomId;
const isViewingThread = this.state.phase === RightPanelPhases.ThreadView;
if (isChangingRoom && isViewingThread) {
dispatchShowThreadsPanelEvent();
@ -248,6 +251,10 @@ export default class RightPanel extends React.Component<IProps, IState> {
}
};
private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({ searchQuery });
};
public render(): JSX.Element {
let panel = <div />;
const roomId = this.props.room ? this.props.room.roomId : undefined;
@ -255,7 +262,13 @@ export default class RightPanel extends React.Component<IProps, IState> {
switch (this.state.phase) {
case RightPanelPhases.RoomMemberList:
if (roomId) {
panel = <MemberList roomId={roomId} key={roomId} onClose={this.onClose} />;
panel = <MemberList
roomId={roomId}
key={roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>;
}
break;
case RightPanelPhases.SpaceMemberList:
@ -263,6 +276,8 @@ export default class RightPanel extends React.Component<IProps, IState> {
roomId={this.state.space ? this.state.space.roomId : roomId}
key={this.state.space ? this.state.space.roomId : roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>;
break;
@ -320,7 +335,17 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <PinnedMessagesCard room={this.props.room} onClose={this.onClose} />;
}
break;
case RightPanelPhases.Timeline:
if (!SettingsStore.getValue("feature_maximised_widgets")) break;
panel = <TimelineCard
room={this.props.room}
timelineSet={this.props.room.getUnfilteredTimelineSet()}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
/>;
break;
case RightPanelPhases.FilePanel:
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break;
@ -341,7 +366,9 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose} />;
onClose={this.onClose}
permalinkCreator={this.props.permalinkCreator}
/>;
break;
case RightPanelPhases.RoomSummary:

View file

@ -19,7 +19,6 @@ import React from "react";
import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
import { Visibility } from "matrix-js-sdk/src/@types/partials";
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
@ -49,6 +48,9 @@ import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads";
import { getDisplayAliasForAliasSet } from "../../Rooms";
import { logger } from "matrix-js-sdk/src/logger";
import { Action } from "../../dispatcher/actions";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
@ -492,7 +494,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished();
const payload: ActionPayload = {
action: 'view_room',
action: Action.ViewRoom,
auto_join: autoJoin,
should_peek: shouldPeek,
_type: "room_directory", // instrumentation
@ -588,9 +590,12 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
// We use onMouseDown instead of onClick, so that we can avoid text getting selected
return [
return <div
key={room.room_id}
role="listitem"
className="mx_RoomDirectory_listItem"
>
<div
key={`${room.room_id}_avatar`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomAvatar"
>
@ -602,9 +607,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
idName={name}
url={avatarUrl}
/>
</div>,
</div>
<div
key={`${room.room_id}_description`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomDescription"
>
@ -625,30 +629,27 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
>
{ getDisplayAliasForRoom(room) }
</div>
</div>,
</div>
<div
key={`${room.room_id}_memberCount`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_roomMemberCount"
>
{ room.num_joined_members }
</div>,
</div>
<div
key={`${room.room_id}_preview`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
className="mx_RoomDirectory_preview"
>
{ previewButton }
</div>,
</div>
<div
key={`${room.room_id}_join`}
onMouseDown={(ev) => this.onRoomClicked(room, ev)}
className="mx_RoomDirectory_join"
>
{ joinOrViewButton }
</div>,
];
</div>
</div>;
}
private stringLooksLikeId(s: string, fieldType: IFieldType) {

View file

@ -17,7 +17,6 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
@ -28,7 +27,9 @@ import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { isMac } from "../../Keyboard";
interface IProps {
isMinimized: boolean;
@ -41,12 +42,11 @@ interface IProps {
interface IState {
query: string;
focused: boolean;
inSpaces: boolean;
}
@replaceableComponent("structures.RoomSearch")
export default class RoomSearch extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private readonly dispatcherRef: string;
private inputRef: React.RefObject<HTMLInputElement> = createRef();
private searchFilter: NameFilterCondition = new NameFilterCondition();
@ -56,13 +56,11 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
this.state = {
query: "",
focused: false,
inSpaces: false,
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
// clear filter when changing spaces, in future we may wish to maintain a filter per-space
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
@ -83,17 +81,10 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces);
}
private onSpaces = (spaces: Room[]) => {
this.setState({
inSpaces: spaces.length > 0,
});
};
private onAction = (payload: ActionPayload) => {
if (payload.action === 'view_room' && payload.clear_search) {
if (payload.action === Action.ViewRoom && payload.clear_search) {
this.clearInput();
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
this.inputRef.current.focus();
@ -145,9 +136,9 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
}
};
public focus(): void {
public focus = (): void => {
this.inputRef.current?.focus();
}
};
public render(): React.ReactNode {
const classes = classNames({
@ -162,13 +153,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
});
let placeholder = _t("Filter");
if (this.state.inSpaces) {
placeholder = _t("Filter all spaces");
}
let icon = (
<div className='mx_RoomSearch_icon' />
<div className="mx_RoomSearch_icon" onClick={this.focus} />
);
let input = (
<input
@ -180,7 +166,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={placeholder}
placeholder={_t("Filter")}
autoComplete="off"
/>
);
@ -192,6 +178,9 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
onClick={this.clearInput}
/>
);
let shortcutPrompt = <div className="mx_RoomSearch_shortcutPrompt" onClick={this.focus}>
{ isMac ? "⌘ K" : "Ctrl K" }
</div>;
if (this.props.isMinimized) {
icon = (
@ -203,14 +192,28 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
);
input = null;
clearButton = null;
shortcutPrompt = null;
}
return (
<div className={classes}>
{ icon }
{ input }
{ shortcutPrompt }
{ clearButton }
</div>
);
}
public appendChar(char: string): void {
this.setState({
query: this.state.query + char,
});
}
public backspace(): void {
this.setState({
query: this.state.query.substring(0, this.state.query.length - 1),
});
}
}

View file

@ -27,10 +27,10 @@ import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventSubscription } from "fbemitter";
import { ISearchResults } from 'matrix-js-sdk/src/@types/search';
import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import shouldHideEvent from '../../shouldHideEvent';
import { _t } from '../../languageHandler';
@ -38,7 +38,7 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import ResizeNotifier from '../../utils/ResizeNotifier';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import CallHandler, { PlaceCallType } from '../../CallHandler';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import dis from '../../dispatcher/dispatcher';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
@ -48,7 +48,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile";
@ -70,6 +70,7 @@ import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects';
import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import WidgetStore from "../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
@ -82,6 +83,7 @@ import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom";
import { replaceableComponent } from "../../utils/replaceableComponent";
import EditorStateTransfer from "../../utils/EditorStateTransfer";
import { throttle } from "lodash";
import ErrorDialog from '../views/dialogs/ErrorDialog';
import SearchResultTile from '../views/rooms/SearchResultTile';
import Spinner from "../views/elements/Spinner";
@ -90,10 +92,13 @@ import RoomStatusBar from "./RoomStatusBar";
import MessageComposer from '../views/rooms/MessageComposer';
import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { dispatchShowThreadEvent } from '../../dispatcher/dispatch-actions/threads';
import { fetchInitialEvent } from "../../utils/EventUtils";
import { ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload";
import AppsDrawer from '../views/rooms/AppsDrawer';
import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
import { RightPanelPhases } from '../../stores/RightPanelStorePhases';
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -118,6 +123,13 @@ interface IRoomProps extends MatrixClientProps {
onRegistered?(credentials: IMatrixClientCreds): void;
}
// This defines the content of the mainSplit.
// If the mainSplit does not contain the Timeline, the chat is shown in the right panel.
enum MainSplitContentType {
Timeline,
MaximisedWidget,
// Video
}
export interface IRoomState {
room?: Room;
roomId?: string;
@ -187,6 +199,7 @@ export interface IRoomState {
rejecting?: boolean;
rejectError?: Error;
hasPinnedWidgets?: boolean;
mainSplitContentType?: MainSplitContentType;
dragCounter: number;
// whether or not a spaces context switch brought us here,
// if it did we don't want the room to be marked as read as soon as it is loaded.
@ -253,6 +266,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
showAvatarChanges: true,
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
mainSplitContentType: MainSplitContentType.Timeline,
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
@ -305,18 +319,59 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
private onWidgetStoreUpdate = () => {
if (this.state.room) {
this.checkWidgets(this.state.room);
if (!this.state.room) return;
this.checkWidgets(this.state.room);
};
private onWidgetEchoStoreUpdate = () => {
if (!this.state.room) return;
this.checkWidgets(this.state.room);
};
private onWidgetLayoutChange = () => {
if (!this.state.room) return;
if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) {
// Show chat in right panel when a widget is maximised
dis.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.Timeline,
});
}
this.checkWidgets(this.state.room);
this.checkRightPanel(this.state.room);
};
private checkWidgets = (room) => {
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0,
hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room),
mainSplitContentType: this.getMainSplitContentType(room),
showApps: this.shouldShowApps(room),
});
};
private getMainSplitContentType = (room) => {
// TODO-video check if video should be displayed in main panel
return (WidgetLayoutStore.instance.hasMaximisedWidget(room))
? MainSplitContentType.MaximisedWidget
: MainSplitContentType.Timeline;
};
private checkRightPanel = (room) => {
// This is a hack to hide the chat. This should not be necessary once the right panel
// phase is stored per room. (need to be done after check widget so that mainSplitContentType is updated)
if (
RightPanelStore.getSharedInstance().roomPanelPhase === RightPanelPhases.Timeline &&
this.state.showRightPanel &&
!WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)
) {
// Two timelines are shown prevent this by hiding the right panel
dis.dispatch({
action: Action.ToggleRightPanel,
type: "room",
});
}
};
private onReadReceiptsChange = () => {
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
@ -392,6 +447,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} else {
newState.initialEventId = initialEventId;
newState.isInitialEventHighlighted = RoomViewStore.isInitialEventHighlighted();
if (thread && initialEvent?.isThreadRoot) {
dispatchShowThreadEvent(
thread.rootEvent,
initialEvent,
RoomViewStore.isInitialEventHighlighted(),
);
}
}
}
@ -503,18 +566,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
}
private onWidgetEchoStoreUpdate = () => {
if (!this.state.room) return;
this.setState({
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0,
showApps: this.shouldShowApps(this.state.room),
});
};
private onWidgetLayoutChange = () => {
this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters
};
private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) {
// if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek)
@ -582,15 +633,15 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// Check if user has previously chosen to hide the app drawer for this
// room. If so, do not show apps
const hideWidgetDrawer = localStorage.getItem(
room.roomId + "_hide_widget_drawer");
const hideWidgetKey = room.roomId + "_hide_widget_drawer";
const hideWidgetDrawer = localStorage.getItem(hideWidgetKey);
// This is confusing, but it means to say that we default to the tray being
// hidden unless the user clicked to open it.
const isManuallyShown = hideWidgetDrawer === "false";
// If unset show the Tray
// Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter.
const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false": true;
const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
return widgets.length > 0 || isManuallyShown;
return isManuallyShown && widgets.length > 0;
}
componentDidMount() {
@ -619,6 +670,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
componentDidUpdate() {
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState);
if (this.roomView.current) {
const roomView = this.roomView.current;
if (!roomView.ondrop) {
@ -649,6 +701,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
// update the scroll map before we get unmounted
if (this.state.roomId) {
RoomScrollStateStore.setScrollState(this.state.roomId, this.getScrollState());
@ -721,7 +775,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private onUserScroll = () => {
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.state.room.roomId,
event_id: this.state.initialEventId,
highlighted: false,
@ -772,6 +826,15 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
};
private onCallState = (roomId: string): void => {
// don't filter out payloads for room IDs other than props.room because
// we may be interested in the conf 1:1 room
if (!roomId) return;
const call = this.getCallForRoom();
this.setState({ callState: call ? call.state : null });
};
private onAction = payload => {
switch (payload.action) {
case 'message_sent':
@ -781,11 +844,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.injectSticker(
payload.data.content.url,
payload.data.content.info,
payload.data.description || payload.data.name);
payload.data.description || payload.data.name,
payload.data.threadId);
break;
case 'picture_snapshot':
ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, this.context);
[payload.file], this.state.room.roomId, null, this.context);
break;
case 'notifier_enabled':
case Action.UploadStarted:
@ -793,21 +857,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
case Action.UploadCanceled:
this.forceUpdate();
break;
case 'call_state': {
// don't filter out payloads for room IDs other than props.room because
// we may be interested in the conf 1:1 room
if (!payload.room_id) {
return;
}
const call = this.getCallForRoom();
this.setState({
callState: call ? call.state : null,
});
break;
}
case 'appsDrawer':
this.setState({
showApps: payload.show,
@ -830,7 +879,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
setImmediate(() => {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: roomId,
deferred_action: payload,
});
@ -880,7 +929,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
case "scroll_to_bottom":
if (payload.timelineRenderingType === this.context.timelineRenderingType) {
if (payload.timelineRenderingType === TimelineRenderingType.Room) {
this.messagePanel?.jumpToLiveTimeline();
}
break;
@ -941,7 +990,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
CHAT_EFFECTS.forEach(effect => {
if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) {
dis.dispatch({ action: `effects.${effect.command}` });
// For initial threads launch, chat effects are disabled
// see #19731
if (!SettingsStore.getValue("feature_thread") || !ev.isThreadRelation) {
dis.dispatch({ action: `effects.${effect.command}` });
}
}
});
};
@ -971,7 +1024,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (this.unmounted) return;
// Attach a widget store listener only when we get a room
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
this.onWidgetLayoutChange(); // provoke an update
this.calculatePeekRules(room);
this.updatePreviewUrlVisibility(room);
@ -980,6 +1032,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.updateE2EStatus(room);
this.updatePermissions(room);
this.checkWidgets(room);
this.checkRightPanel(room);
this.setState({
liveTimeline: room.getLiveTimeline(),
@ -1112,12 +1165,21 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
};
private onRoomStateEvents = (ev: MatrixEvent, state) => {
private onRoomStateEvents = (ev: MatrixEvent, state: RoomState) => {
// ignore if we don't have a room yet
if (!this.state.room || this.state.room.roomId !== state.roomId) {
return;
}
if (ev.getType() === EventType.RoomCanonicalAlias) {
// re-view the room so MatrixChat can manage the alias in the URL properly
dis.dispatch({
action: Action.ViewRoom,
room_id: this.state.room.roomId,
});
return; // this event cannot affect permissions so bail
}
this.updatePermissions(this.state.room);
};
@ -1209,7 +1271,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_room',
action: Action.ViewRoom,
room_id: this.getRoomId(),
},
});
@ -1291,7 +1353,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
ev.stopPropagation();
ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, this.context,
ev.dataTransfer.files, this.state.room.roomId, null, this.context,
);
dis.fire(Action.FocusSendMessageComposer);
@ -1301,13 +1363,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
};
private injectSticker(url: string, info: object, text: string) {
private injectSticker(url: string, info: object, text: string, threadId: string | null) {
if (this.context.isGuest()) {
dis.dispatch({ action: 'require_registration' });
return;
}
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, this.context)
ContentMessages.sharedInstance()
.sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context)
.then(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
@ -1477,16 +1540,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return ret;
}
private onCallPlaced = (type: PlaceCallType) => {
dis.dispatch({
action: 'place_call',
type: type,
room_id: this.state.room.roomId,
});
};
private onSettingsClick = () => {
dis.dispatch({ action: "open_room_settings" });
private onCallPlaced = (type: CallType): void => {
CallHandler.instance.placeCall(this.state.room?.roomId, type);
};
private onAppsClick = () => {
@ -1589,7 +1644,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// an event will take care of both clearing the URL fragment and
// jumping to the bottom
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.state.room.roomId,
});
} else {
@ -1698,7 +1753,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room) {
return null;
}
return CallHandler.sharedInstance().getCallForRoom(this.state.room.roomId);
return CallHandler.instance.getCallForRoom(this.state.room.roomId);
}
// this has to be a proper method rather than an unnamed function,
@ -2079,6 +2134,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel
? <RightPanel
room={this.state.room}
@ -2097,6 +2153,52 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const showChatEffects = SettingsStore.getValue('showChatEffects');
// Decide what to show in the main split
let mainSplitBody = <React.Fragment>
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
</React.Fragment>;
switch (this.state.mainSplitContentType) {
case MainSplitContentType.Timeline:
// keep the timeline in as the mainSplitBody
break;
case MainSplitContentType.MaximisedWidget:
if (!SettingsStore.getValue("feature_maximised_widgets")) break;
mainSplitBody = <AppsDrawer
room={this.state.room}
userId={this.context.credentials.userId}
resizeNotifier={this.props.resizeNotifier}
showApps={true}
/>;
break;
// TODO-video MainSplitContentType.Video:
// break;
}
let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline];
let onAppsClick = this.onAppsClick;
let onForgetClick = this.onForgetClick;
let onSearchClick = this.onSearchClick;
if (this.state.mainSplitContentType === MainSplitContentType.MaximisedWidget) {
// Disable phase buttons and action button to have a simplified header when a widget is maximised
// and enable (not disable) the RightPanelPhases.Timeline button
excludedRightPanelPhaseButtons = [
RightPanelPhases.ThreadPanel,
RightPanelPhases.PinnedMessages,
];
onAppsClick = null;
onForgetClick = null;
onSearchClick = null;
}
return (
<RoomContext.Provider value={this.state}>
<main className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
@ -2109,27 +2211,17 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
searchInfo={searchInfo}
oobData={this.props.oobData}
inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onSearchClick={onSearchClick}
onForgetClick={(myMembership === "leave") ? onForgetClick : null}
e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null}
onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null}
appsShown={this.state.showApps}
onCallPlaced={this.onCallPlaced}
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className="mx_RoomView_body">
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
{ mainSplitBody }
</div>
</MainSplit>
</ErrorBoundary>

View file

@ -750,6 +750,8 @@ export default class ScrollPanel extends React.Component<IProps> {
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
this.pages = Math.ceil(height / PAGE_SIZE);
const displayScrollbar = contentHeight > minHeight;
sn.dataset.scrollbar = displayScrollbar.toString();
this.bottomGrowth = 0;
const newHeight = `${this.getListHeight()}px`;

View file

@ -23,6 +23,7 @@ import { Key } from '../../Keyboard';
import dis from '../../dispatcher/dispatcher';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import { replaceableComponent } from "../../utils/replaceableComponent";
import { Action } from '../../dispatcher/actions';
interface IProps {
onSearch?: (query: string) => void;
@ -78,7 +79,7 @@ export default class SearchBox extends React.Component<IProps, IState> {
if (!this.props.enableRoomSearchFocus) return;
switch (payload.action) {
case 'view_room':
case Action.ViewRoom:
if (this.search.current && payload.clear_search) {
this.clearSearch();
}

View file

@ -49,7 +49,7 @@ import { mediaFromMxc } from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import { useStateToggle } from "../../hooks/useStateToggle";
import { getChildOrder } from "../../stores/SpaceStore";
import { getChildOrder } from "../../stores/spaces/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { useDispatcher } from "../../hooks/useDispatcher";
@ -68,7 +68,6 @@ interface IProps {
initialText?: string;
additionalButtons?: ReactNode;
showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void;
joinRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void;
}
interface ITileProps {
@ -78,7 +77,7 @@ interface ITileProps {
numChildRooms?: number;
hasPermissions?: boolean;
onViewRoomClick(): void;
onJoinRoomClick(): void;
onJoinRoomClick(): Promise<unknown>;
onToggleClick?(): void;
}
@ -115,9 +114,9 @@ const Tile: React.FC<ITileProps> = ({
setBusy(true);
ev.preventDefault();
ev.stopPropagation();
onJoinRoomClick();
setJoinedRoom(await awaitRoomDownSync(cli, room.room_id));
setBusy(false);
onJoinRoomClick().then(() => awaitRoomDownSync(cli, room.room_id)).then(setJoinedRoom).finally(() => {
setBusy(false);
});
};
let button;
@ -141,7 +140,7 @@ const Tile: React.FC<ITileProps> = ({
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
} else {
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
@ -343,7 +342,7 @@ export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st
});
};
export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void => {
export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): Promise<unknown> => {
// Don't let the user view a room they won't be able to either peek or join:
// fail earlier so they don't have to click back to the directory.
if (cli.isGuest()) {
@ -351,11 +350,15 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st
return;
}
cli.joinRoom(roomId, {
const prom = cli.joinRoom(roomId, {
viaServers: Array.from(hierarchy.viaMap.get(roomId) || []),
}).catch(err => {
});
prom.catch(err => {
RoomViewStore.showJoinRoomError(err, roomId);
});
return prom;
};
interface IHierarchyLevelProps {
@ -365,7 +368,7 @@ interface IHierarchyLevelProps {
parents: Set<string>;
selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, roomType?: RoomType): void;
onJoinRoomClick(roomId: string): void;
onJoinRoomClick(roomId: string): Promise<unknown>;
onToggleClick?(parentId: string, childId: string): void;
}

View file

@ -49,6 +49,7 @@ import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPan
import { useStateArray } from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import {
shouldShowSpaceInvite,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
@ -56,9 +57,9 @@ import {
showSpaceInvite,
showSpaceSettings,
} from "../../utils/space";
import SpaceHierarchy, { joinRoom, showRoom } from "./SpaceHierarchy";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import SpaceStore from "../../stores/SpaceStore";
import SpaceStore from "../../stores/spaces/SpaceStore";
import FacePile from "../views/elements/FacePile";
import {
AddExistingToSpace,
@ -127,6 +128,7 @@ const useMyRoomMembership = (room: Room) => {
};
const SpaceInfo = ({ space }: { space: Room }) => {
// summary will begin as undefined whilst loading and go null if it fails to load.
const summary = useAsyncMemo(async () => {
if (space.getMyMembership() !== "invite") return;
try {
@ -155,7 +157,7 @@ const SpaceInfo = ({ space }: { space: Room }) => {
memberSection = <span className="mx_SpaceRoomView_info_memberCount">
{ _t("%(count)s members", { count: summary.num_joined_members }) }
</span>;
} else if (summary === null) {
} else if (summary !== undefined) { // summary is not still loading
memberSection = <RoomMemberCount room={space}>
{ (count) => count > 0 ? (
<AccessibleButton
@ -394,7 +396,7 @@ const SpaceLandingAddButton = ({ space }) => {
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
iconClassName="mx_RoomList_iconAddExistingRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -438,9 +440,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
const userId = cli.getUserId();
let inviteButton;
if (((myMembership === "join" && space.canInvite(userId)) || space.getJoinRule() === JoinRule.Public) &&
shouldShowComponent(UIComponent.InviteUsers)
) {
if (shouldShowSpaceInvite(space) && shouldShowComponent(UIComponent.InviteUsers)) {
inviteButton = (
<AccessibleButton
kind="primary"
@ -507,7 +507,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
) }
</RoomTopic>
<SpaceHierarchy space={space} showRoom={showRoom} joinRoom={joinRoom} additionalButtons={addRoomButton} />
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>;
};

View file

@ -14,29 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
import { Room } from 'matrix-js-sdk/src/models/room';
import BaseCard from "../views/right_panel/BaseCard";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import ResizeNotifier from '../../utils/ResizeNotifier';
import MatrixClientContext from '../../contexts/MatrixClientContext';
import { _t } from '../../languageHandler';
import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton';
import ContextMenu, { useContextMenu } from './ContextMenu';
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from './ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import TimelinePanel from './TimelinePanel';
import { Layout } from '../../settings/Layout';
import { Layout } from '../../settings/enums/Layout';
import { useEventEmitter } from '../../hooks/useEventEmitter';
import AccessibleButton from '../views/elements/AccessibleButton';
import { TileShape } from '../views/rooms/EventTile';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
interface IProps {
roomId: string;
onClose: () => void;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
}
export enum ThreadFilterType {
@ -69,46 +69,21 @@ const useFilteredThreadsTimelinePanel = ({
pendingEvents: false,
}), []);
useEffect(() => {
let filteredThreads = Array.from(threads);
if (filterOption === ThreadFilterType.My) {
filteredThreads = filteredThreads.filter(([id, thread]) => {
return thread.rootEvent.getSender() === userId;
const buildThreadList = useCallback(function(timelineSet: EventTimelineSet) {
timelineSet.resetLiveTimeline("");
Array.from(threads)
.forEach(([, thread]) => {
if (filterOption !== ThreadFilterType.My || thread.hasCurrentUserParticipated) {
timelineSet.addLiveEvent(thread.rootEvent);
}
});
}
// NOTE: Temporarily reverse the list until https://github.com/vector-im/element-web/issues/19393 gets properly resolved
// The proper list order should be top-to-bottom, like in social-media newsfeeds.
filteredThreads.reverse().forEach(([id, thread]) => {
const event = thread.rootEvent;
if (timelineSet.findEventById(event.getId()) || event.status !== null) return;
timelineSet.addEventToTimeline(
event,
timelineSet.getLiveTimeline(),
true,
);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, timelineSet]);
useEventEmitter(room, ThreadEvent.Update, (thread) => {
const event = thread.rootEvent;
if (
// If that's a reply and not an event
event !== thread.replyToEvent &&
timelineSet.findEventById(event.getId()) ||
event.status !== null
) return;
if (event !== thread.events[thread.events.length - 1]) {
timelineSet.removeEvent(thread.events[thread.events.length - 1]);
timelineSet.removeEvent(event);
}
timelineSet.addEventToTimeline(
event,
timelineSet.getLiveTimeline(),
false,
);
updateTimeline();
});
}, [filterOption, threads, updateTimeline]);
useEffect(() => { buildThreadList(timelineSet); }, [timelineSet, buildThreadList]);
useEventEmitter(room, ThreadEvent.Update, () => { buildThreadList(timelineSet); });
useEventEmitter(room, ThreadEvent.New, () => { buildThreadList(timelineSet); });
return timelineSet;
};
@ -122,14 +97,14 @@ export const ThreadPanelHeaderFilterOptionItem = ({
onClick: () => void;
isSelected: boolean;
}) => {
return <AccessibleButton
aria-selected={isSelected}
return <MenuItemRadio
active={isSelected}
className="mx_ThreadPanel_Header_FilterOptionItem"
onClick={onClick}
>
<span>{ label }</span>
<span>{ description }</span>
</AccessibleButton>;
</MenuItemRadio>;
};
export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
@ -140,7 +115,7 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
const options: readonly ThreadPanelHeaderOption[] = [
{
label: _t("My threads"),
description: _t("Shows all threads youve participated in"),
description: _t("Shows all threads you've participated in"),
key: ThreadFilterType.My,
},
{
@ -161,19 +136,49 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
}}
isSelected={opt === value}
/>);
const contextMenu = menuDisplayed ? <ContextMenu top={0} right={25} onFinished={closeMenu} managed={false}>
const contextMenu = menuDisplayed ? <ContextMenu
top={0}
right={25}
onFinished={closeMenu}
chevronFace={ChevronFace.Top}
mountAsChild={true}
>
{ contextMenuOptions }
</ContextMenu> : null;
return <div className="mx_ThreadPanel__header">
<span>{ _t("Threads") }</span>
<ContextMenuButton inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
<ContextMenuButton className="mx_ThreadPanel_dropdown" inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
{ `${_t('Show:')} ${value.label}` }
</ContextMenuButton>
{ contextMenu }
</div>;
};
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
interface EmptyThreadIProps {
filterOption: ThreadFilterType;
showAllThreadsCallback: () => void;
}
const EmptyThread: React.FC<EmptyThreadIProps> = ({ filterOption, showAllThreadsCallback }) => {
return <aside className="mx_ThreadPanel_empty">
<div className="mx_ThreadPanel_largeIcon" />
<h2>{ _t("Keep discussions organised with threads") }</h2>
<p>{ _t("Threads help you keep conversations on-topic and easily "
+ "track them over time. Create the first one by using the "
+ "\"Reply in thread\" button on a message.") }
</p>
<p>
{ /* Always display that paragraph to prevent layout shift
When hiding the button */ }
{ filterOption === ThreadFilterType.My
? <button onClick={showAllThreadsCallback}>{ _t("Show all threads") }</button>
: <>&nbsp;</>
}
</p>
</aside>;
};
const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) => {
const mxClient = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const room = mxClient.getRoom(roomId);
@ -199,7 +204,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
header={<ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />}
className="mx_ThreadPanel"
onClose={onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
<TimelinePanel
ref={ref}
@ -209,7 +214,10 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
sendReadReceiptOnLoad={false} // No RR support in thread's MVP
timelineSet={filteredTimelineSet}
showUrlPreview={true}
empty={<div>empty</div>}
empty={<EmptyThread
filterOption={filterOption}
showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)}
/>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
@ -217,7 +225,9 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
showReactions={true}
className="mx_RoomView_messagePanel mx_GroupLayout"
membersLoaded={true}
permalinkCreator={permalinkCreator}
tileShape={TileShape.ThreadPanel}
disableGrouping={true}
/>
</BaseCard>
</RoomContext.Provider>

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk/src';
import { IEventRelation, MatrixEvent, Room } from 'matrix-js-sdk/src';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
@ -26,7 +26,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier';
import { TileShape } from '../views/rooms/EventTile';
import MessageComposer from '../views/rooms/MessageComposer';
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import { Layout } from '../../settings/Layout';
import { Layout } from '../../settings/enums/Layout';
import TimelinePanel from './TimelinePanel';
import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from '../../dispatcher/payloads';
@ -36,6 +36,13 @@ import { MatrixClientPeg } from '../../MatrixClientPeg';
import { E2EStatus } from '../../utils/ShieldUtils';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
import ContentMessages from '../../ContentMessages';
import UploadBar from './UploadBar';
import { _t } from '../../languageHandler';
import ThreadListContextMenu from '../views/context_menus/ThreadListContextMenu';
import RightPanelStore from '../../stores/RightPanelStore';
import SettingsStore from '../../settings/SettingsStore';
import { WidgetLayoutStore } from '../../stores/widgets/WidgetLayoutStore';
interface IProps {
room: Room;
@ -47,7 +54,6 @@ interface IProps {
initialEvent?: MatrixEvent;
initialEventHighlighted?: boolean;
}
interface IState {
thread?: Thread;
editState?: EditorStateTransfer;
@ -78,7 +84,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
this.teardownThread();
dis.unregister(this.dispatcherRef);
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
room.on(ThreadEvent.New, this.onNewThread);
room.removeListener(ThreadEvent.New, this.onNewThread);
}
public componentDidUpdate(prevProps) {
@ -97,10 +103,8 @@ export default class ThreadView extends React.Component<IProps, IState> {
private onAction = (payload: ActionPayload): void => {
if (payload.phase == RightPanelPhases.ThreadView && payload.event) {
if (payload.event !== this.props.mxEvent) {
this.teardownThread();
this.setupThread(payload.event);
}
this.teardownThread();
this.setupThread(payload.event);
}
switch (payload.action) {
case Action.EditEvent:
@ -129,15 +133,18 @@ export default class ThreadView extends React.Component<IProps, IState> {
};
private setupThread = (mxEv: MatrixEvent) => {
let thread = mxEv.getThread();
let thread = this.props.room.threads.get(mxEv.getId());
if (!thread) {
const client = MatrixClientPeg.get();
// Do not attach this thread object to the event for now
// TODO: When local echo gets reintroduced it will be important
// to add that back in, and the threads model should go through the
// same reconciliation algorithm as events
thread = new Thread(
[mxEv],
this.props.room,
client,
);
mxEv.setThread(thread);
}
thread.on(ThreadEvent.Update, this.updateThread);
thread.once(ThreadEvent.Ready, this.updateThread);
@ -159,10 +166,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
};
private updateThread = (thread?: Thread) => {
if (thread) {
if (thread && this.state.thread !== thread) {
this.setState({
thread,
});
thread.emit(ThreadEvent.ViewThread);
}
this.timelinePanelRef.current?.refreshTimeline();
@ -171,7 +179,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
private onScroll = (): void => {
if (this.props.initialEvent && this.props.initialEventHighlighted) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.props.room.roomId,
event_id: this.props.initialEvent?.getId(),
highlighted: false,
@ -180,10 +188,43 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
};
private renderThreadViewHeader = (): JSX.Element => {
return <div className="mx_ThreadPanel__header">
<span>{ _t("Thread") }</span>
<ThreadListContextMenu
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} />
</div>;
};
public render(): JSX.Element {
const highlightedEventId = this.props.initialEventHighlighted
? this.props.initialEvent?.getId()
: null;
const threadRelation: IEventRelation = {
rel_type: RelationType.Thread,
event_id: this.state.thread?.id,
};
let previousPhase = RightPanelStore.getSharedInstance().previousPhase;
if (!SettingsStore.getValue("feature_maximised_widgets")) {
previousPhase = RightPanelPhases.ThreadPanel;
}
// change the previous phase to the threadPanel in case there is no maximised widget anymore
if (!WidgetLayoutStore.instance.hasMaximisedWidget(this.props.room)) {
previousPhase = RightPanelPhases.ThreadPanel;
}
// Make sure the previous Phase is always one of the two: Timeline or ThreadPanel
if (![RightPanelPhases.ThreadPanel, RightPanelPhases.Timeline].includes(previousPhase)) {
previousPhase = RightPanelPhases.ThreadPanel;
}
const previousPhaseLabels = {};
previousPhaseLabels[RightPanelPhases.ThreadPanel] = _t("All threads");
previousPhaseLabels[RightPanelPhases.Timeline] = _t("Chat");
return (
<RoomContext.Provider value={{
...this.context,
@ -192,23 +233,24 @@ export default class ThreadView extends React.Component<IProps, IState> {
}}>
<BaseCard
className="mx_ThreadView"
className="mx_ThreadView mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.ThreadPanel}
previousPhase={previousPhase}
previousPhaseLabel={previousPhaseLabels[previousPhase]}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
>
{ 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
showReadReceipts={false} // Hide the read receipts
// until homeservers speak threads language
manageReadReceipts={true}
manageReadMarkers={true}
sendReadReceiptOnLoad={true}
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={true}
tileShape={TileShape.Thread}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
hidden={false}
@ -223,13 +265,14 @@ export default class ThreadView extends React.Component<IProps, IState> {
/>
) }
{ ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />
) }
{ this.state?.thread?.timelineSet && (<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={{
rel_type: RelationType.Thread,
event_id: this.state.thread.id,
}}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}

View file

@ -27,13 +27,14 @@ import { debounce } from 'lodash';
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/Layout";
import { Layout } from "../../settings/enums/Layout";
import { _t } from '../../languageHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomContext from "../../contexts/RoomContext";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import UserActivity from "../../UserActivity";
import Modal from "../../Modal";
import dis from "../../dispatcher/dispatcher";
import { Action } from '../../dispatcher/actions';
import { Key } from '../../Keyboard';
import Timer from '../../utils/Timer';
import shouldHideEvent from '../../shouldHideEvent';
@ -132,6 +133,7 @@ interface IProps {
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
}
interface IState {
@ -221,6 +223,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
disableGrouping: false,
};
private lastRRSentEventId: string = undefined;
@ -474,10 +477,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
};
private onMessageListScroll = e => {
if (this.props.onScroll) {
this.props.onScroll(e);
}
this.props.onScroll?.(e);
if (this.props.manageReadMarkers) {
this.doManageReadMarkers();
}
@ -505,7 +505,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// (and user is active), switch timeout
const timeout = this.readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this.readMarkerActivityTimer.changeTimeout(timeout);
this.readMarkerActivityTimer?.changeTimeout(timeout);
}, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true });
private onAction = (payload: ActionPayload): void => {
@ -592,7 +592,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState<null>(updatedState, () => {
this.messagePanel.current.updateTimelineMinHeight();
if (callRMUpdated) {
this.props.onReadMarkerUpdated();
this.props.onReadMarkerUpdated?.();
}
});
});
@ -1134,7 +1134,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
onFinished = () => {
// go via the dispatcher so that the URL is updated
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: this.props.timelineSet.room.roomId,
});
};
@ -1309,12 +1309,17 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
private indexForEventId(evId: string): number | null {
for (let i = 0; i < this.state.events.length; ++i) {
if (evId == this.state.events[i].getId()) {
return i;
}
/* Threads do not have server side support for read receipts and the concept
is very tied to the main room timeline, we are forcing the timeline to
send read receipts for threaded events */
const isThreadTimeline = this.context.timelineRenderingType === TimelineRenderingType.Thread;
if (SettingsStore.getValue("feature_thread") && isThreadTimeline) {
return 0;
}
return null;
const index = this.state.events.findIndex(ev => ev.getId() === evId);
return index > -1
? index
: null;
}
private getLastDisplayedEventIndex(opts: IEventIndexOpts = {}): number | null {
@ -1538,6 +1543,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
hideThreadedMessages={this.props.hideThreadedMessages}
disableGrouping={this.props.disableGrouping}
/>
);
}

View file

@ -28,9 +28,11 @@ import AccessibleButton from "../views/elements/AccessibleButton";
import { IUpload } from "../../models/IUpload";
import { replaceableComponent } from "../../utils/replaceableComponent";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { IEventRelation } from 'matrix-js-sdk/src';
interface IProps {
room: Room;
relation?: IEventRelation;
}
interface IState {
@ -65,7 +67,7 @@ export default class UploadBar extends React.Component<IProps, IState> {
}
private getUploadsInRoom(): IUpload[] {
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation);
return uploads.filter(u => u.roomId === this.props.room.roomId);
}

View file

@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import React, { createRef, useContext, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import * as fbEmitter from "fbemitter";
import { logger } from "matrix-js-sdk/src/logger";
import classNames from "classnames";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
@ -26,7 +25,7 @@ import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler";
import { ContextMenuButton } from "./ContextMenu";
import { ChevronFace, ContextMenuButton } from "./ContextMenu";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
@ -43,26 +42,62 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { SettingLevel } from "../../settings/SettingLevel";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import { showCommunityInviteDialog } from "../../RoomInvite";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog";
import { UIFeature } from "../../settings/UIFeature";
import HostSignupAction from "./HostSignupAction";
import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import RoomName from "../views/elements/RoomName";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { replaceableComponent } from "../../utils/replaceableComponent";
import InlineSpinner from "../views/elements/InlineSpinner";
import TooltipButton from "../views/elements/TooltipButton";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
const CustomStatusSection = () => {
const cli = useContext(MatrixClientContext);
const setStatus = cli.getUser(cli.getUserId()).unstable_statusMessage || "";
const [value, setValue] = useState(setStatus);
let details: JSX.Element;
if (value !== setStatus) {
details = <>
<p>{ _t("Your status will be shown to people you have a DM with.") }</p>
<AccessibleButton
onClick={() => cli._unstable_setStatusMessage(value)}
kind="primary_outline"
>
{ value ? _t("Set status") : _t("Clear status") }
</AccessibleButton>
</>;
}
return <div className="mx_UserMenu_CustomStatusSection">
<div className="mx_UserMenu_CustomStatusSection_input">
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder={_t("Set a new status")}
autoComplete="off"
/>
<AccessibleButton
tabIndex={-1}
title={_t("Clear")}
className="mx_UserMenu_CustomStatusSection_clear"
onClick={() => setValue("")}
/>
</div>
{ details }
</div>;
};
interface IProps {
isMinimized: boolean;
isPanelCollapsed: boolean;
}
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
@ -72,14 +107,30 @@ interface IState {
isDarkTheme: boolean;
isHighContrast: boolean;
selectedSpace?: Room;
pendingRoomJoin: Set<string>;
dndEnabled: boolean;
}
const toRightOf = (rect: PartialDOMRect) => {
return {
left: rect.width + rect.left + 8,
top: rect.top,
chevronFace: ChevronFace.None,
};
};
const below = (rect: PartialDOMRect) => {
return {
left: rect.left,
top: rect.top + rect.height,
chevronFace: ChevronFace.None,
};
};
@replaceableComponent("structures.UserMenu")
export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private dndWatcherRef: string;
private readonly dndWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
private tagStoreRef: fbEmitter.EventSubscription;
@ -90,7 +141,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
pendingRoomJoin: new Set<string>(),
dndEnabled: this.doNotDisturb,
selectedSpace: SpaceStore.instance.activeSpaceRoom,
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
@ -98,8 +150,12 @@ export default class UserMenu extends React.Component<IProps, IState> {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
// Force update is the easiest way to trigger the UI update (we don't store state for this)
this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate());
SettingsStore.monitorSetting("feature_dnd", null);
SettingsStore.monitorSetting("doNotDisturb", null);
}
private get doNotDisturb(): boolean {
return SettingsStore.getValue("doNotDisturb");
}
private get hasHomePage(): boolean {
@ -110,7 +166,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
MatrixClientPeg.get().on("Room", this.onRoom);
}
public componentWillUnmount() {
@ -122,13 +177,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SpaceStore.spacesEnabled) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
MatrixClientPeg.get().removeListener("Room", this.onRoom);
}
private onRoom = (room: Room): void => {
this.removePendingJoinRoom(room.roomId);
};
private onTagStoreUpdate = () => {
this.forceUpdate(); // we don't have anything useful in state to update
};
@ -163,8 +213,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.forceUpdate();
};
private onSelectedSpaceUpdate = async (selectedSpace?: Room) => {
this.setState({ selectedSpace });
private onSelectedSpaceUpdate = async () => {
this.setState({
selectedSpace: SpaceStore.instance.activeSpaceRoom,
});
};
private onThemeChanged = () => {
@ -175,8 +227,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
});
};
private onAction = (ev: ActionPayload) => {
switch (ev.action) {
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case Action.ToggleUserMenu:
if (this.state.contextMenuPosition) {
this.setState({ contextMenuPosition: null });
@ -184,36 +236,27 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (this.buttonRef.current) this.buttonRef.current.click();
}
break;
case Action.JoinRoom:
this.addPendingJoinRoom(ev.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
this.removePendingJoinRoom(ev.roomId);
break;
case Action.SettingUpdated: {
const settingUpdatedPayload = payload as SettingUpdatedPayload;
switch (settingUpdatedPayload.settingName) {
case "feature_dnd":
case "doNotDisturb": {
const dndEnabled = this.doNotDisturb;
if (this.state.dndEnabled !== dndEnabled) {
this.setState({ dndEnabled });
}
break;
}
}
}
}
};
private addPendingJoinRoom(roomId: string): void {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin)
.add(roomId),
});
}
private removePendingJoinRoom(roomId: string): void {
if (this.state.pendingRoomJoin.delete(roomId)) {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin),
});
}
}
private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
this.setState({ contextMenuPosition: ev.currentTarget.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent) => {
@ -259,15 +302,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onShowArchived = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
// TODO: Archived room view: https://github.com/vector-im/element-web/issues/14038
// Note: You'll need to uncomment the button too.
logger.log("TODO: Show archived rooms");
};
private onProvideFeedback = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -309,50 +343,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunitySettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, {
communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(),
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunityMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
// We'd ideally just pop open a right panel with the member list, but the current
// way the right panel is structured makes this exceedingly difficult. Instead, we'll
// switch to the general room and open the member list there as it should be in sync
// anyways.
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat) {
dis.dispatch({
action: 'view_room',
room_id: chat.roomId,
}, true);
dis.dispatch({ action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList });
} else {
// "This should never happen" clauses go here for the prototype.
Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, {
title: _t('Failed to find the general chat for this community'),
description: _t("Failed to find the general chat for this community"),
});
}
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onCommunityInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onDndToggle = (ev) => {
private onDndToggle = (ev: ButtonEvent) => {
ev.stopPropagation();
const current = SettingsStore.getValue("doNotDisturb");
SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current);
@ -361,8 +352,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null;
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let topSection;
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup;
if (MatrixClientPeg.get().isGuest()) {
@ -408,6 +397,24 @@ export default class UserMenu extends React.Component<IProps, IState> {
);
}
let customStatusSection: JSX.Element;
if (SettingsStore.getValue("feature_custom_status")) {
customStatusSection = <CustomStatusSection />;
}
let dndButton: JSX.Element;
if (SettingsStore.getValue("feature_dnd")) {
dndButton = (
<IconizedContextMenuCheckbox
iconClassName={this.state.dndEnabled ? "mx_UserMenu_iconDnd" : "mx_UserMenu_iconDndOff"}
label={_t("Do not disturb")}
onClick={this.onDndToggle}
active={this.state.dndEnabled}
words
/>
);
}
let feedbackButton;
if (SettingsStore.getValue(UIFeature.Feedback)) {
feedbackButton = <IconizedContextMenuOption
@ -417,156 +424,68 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>;
}
let primaryHeader = (
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
);
let primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{ homeButton }
{ dndButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notifications")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & privacy")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
<IconizedContextMenuOption
className="mx_IconizedContextMenu_option_red"
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
);
if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<IconizedContextMenuOptionList>
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconBell"
label={_t("Notification settings")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconLock"
label={_t("Security & privacy")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ /* <IconizedContextMenuOption
iconClassName="mx_UserMenu_iconArchive"
label={_t("Archived rooms")}
onClick={this.onShowArchived}
/> */ }
{ feedbackButton }
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
</React.Fragment>
);
let secondarySection = null;
if (prototypeCommunityName) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
primaryHeader = (
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ prototypeCommunityName }
</span>
</div>
);
let settingsOption;
let inviteOption;
if (CommunityPrototypeStore.instance.canInviteTo(communityId)) {
inviteOption = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconInvite"
label={_t("Invite")}
onClick={this.onCommunityInviteClick}
/>
);
}
if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
aria-label={_t("Community settings")}
onClick={this.onCommunitySettingsClick}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
);
}
primaryOptionList = (
<IconizedContextMenuOptionList>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconMembers"
label={_t("Members")}
onClick={this.onCommunityMembersClick}
/>
{ inviteOption }
{ feedbackButton }
</IconizedContextMenuOptionList>
);
secondarySection = (
<React.Fragment>
<hr />
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
</div>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
aria-label={_t("User settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList red>
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSignOut"
label={_t("Sign out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
</React.Fragment>
);
} else if (MatrixClientPeg.get().isGuest()) {
primaryOptionList = (
<React.Fragment>
<IconizedContextMenuOptionList>
{ homeButton }
<IconizedContextMenuOption
iconClassName="mx_UserMenu_iconSettings"
label={_t("Settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
{ feedbackButton }
</IconizedContextMenuOptionList>
</React.Fragment>
);
}
const classes = classNames({
"mx_UserMenu_contextMenu": true,
"mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName,
});
const position = this.props.isPanelCollapsed
? toRightOf(this.state.contextMenuPosition)
: below(this.state.contextMenuPosition);
return <IconizedContextMenu
// numerical adjustments to overlap the context menu by just over the width of the
// menu icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 10}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height + 8}
{...position}
onFinished={this.onCloseMenu}
className={classes}
className="mx_UserMenu_contextMenu"
>
<div className="mx_UserMenu_contextMenu_header">
{ primaryHeader }
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{ OwnProfileStore.instance.displayName }
</span>
<span className="mx_UserMenu_contextMenu_userId">
{ MatrixClientPeg.get().getUserId() }
</span>
</div>
<AccessibleTooltipButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
@ -579,9 +498,9 @@ export default class UserMenu extends React.Component<IProps, IState> {
/>
</AccessibleTooltipButton>
</div>
{ customStatusSection }
{ topSection }
{ primaryOptionList }
{ secondarySection }
</IconizedContextMenu>;
};
@ -592,102 +511,47 @@ export default class UserMenu extends React.Component<IProps, IState> {
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName();
let badge: JSX.Element;
if (this.state.dndEnabled) {
badge = <div className="mx_UserMenu_dndBadge" />;
}
let isPrototype = false;
let menuName = _t("User menu");
let name = <span className="mx_UserMenu_userName">{ displayName }</span>;
let buttons = (
<span className="mx_UserMenu_headerButtons">
{ /* masked image in CSS */ }
</span>
);
let dnd;
if (this.state.selectedSpace) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{ displayName }</span>
<RoomName room={this.state.selectedSpace}>
{ (roomName) => <span className="mx_UserMenu_subUserName">{ roomName }</span> }
</RoomName>
</div>
);
} else if (prototypeCommunityName) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{ prototypeCommunityName }</span>
<span className="mx_UserMenu_subUserName">{ displayName }</span>
</div>
);
menuName = _t("Community and user menu");
isPrototype = true;
} else if (SettingsStore.getValue("feature_communities_v2_prototypes")) {
name = (
<div className="mx_UserMenu_doubleName">
<span className="mx_UserMenu_userName">{ _t("Home") }</span>
<span className="mx_UserMenu_subUserName">{ displayName }</span>
</div>
);
isPrototype = true;
} else if (SettingsStore.getValue("feature_dnd")) {
const isDnd = SettingsStore.getValue("doNotDisturb");
dnd = <AccessibleButton
onClick={this.onDndToggle}
let name: JSX.Element;
if (!this.props.isPanelCollapsed) {
name = <div className="mx_UserMenu_name">
{ displayName }
</div>;
}
return <div className="mx_UserMenu">
<ContextMenuButton
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("User menu")}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
className={classNames({
"mx_UserMenu_dnd": true,
"mx_UserMenu_dnd_noisy": !isDnd,
"mx_UserMenu_dnd_muted": isDnd,
mx_UserMenu_cutout: badge,
})}
/>;
}
if (this.props.isMinimized) {
name = null;
buttons = null;
}
>
<div className="mx_UserMenu_userAvatar">
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar_BaseAvatar"
/>
{ badge }
</div>
{ name }
const classes = classNames({
'mx_UserMenu': true,
'mx_UserMenu_minimized': this.props.isMinimized,
'mx_UserMenu_prototype': isPrototype,
});
return (
<React.Fragment>
<ContextMenuButton
className={classes}
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={menuName}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
>
<div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar"
/>
</span>
{ name }
{ this.state.pendingRoomJoin.size > 0 && (
<InlineSpinner>
<TooltipButton helpText={_t(
"Currently joining %(count)s rooms",
{ count: this.state.pendingRoomJoin.size },
)} />
</InlineSpinner>
) }
{ dnd }
{ buttons }
</div>
</ContextMenuButton>
{ this.renderContextMenu() }
</React.Fragment>
);
</ContextMenuButton>
{ this.props.children }
</div>;
}
}

View file

@ -17,11 +17,12 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
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';
import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody";
import AuthPage from "../../views/auth/AuthPage";
interface IProps {
onFinished: () => void;
@ -59,8 +60,6 @@ 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, lostKeys } = this.state;
let icon;
let title;

View file

@ -17,14 +17,11 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { logger } from "matrix-js-sdk/src/logger";
import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from "../../../Modal";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics";
import ServerPicker from "../../views/elements/ServerPicker";
@ -32,8 +29,14 @@ import EmailField from "../../views/auth/EmailField";
import PassphraseField from '../../views/auth/PassphraseField';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
import { IValidationResult } from "../../views/elements/Validation";
import InlineSpinner from '../../views/elements/InlineSpinner';
import { logger } from "matrix-js-sdk/src/logger";
import Spinner from "../../views/elements/Spinner";
import QuestionDialog from "../../views/dialogs/QuestionDialog";
import ErrorDialog from "../../views/dialogs/ErrorDialog";
import AuthHeader from "../../views/auth/AuthHeader";
import AuthBody from "../../views/auth/AuthBody";
import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField";
enum Phase {
// Show the forgot password inputs
@ -68,11 +71,15 @@ interface IState {
serverErrorIsFatal: boolean;
serverDeadError: string;
emailFieldValid: boolean;
passwordFieldValid: boolean;
currentHttpRequest?: Promise<any>;
}
enum ForgotPasswordField {
Email = 'field_email',
Password = 'field_password',
PasswordConfirm = 'field_password_confirm',
}
@replaceableComponent("structures.auth.ForgotPassword")
export default class ForgotPassword extends React.Component<IProps, IState> {
private reset: PasswordReset;
@ -91,8 +98,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
emailFieldValid: false,
passwordFieldValid: false,
};
constructor(props: IProps) {
@ -171,42 +176,58 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
// refresh the server errors, just in case the server came back online
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
await this['email_field'].validate({ allowEmpty: false });
await this['password_field'].validate({ allowEmpty: false });
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.emailFieldValid) {
this.showErrorDialog(_t("The email address doesn't appear to be valid."));
} else if (!this.state.password || !this.state.password2) {
this.showErrorDialog(_t('A new password must be entered.'));
} else if (!this.state.passwordFieldValid) {
this.showErrorDialog(_t('Please choose a strong password'));
} else if (this.state.password !== this.state.password2) {
this.showErrorDialog(_t('New passwords must match each other.'));
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
"Changing your password will reset any end-to-end encryption keys " +
"on all of your sessions, making encrypted chat history unreadable. Set up " +
"Key Backup or export your room keys from another session before resetting your " +
"password.",
) }
</div>,
button: _t('Continue'),
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(this.state.email, this.state.password);
}
},
});
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
"Changing your password will reset any end-to-end encryption keys " +
"on all of your sessions, making encrypted chat history unreadable. Set up " +
"Key Backup or export your room keys from another session before resetting your " +
"password.",
) }
</div>,
button: _t('Continue'),
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(this.state.email, this.state.password);
}
},
});
};
private async verifyFieldsBeforeSubmit() {
const fieldIdsInDisplayOrder = [
ForgotPasswordField.Email,
ForgotPasswordField.Password,
ForgotPasswordField.PasswordConfirm,
];
const invalidFields = [];
for (const fieldId of fieldIdsInDisplayOrder) {
const valid = await this[fieldId].validate({ allowEmpty: false });
if (!valid) {
invalidFields.push(this[fieldId]);
}
}
if (invalidFields.length === 0) {
return true;
}
// Focus on the first invalid field, then re-validate,
// which will result in the error tooltip being displayed for that field.
invalidFields[0].focus();
invalidFields[0].validate({ allowEmpty: false, focused: true });
return false;
}
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
this.setState({
[stateKey]: ev.currentTarget.value,
@ -220,25 +241,12 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
};
public showErrorDialog(description: string, title?: string) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
title,
description,
});
}
private onEmailValidate = (result: IValidationResult) => {
this.setState({
emailFieldValid: result.valid,
});
};
private onPasswordValidate(result: IValidationResult) {
this.setState({
passwordFieldValid: result.valid,
});
}
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
this.setState({
currentHttpRequest: request,
@ -251,8 +259,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
renderForgot() {
const Field = sdk.getComponent('elements.Field');
let errorText = null;
const err = this.state.errorText;
if (err) {
@ -284,11 +290,12 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
<div className="mx_AuthBody_fieldRow">
<EmailField
name="reset_email" // define a name so browser's password autofill gets less confused
labelRequired={_td('The email address linked to your account must be entered.')}
labelInvalid={_td("The email address doesn't appear to be valid.")}
value={this.state.email}
fieldRef={field => this['email_field'] = field}
fieldRef={field => this[ForgotPasswordField.Email] = field}
autoFocus={true}
onChange={this.onInputChanged.bind(this, "email")}
onValidate={this.onEmailValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}
/>
@ -300,18 +307,20 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
label={_td('New Password')}
value={this.state.password}
minScore={PASSWORD_MIN_SCORE}
fieldRef={field => this[ForgotPasswordField.Password] = field}
onChange={this.onInputChanged.bind(this, "password")}
fieldRef={field => this['password_field'] = field}
onValidate={(result) => this.onPasswordValidate(result)}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")}
autoComplete="new-password"
/>
<Field
<PassphraseConfirmField
name="reset_password_confirm"
type="password"
label={_t('Confirm')}
label={_td('Confirm')}
labelRequired={_td("A new password must be entered.")}
labelInvalid={_td("New passwords must match each other.")}
value={this.state.password2}
password={this.state.password}
fieldRef={field => this[ForgotPasswordField.PasswordConfirm] = field}
onChange={this.onInputChanged.bind(this, "password2")}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")}
@ -335,7 +344,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
renderSendingEmail() {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
@ -372,9 +380,6 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}
render() {
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
let resetPasswordJsx;
switch (this.state.phase) {
case Phase.Forgot:

View file

@ -303,7 +303,7 @@ export default class Registration extends React.Component<IProps, IState> {
errorText = _t('This server does not support authentication with a phone number.');
}
} else if (response.errcode === "M_USER_IN_USE") {
errorText = _t("That username already exists, please try another.");
errorText = _t("Someone already has that username, please try another.");
} else if (response.errcode === "M_THREEPID_IN_USE") {
errorText = _t("That e-mail address is already in use.");
}
@ -509,6 +509,7 @@ export default class Registration extends React.Component<IProps, IState> {
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
matrixClient={this.state.matrixClient}
/>
</React.Fragment>;
}

View file

@ -39,7 +39,7 @@ function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean {
}
interface IProps {
onFinished: (boolean) => void;
onFinished: () => void;
}
interface IState {
@ -70,7 +70,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
private onStoreUpdate = () => {
const store = SetupEncryptionStore.sharedInstance();
if (store.phase === Phase.Finished) {
this.props.onFinished(true);
this.props.onFinished();
return;
}
this.setState({
@ -97,13 +97,16 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
const userId = cli.getUserId();
const requestPromise = cli.requestVerification(userId);
this.props.onFinished(true);
// We need to call onFinished now to close this dialog, and
// again later to signal that the verification is complete.
this.props.onFinished();
Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId),
onFinished: async () => {
const request = await requestPromise;
request.cancel();
this.props.onFinished();
},
});
};
@ -125,6 +128,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
};
private onResetConfirmClick = () => {
this.props.onFinished();
const store = SetupEncryptionStore.sharedInstance();
store.resetConfirm();
};
@ -140,7 +144,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
};
private onEncryptionPanelClose = () => {
this.props.onFinished(false);
this.props.onFinished();
};
public render() {
@ -249,7 +253,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
return (
<div>
<p>{ _t(
"Without verifying, you wont have access to all your messages " +
"Without verifying, you won't have access to all your messages " +
"and may appear as untrusted to others.",
) }</p>
<div className="mx_CompleteSecurity_actionRow">

View file

@ -215,7 +215,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
if (this.state.keyBackupNeeded) {
introText = _t(
"Regain access to your account and recover encryption keys stored in this session. " +
"Without them, you wont be able to read all of your secure messages in any session.");
"Without them, you won't be able to read all of your secure messages in any session.");
}
if (this.state.loginView === LOGIN_VIEW.PASSWORD) {