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

View file

@ -0,0 +1,83 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { PureComponent, RefCallback, RefObject } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field, { IInputProps } from "../elements/Field";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { _t, _td } from "../../../languageHandler";
interface IProps extends Omit<IInputProps, "onValidate"> {
id?: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
autoComplete?: string;
value: string;
password: string; // The password we're confirming
labelRequired?: string;
labelInvalid?: string;
onChange(ev: React.FormEvent<HTMLElement>);
onValidate?(result: IValidationResult);
}
@replaceableComponent("views.auth.EmailField")
class PassphraseConfirmField extends PureComponent<IProps> {
static defaultProps = {
label: _td("Confirm password"),
labelRequired: _td("Confirm password"),
labelInvalid: _td("Passwords don't match"),
};
private validate = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t(this.props.labelRequired),
},
{
key: "match",
test: ({ value }) => !value || value === this.props.password,
invalid: () => _t(this.props.labelInvalid),
},
],
});
private onValidate = async (fieldState: IFieldState) => {
const result = await this.validate(fieldState);
if (this.props.onValidate) {
this.props.onValidate(result);
}
return result;
};
render() {
return <Field
id={this.props.id}
ref={this.props.fieldRef}
type="password"
label={_t(this.props.label)}
autoComplete={this.props.autoComplete}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>;
}
}
export default PassphraseConfirmField;

View file

@ -38,7 +38,7 @@ interface IProps extends Omit<IInputProps, "onValidate"> {
labelAllowedButUnsafe?: string;
onChange(ev: React.FormEvent<HTMLElement>);
onValidate(result: IValidationResult);
onValidate?(result: IValidationResult);
}
@replaceableComponent("views.auth.PassphraseField")
@ -98,7 +98,9 @@ class PassphraseField extends PureComponent<IProps> {
onValidate = async (fieldState: IFieldState) => {
const result = await this.validate(fieldState);
this.props.onValidate(result);
if (this.props.onValidate) {
this.props.onValidate(result);
}
return result;
};

View file

@ -317,6 +317,8 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return <EmailField
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="email"
type="email"
key="email_input"
placeholder="joe@example.com"
value={this.props.username}
@ -333,6 +335,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
autoComplete="username"
key="username_input"
type="text"
label={_t("Username")}
@ -359,6 +362,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return <Field
className={classNames(classes)}
name="phoneNumber"
autoComplete="tel-national"
key="phone_input"
type="text"
label={_t("Phone")}
@ -444,6 +448,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
{ loginField }
<Field
className={pwFieldClass}
autoComplete="password"
type="password"
name="password"
label={_t('Password')}

View file

@ -16,12 +16,12 @@ limitations under the License.
*/
import React from 'react';
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from 'matrix-js-sdk/src/client';
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation, { IValidationResult } from '../elements/Validation';
@ -34,6 +34,9 @@ import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDia
import { replaceableComponent } from "../../../utils/replaceableComponent";
import CountryDropdown from "./CountryDropdown";
import { logger } from "matrix-js-sdk/src/logger";
import PassphraseConfirmField from "./PassphraseConfirmField";
enum RegistrationField {
Email = "field_email",
PhoneNumber = "field_phone_number",
@ -56,6 +59,7 @@ interface IProps {
}[];
serverConfig: ValidatedServerConfig;
canSubmit?: boolean;
matrixClient: MatrixClient;
onRegisterClick(params: {
username: string;
@ -292,29 +296,10 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
});
};
private onPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState);
private onPasswordConfirmValidate = (result: IValidationResult) => {
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result;
};
private validatePasswordConfirmRules = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Confirm password"),
},
{
key: "match",
test(this: RegistrationForm, { value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
});
private onPhoneCountryChange = newVal => {
this.setState({
phoneCountry: newVal.iso2,
@ -365,7 +350,11 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
};
private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
description: (_, results) => {
// omit the description if the only failing result is the `available` one as it makes no sense for it.
if (results.every(({ key, valid }) => key === "available" || valid)) return;
return _t("Use lowercase letters, numbers, dashes and underscores only");
},
hideDescriptionIfValid: true,
rules: [
{
@ -378,6 +367,23 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
invalid: () => _t("Some characters not allowed"),
},
{
key: "available",
final: true,
test: async ({ value }) => {
if (!value) {
return true;
}
try {
await this.props.matrixClient.isUsernameAvailable(value);
return true;
} catch (err) {
return false;
}
},
invalid: () => _t("Someone already has that username. Try another or if it is you, sign in below."),
},
],
});
@ -425,8 +431,8 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
return null;
}
const emailLabel = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
_td("Email") :
_td("Email (optional)");
return <EmailField
fieldRef={field => this[RegistrationField.Email] = field}
label={emailLabel}
@ -453,13 +459,12 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
}
renderPasswordConfirm() {
return <Field
return <PassphraseConfirmField
id="mx_RegistrationForm_passwordConfirm"
ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password"
fieldRef={field => this[RegistrationField.PasswordConfirm] = field}
autoComplete="new-password"
label={_t("Confirm password")}
value={this.state.passwordConfirm}
password={this.state.password}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}

View file

@ -150,6 +150,7 @@ const BaseAvatar = (props: IProps) => {
return (
<AccessibleButton
aria-label={_t("Avatar")}
aria-live="off"
{...otherProps}
element="span"
className={classNames("mx_BaseAvatar", className)}

View file

@ -1,160 +1 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from 'react';
import classNames from 'classnames';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore";
import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
member: RoomMember;
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
}
interface IState {
hasStatus: boolean;
menuDisplayed: boolean;
}
@replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
width: 40,
height: 40,
resizeMethod: 'crop',
};
private button = createRef<HTMLDivElement>();
constructor(props: IProps) {
super(props);
this.state = {
hasStatus: this.hasStatus,
menuDisplayed: false,
};
}
public componentDidMount(): void {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
}
if (!SettingsStore.getValue("feature_custom_status")) {
return;
}
const { user } = this.props.member;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
}
public componentWillUnmount(): void {
const { user } = this.props.member;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this.onStatusMessageCommitted,
);
}
private get hasStatus(): boolean {
const { user } = this.props.member;
if (!user) {
return false;
}
return !!user.unstable_statusMessage;
}
private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change.
this.setState({
hasStatus: this.hasStatus,
});
};
private openMenu = (): void => {
this.setState({ menuDisplayed: true });
};
private closeMenu = (): void => {
this.setState({ menuDisplayed: false });
};
public render(): JSX.Element {
const avatar = <MemberAvatar
member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod}
/>;
if (!SettingsStore.getValue("feature_custom_status")) {
return avatar;
}
const classes = classNames({
"mx_MemberStatusMessageAvatar": true,
"mx_MemberStatusMessageAvatar_hasStatus": this.state.hasStatus,
});
let contextMenu;
if (this.state.menuDisplayed) {
const elementRect = this.button.current.getBoundingClientRect();
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronMargin = 1; // Add some spacing away from target
contextMenu = (
<ContextMenu
chevronOffset={(elementRect.width - chevronWidth) / 2}
chevronFace={ChevronFace.Bottom}
left={elementRect.left + window.pageXOffset}
top={elementRect.top + window.pageYOffset - chevronMargin}
menuWidth={226}
onFinished={this.closeMenu}
>
<StatusMessageContextMenu user={this.props.member.user} />
</ContextMenu>
);
}
return <React.Fragment>
<ContextMenuButton
className={classes}
inputRef={this.button}
onClick={this.openMenu}
isExpanded={this.state.menuDisplayed}
label={_t("User Status")}
>
{ avatar }
</ContextMenuButton>
{ contextMenu }
</React.Fragment>;
}
}

View file

@ -16,10 +16,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import ContextMenu, { IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import CallHandler from '../../../CallHandler';
import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog';
import Modal from '../../../Modal';
@ -46,7 +45,7 @@ export default class CallContextMenu extends React.Component<IProps> {
};
onUnholdClick = () => {
CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId);
CallHandler.instance.setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
};

View file

@ -16,10 +16,9 @@ limitations under the License.
import * as React from "react";
import { createRef } from "react";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import ContextMenu, { IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
import DialPad from '../voip/DialPad';
import { replaceableComponent } from "../../../utils/replaceableComponent";

View file

@ -17,13 +17,13 @@ limitations under the License.
import React from "react";
import classNames from "classnames";
import {
import ContextMenu, {
ChevronFace,
ContextMenu,
IProps as IContextMenuProps,
MenuItem,
MenuItemCheckbox, MenuItemRadio,
} from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
interface IProps extends IContextMenuProps {
className?: string;
@ -42,6 +42,7 @@ interface IOptionProps extends React.ComponentProps<typeof MenuItem> {
interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
iconClassName: string;
words?: boolean;
}
interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> {
@ -74,8 +75,21 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
iconClassName,
active,
className,
words,
...props
}) => {
let marker: JSX.Element;
if (words) {
marker = <span className="mx_IconizedContextMenu_activeText">
{ active ? _t("On") : _t("Off") }
</span>;
} else {
marker = <span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />;
}
return <MenuItemCheckbox
{...props}
className={classNames(className, {
@ -86,10 +100,7 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span>
<span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
{ marker }
</MenuItemCheckbox>;
};

View file

@ -39,6 +39,12 @@ import ShareDialog from '../dialogs/ShareDialog';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { IPosition, ChevronFace } from '../../structures/ContextMenu';
import RoomContext, { TimelineRenderingType } from '../../../contexts/RoomContext';
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
import EndPollDialog from '../dialogs/EndPollDialog';
import { Relations } from 'matrix-js-sdk/src/models/relations';
import { isPollEnded } from '../messages/MPollBody';
export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -66,6 +72,11 @@ interface IProps extends IPosition {
onFinished(): void;
/* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */
onCloseDialog?(): void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
interface IState {
@ -76,6 +87,7 @@ interface IState {
@replaceableComponent("views.context_menus.MessageContextMenu")
export default class MessageContextMenu extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
state = {
canRedact: false,
@ -120,6 +132,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}
private canEndPoll(mxEvent: MatrixEvent): boolean {
return (
mxEvent.getType() === POLL_START_EVENT_TYPE.name &&
this.state.canRedact &&
!isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent)
);
}
private onResendReactionsClick = (): void => {
for (const reaction of this.getUnsentReactions()) {
Resend.resend(reaction);
@ -190,9 +210,10 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
};
private onQuoteClick = (): void => {
dis.dispatch({
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
event: this.props.mxEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
this.closeMenu();
};
@ -211,6 +232,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.closeMenu();
};
private onEndPollClick = (): void => {
const matrixClient = MatrixClientPeg.get();
Modal.createTrackedDialog('End Poll', '', EndPollDialog, {
matrixClient,
event: this.props.mxEvent,
getRelationsForEvent: this.props.getRelationsForEvent,
}, 'mx_Dialog_endPoll');
this.closeMenu();
};
private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
@ -231,7 +262,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
private viewInRoom = () => {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
@ -246,6 +277,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
const eventStatus = mxEvent.status;
const unsentReactionsCount = this.getUnsentReactions().length;
let endPollButton: JSX.Element;
let resendReactionsButton: JSX.Element;
let redactButton: JSX.Element;
let forwardButton: JSX.Element;
@ -341,6 +373,16 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
/>
);
if (this.canEndPoll(mxEvent)) {
endPollButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconEndPoll"
label={_t("End Poll")}
onClick={this.onEndPollClick}
/>
);
}
if (this.props.eventTileOps) { // this event is rendered using TextualBody
quoteButton = (
<IconizedContextMenuOption
@ -401,13 +443,17 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
const isThreadRootEvent = isThread && this.props.mxEvent?.getThread()?.rootEvent === this.props.mxEvent;
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
);
const commonItemsList = (
<IconizedContextMenuOptionList>
{ isThreadRootEvent && <IconizedContextMenuOption
{ (isThreadRootEvent && isMainSplitTimelineShown) && <IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconViewInRoom"
label={_t("View in room")}
onClick={this.viewInRoom}
/> }
{ endPollButton }
{ quoteButton }
{ forwardButton }
{ pinButton }

View file

@ -0,0 +1,313 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "./IconizedContextMenu";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import dis from "../../../dispatcher/dispatcher";
import RoomListActions from "../../../actions/RoomListActions";
import { Key } from "../../../Keyboard";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { RoomNotifState } from "../../../RoomNotifs";
import Modal from "../../../Modal";
import ExportDialog from "../dialogs/ExportDialog";
import { onRoomFilesClick, onRoomMembersClick } from "../right_panel/RoomSummaryCard";
import RoomViewStore from "../../../stores/RoomViewStore";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { ROOM_NOTIFICATIONS_TAB } from "../dialogs/RoomSettingsDialog";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
interface IProps extends IContextMenuProps {
room: Room;
}
const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const roomTags = useEventEmitterState(
RoomListStore.instance,
LISTS_UPDATE_EVENT,
() => RoomListStore.instance.getTagsForRoom(room),
);
let leaveOption: JSX.Element;
if (roomTags.includes(DefaultTagID.Archived)) {
const onForgetRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "forget_room",
room_id: room.roomId,
});
onFinished();
};
leaveOption = <IconizedContextMenuOption
iconClassName="mx_RoomTile_iconSignOut"
label={_t("Forget")}
className="mx_IconizedContextMenu_option_red"
onClick={onForgetRoomClick}
/>;
} else {
const onLeaveRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "leave_room",
room_id: room.roomId,
});
onFinished();
};
leaveOption = <IconizedContextMenuOption
onClick={onLeaveRoomClick}
label={_t("Leave")}
className="mx_IconizedContextMenu_option_red"
iconClassName="mx_RoomTile_iconSignOut"
/>;
}
let inviteOption: JSX.Element;
if (room.canInvite(cli.getUserId())) {
const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "view_invite",
roomId: room.roomId,
});
onFinished();
};
inviteOption = <IconizedContextMenuOption
onClick={onInviteClick}
label={_t("Invite")}
iconClassName="mx_RoomTile_iconInvite"
/>;
}
let favouriteOption: JSX.Element;
let lowPriorityOption: JSX.Element;
let notificationOption: JSX.Element;
if (room.getMyMembership() === "join") {
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
favouriteOption = <IconizedContextMenuCheckbox
onClick={(e) => onTagRoom(e, DefaultTagID.Favourite)}
active={isFavorite}
label={isFavorite ? _t("Favourited") : _t("Favourite")}
iconClassName="mx_RoomTile_iconStar"
/>;
const isLowPriority = roomTags.includes(DefaultTagID.LowPriority);
lowPriorityOption = <IconizedContextMenuCheckbox
onClick={(e) => onTagRoom(e, DefaultTagID.LowPriority)}
active={isLowPriority}
label={_t("Low priority")}
iconClassName="mx_RoomTile_iconArrowDown"
/>;
const echoChamber = EchoChamber.forRoom(room);
let notificationLabel: string;
let iconClassName: string;
switch (echoChamber.notificationVolume) {
case RoomNotifState.AllMessages:
notificationLabel = _t("Default");
iconClassName = "mx_RoomTile_iconNotificationsDefault";
break;
case RoomNotifState.AllMessagesLoud:
notificationLabel = _t("All messages");
iconClassName = "mx_RoomTile_iconNotificationsAllMessages";
break;
case RoomNotifState.MentionsOnly:
notificationLabel = _t("Mentions only");
iconClassName = "mx_RoomTile_iconNotificationsMentionsKeywords";
break;
case RoomNotifState.Mute:
notificationLabel = _t("Mute");
iconClassName = "mx_RoomTile_iconNotificationsNone";
break;
}
notificationOption = <IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "open_room_settings",
room_id: room.roomId,
initial_tab_id: ROOM_NOTIFICATIONS_TAB,
});
onFinished();
}}
label={_t("Notifications")}
iconClassName={iconClassName}
>
<span className="mx_IconizedContextMenu_sublabel">
{ notificationLabel }
</span>
</IconizedContextMenuOption>;
}
const onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
ev.preventDefault();
ev.stopPropagation();
if (tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority) {
const inverseTag = tagId === DefaultTagID.Favourite ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId);
const removeTag = isApplied ? tagId : inverseTag;
const addTag = isApplied ? null : tagId;
dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0));
} else {
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
}
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onFinished();
}
};
const ensureViewingRoom = () => {
if (RoomViewStore.getRoomId() === room.roomId) return;
dis.dispatch({
action: "view_room",
room_id: room.roomId,
}, true);
};
return <IconizedContextMenu {...props} onFinished={onFinished} className="mx_RoomTile_contextMenu" compact>
<IconizedContextMenuOptionList>
{ inviteOption }
{ notificationOption }
{ favouriteOption }
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
ensureViewingRoom();
onRoomMembersClick(false);
onFinished();
}}
label={_t("People")}
iconClassName="mx_RoomTile_iconPeople"
>
<span className="mx_IconizedContextMenu_sublabel">
{ room.getJoinedMemberCount() }
</span>
</IconizedContextMenuOption>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
ensureViewingRoom();
onRoomFilesClick(false);
onFinished();
}}
label={_t("Files")}
iconClassName="mx_RoomTile_iconFiles"
/>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
ensureViewingRoom();
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.RoomSummary,
allowClose: false,
});
onFinished();
}}
label={_t("Widgets")}
iconClassName="mx_RoomTile_iconWidgets"
/>
{ lowPriorityOption }
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "copy_room",
room_id: room.roomId,
});
onFinished();
}}
label={_t("Copy link")}
iconClassName="mx_RoomTile_iconCopyLink"
/>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: "open_room_settings",
room_id: room.roomId,
});
onFinished();
}}
label={_t("Settings")}
iconClassName="mx_RoomTile_iconSettings"
/>
<IconizedContextMenuOption
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Export room dialog', '', ExportDialog, { room });
onFinished();
}}
label={_t("Export chat")}
iconClassName="mx_RoomTile_iconExport"
/>
{ leaveOption }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
export default RoomContextMenu;

View file

@ -26,7 +26,6 @@ import { _t } from "../../../languageHandler";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
@ -35,18 +34,16 @@ import {
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { BetaPill } from "../beta/BetaCard";
import SettingsStore from "../../../settings/SettingsStore";
import { Action } from "../../../dispatcher/actions";
interface IProps extends IContextMenuProps {
space: Room;
hideHeader?: boolean;
}
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
@ -64,14 +61,14 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
label={_t("Invite")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
let leaveOption;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
@ -97,36 +94,37 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
leaveOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
className="mx_IconizedContextMenu_option_red"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
);
}
let devtoolsSection;
let devtoolsOption;
if (SettingsStore.getValue("developerMode")) {
const onViewTimelineClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: space.roomId,
forceTimeline: true,
});
onFinished();
};
devtoolsSection = <IconizedContextMenuOptionList first>
devtoolsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("See room timeline (devtools)")}
onClick={onViewTimelineClick}
/>
</IconizedContextMenuOptionList>;
);
}
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
@ -141,14 +139,6 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
onFinished();
};
const onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(space);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -157,46 +147,25 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
onFinished();
};
newRoomSection = <IconizedContextMenuOptionList first>
newRoomSection = <>
<div className="mx_SpacePanel_contextMenu_separatorLabel">
{ _t("Add") }
</div>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
label={_t("Room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
label={_t("Space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
</>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space },
});
onFinished();
};
const onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -214,26 +183,21 @@ const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ !hideHeader && <div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
</div> }
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
{ settingsOption }
{ leaveOption }
{ devtoolsOption }
{ newRoomSection }
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
{ devtoolsSection }
</IconizedContextMenu>;
};

View file

@ -1,156 +1 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent } from 'react';
import { User } from "matrix-js-sdk/src/models/user";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from "../elements/Spinner";
interface IProps {
// js-sdk User object. Not required because it might not exist.
user?: User;
}
interface IState {
message: string;
waiting: boolean;
}
@replaceableComponent("views.context_menus.StatusMessageContextMenu")
export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
message: this.comittedStatusMessage,
waiting: false,
};
}
public componentDidMount(): void {
const { user } = this.props;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
}
public componentWillUnmount(): void {
const { user } = this.props;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this.onStatusMessageCommitted,
);
}
get comittedStatusMessage(): string {
return this.props.user ? this.props.user.unstable_statusMessage : "";
}
private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change.
this.setState({
message: this.comittedStatusMessage,
waiting: false,
});
};
private onClearClick = (): void=> {
MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({
waiting: true,
});
};
private onSubmit = (e: ButtonEvent): void => {
e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({
waiting: true,
});
};
private onStatusChange = (e: ChangeEvent): void => {
// The input field's value was changed.
this.setState({
message: (e.target as HTMLInputElement).value,
});
};
public render(): JSX.Element {
let actionButton;
if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this.onClearClick}
>
<span>{ _t("Clear status") }</span>
</AccessibleButton>;
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this.onSubmit}
>
<span>{ _t("Update status") }</span>
</AccessibleButton>;
}
} else {
actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message}
onClick={this.onSubmit}
>
<span>{ _t("Set status") }</span>
</AccessibleButton>;
}
let spinner = null;
if (this.state.waiting) {
spinner = <Spinner w={24} h={24} />;
}
const form = <form
className="mx_StatusMessageContextMenu_form"
autoComplete="off"
onSubmit={this.onSubmit}
>
<input
type="text"
className="mx_StatusMessageContextMenu_message"
key="message"
placeholder={_t("Set a new status...")}
autoFocus={true}
maxLength={60}
value={this.state.message}
onChange={this.onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">
{ actionButton }
{ spinner }
</div>
</form>;
return <div className="mx_StatusMessageContextMenu">
{ form }
</div>;
}
}

View file

@ -0,0 +1,119 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useState } from "react";
import { MatrixEvent } from "matrix-js-sdk/src";
import { ButtonEvent } from "../elements/AccessibleButton";
import dis from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../../utils/strings";
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
mxEvent: MatrixEvent;
permalinkCreator: RoomPermalinkCreator;
onMenuToggle?: (open: boolean) => void;
}
const contextMenuBelow = (elementRect: DOMRect) => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset + elementRect.width;
const top = elementRect.bottom + window.pageYOffset;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
const ThreadListContextMenu: React.FC<IProps> = ({ mxEvent, permalinkCreator, onMenuToggle }) => {
const [optionsPosition, setOptionsPosition] = useState(null);
const closeThreadOptions = useCallback(() => {
setOptionsPosition(null);
}, []);
const viewInRoom = useCallback((evt: ButtonEvent): void => {
evt.preventDefault();
evt.stopPropagation();
dis.dispatch({
action: Action.ViewRoom,
event_id: mxEvent.getId(),
highlighted: true,
room_id: mxEvent.getRoomId(),
});
closeThreadOptions();
}, [mxEvent, closeThreadOptions]);
const copyLinkToThread = useCallback(async (evt: ButtonEvent) => {
evt.preventDefault();
evt.stopPropagation();
const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId());
await copyPlaintext(matrixToUrl);
closeThreadOptions();
}, [mxEvent, closeThreadOptions, permalinkCreator]);
const toggleOptionsMenu = useCallback((ev: ButtonEvent): void => {
if (!!optionsPosition) {
closeThreadOptions();
} else {
const position = ev.currentTarget.getBoundingClientRect();
setOptionsPosition(position);
}
}, [closeThreadOptions, optionsPosition]);
useEffect(() => {
if (onMenuToggle) {
onMenuToggle(!!optionsPosition);
}
}, [optionsPosition, onMenuToggle]);
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
);
return <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
onClick={toggleOptionsMenu}
title={_t("Thread options")}
isExpanded={!!optionsPosition}
/>
{ !!optionsPosition && (<IconizedContextMenu
onFinished={closeThreadOptions}
className="mx_RoomTile_contextMenu"
compact
rightAligned
{...contextMenuBelow(optionsPosition)}
>
<IconizedContextMenuOptionList>
{ isMainSplitTimelineShown &&
<IconizedContextMenuOption
onClick={(e) => viewInRoom(e)}
label={_t("View in room")}
iconClassName="mx_ThreadPanel_viewInRoom"
/> }
<IconizedContextMenuOption
onClick={(e) => copyLinkToThread(e)}
label={_t("Copy link to thread")}
iconClassName="mx_ThreadPanel_copyLinkToThread"
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>) }
</React.Fragment>;
};
export default ThreadListContextMenu;

View file

@ -25,7 +25,7 @@ import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import { getDisplayAliasForRoom } from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton";

View file

@ -0,0 +1,109 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons";
import React from "react";
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
export enum ButtonClicked {
Primary,
Cancel,
}
interface IProps {
onFinished?(buttonClicked?: ButtonClicked): void;
analyticsOwner: string;
privacyPolicyUrl?: string;
primaryButton?: string;
cancelButton?: string;
hasCancel?: boolean;
}
const AnalyticsLearnMoreDialog: React.FC<IProps> = ({
onFinished,
analyticsOwner,
privacyPolicyUrl,
primaryButton,
cancelButton,
hasCancel,
}) => {
const onPrimaryButtonClick = () => onFinished && onFinished(ButtonClicked.Primary);
const onCancelButtonClick = () => onFinished && onFinished(ButtonClicked.Cancel);
const privacyPolicyLink = privacyPolicyUrl ?
<span>
{
_t("You can read all our terms <PrivacyPolicyUrl>here</PrivacyPolicyUrl>", {}, {
"PrivacyPolicyUrl": (sub) => {
return <a href={privacyPolicyUrl}
rel="norefferer noopener"
target="_blank"
>
{ sub }
<span className="mx_AnalyticsPolicyLink" />
</a>;
},
})
}
</span> : "";
return <BaseDialog
className="mx_AnalyticsLearnMoreDialog"
contentId="mx_AnalyticsLearnMore"
title={_t("Help improve %(analyticsOwner)s", { analyticsOwner })}
onFinished={onFinished}
>
<div className="mx_Dialog_content">
<div className="mx_AnalyticsLearnMore_image_holder" />
<div className="mx_AnalyticsLearnMore_copy">
{ _t("Help us identify issues and improve Element by sharing anonymous usage data. " +
"To understand how people use multiple devices, we'll generate a random identifier, " +
"shared by your devices.",
) }
</div>
<ul className="mx_AnalyticsLearnMore_bullets">
<li>{ _t("We <Bold>don't</Bold> record or profile any account data",
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
<li>{ _t("We <Bold>don't</Bold> share information with third parties",
{}, { "Bold": (sub) => <b>{ sub }</b> }) }</li>
<li>{ _t("You can turn this off anytime in settings") }</li>
</ul>
{ privacyPolicyLink }
</div>
<DialogButtons
primaryButton={primaryButton}
cancelButton={cancelButton}
onPrimaryButtonClick={onPrimaryButtonClick}
onCancel={onCancelButtonClick}
hasCancel={hasCancel}
/>
</BaseDialog>;
};
export const showDialog = (props: Omit<IProps, "cookiePolicyUrl" | "analyticsOwner">): void => {
const privacyPolicyUrl = SdkConfig.get().piwik?.policyUrl;
const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand;
Modal.createTrackedDialog(
"Analytics Learn More",
"",
AnalyticsLearnMoreDialog,
{ privacyPolicyUrl, analyticsOwner, ...props },
"mx_AnalyticsLearnMoreDialog_wrapper",
);
};
export default AnalyticsLearnMoreDialog;

View file

@ -15,10 +15,10 @@ limitations under the License.
*/
import React, { ComponentProps, useMemo, useState } from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import ConfirmUserActionDialog from "./ConfirmUserActionDialog";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { Room } from "matrix-js-sdk/src/models/room";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
type BaseProps = ComponentProps<typeof ConfirmUserActionDialog>;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, ReactNode } from 'react';
import React, { ChangeEvent, FormEvent, ReactNode } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import classNames from "classnames";
@ -76,7 +76,8 @@ export default class ConfirmUserActionDialog extends React.Component<IProps, ISt
};
}
private onOk = (): void => {
private onOk = (ev: FormEvent): void => {
ev.preventDefault();
this.props.onFinished(true, this.state.reason);
};
@ -144,7 +145,8 @@ export default class ConfirmUserActionDialog extends React.Component<IProps, ISt
{ reasonBox }
{ this.props.children }
</div>
<DialogButtons primaryButton={this.props.action}
<DialogButtons
primaryButton={this.props.action}
onPrimaryButtonClick={this.onOk}
primaryButtonClass={confirmButtonClass}
focus={!this.props.askReason}

View file

@ -25,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import InfoTooltip from "../elements/InfoTooltip";
import dis from "../../../dispatcher/dispatcher";
import { Action } from '../../../dispatcher/actions';
import { showCommunityRoomInviteDialog } from "../../../RoomInvite";
import GroupStore from "../../../stores/GroupStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -100,7 +101,7 @@ export default class CreateCommunityPrototypeDialog extends React.PureComponent<
// Force the group store to update as it might have missed the general chat
await GroupStore.refreshGroupRooms(result.group_id);
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: result.room_id,
});
showCommunityRoomInviteDialog(result.room_id, this.state.name);

View file

@ -32,7 +32,7 @@ import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
@ -284,7 +284,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
let microcopy;
if (privateShouldBeEncrypted()) {
if (this.state.canChangeEncryption) {
microcopy = _t("You cant disable this later. Bridges & most bots wont work yet.");
microcopy = _t("You can't disable this later. Bridges & most bots won't work yet.");
} else {
microcopy = _t("Your server requires encryption to be enabled in private rooms.");
}

View file

@ -33,7 +33,7 @@ import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/P
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import Spinner from "../elements/Spinner";
import { mediaFromMxc } from "../../../customisations/Media";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import dis from "../../../dispatcher/dispatcher";

View file

@ -26,7 +26,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";

View file

@ -0,0 +1,103 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { _t } from "../../../languageHandler";
import { IDialogProps } from "./IDialogProps";
import QuestionDialog from "./QuestionDialog";
import { IPollEndContent, POLL_END_EVENT_TYPE, TEXT_NODE_TYPE } from "../../../polls/consts";
import { findTopAnswer } from "../messages/MPollBody";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
event: MatrixEvent;
onFinished: (success: boolean) => void;
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations;
}
export default class EndPollDialog extends React.Component<IProps> {
private onFinished = (endPoll: boolean) => {
const topAnswer = findTopAnswer(
this.props.event,
this.props.matrixClient,
this.props.getRelationsForEvent,
);
const message = (
(topAnswer === "")
? _t("The poll has ended. No votes were cast.")
: _t(
"The poll has ended. Top answer: %(topAnswer)s",
{ topAnswer },
)
);
if (endPoll) {
const endContent: IPollEndContent = {
[POLL_END_EVENT_TYPE.name]: {},
"m.relates_to": {
"event_id": this.props.event.getId(),
"rel_type": "m.reference",
},
[TEXT_NODE_TYPE.name]: message,
};
this.props.matrixClient.sendEvent(
this.props.event.getRoomId(), POLL_END_EVENT_TYPE.name, endContent,
).catch((e: any) => {
console.error("Failed to submit poll response event:", e);
Modal.createTrackedDialog(
'Failed to end poll',
'',
ErrorDialog,
{
title: _t("Failed to end poll"),
description: _t(
"Sorry, the poll did not end. Please try again."),
},
);
});
}
this.props.onFinished(endPoll);
};
render() {
return (
<QuestionDialog
title={_t("End Poll")}
description={
_t(
"Are you sure you want to end this poll? " +
"This will show the final results of the poll and " +
"stop people from being able to vote.",
)
}
button={_t("End Poll")}
onFinished={(endPoll: boolean) => this.onFinished(endPoll)}
/>
);
}
}

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
@ -27,6 +26,9 @@ import BugReportDialog from "./BugReportDialog";
import InfoDialog from "./InfoDialog";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import { IDialogProps } from "./IDialogProps";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import { useStateToggle } from "../../../hooks/useStateToggle";
import StyledCheckbox from "../elements/StyledCheckbox";
const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" +
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
@ -35,18 +37,33 @@ const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose"
interface IProps extends IDialogProps {}
const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
const feedbackRef = useRef<Field>();
const [rating, setRating] = useState<Rating>();
const [comment, setComment] = useState<string>("");
const [canContact, toggleCanContact] = useStateToggle(false);
useEffect(() => {
// autofocus doesn't work on textareas
feedbackRef.current?.focus();
}, []);
const onDebugLogsLinkClick = (): void => {
props.onFinished();
Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {});
};
const hasFeedback = CountlyAnalytics.instance.canEnable();
const countlyEnabled = CountlyAnalytics.instance.canEnable();
const rageshakeUrl = SdkConfig.get().bug_report_endpoint_url;
const hasFeedback = countlyEnabled || rageshakeUrl;
const onFinished = (sendFeedback: boolean): void => {
if (hasFeedback && sendFeedback) {
CountlyAnalytics.instance.reportFeedback(rating, comment);
if (rageshakeUrl) {
submitFeedback(rageshakeUrl, "feedback", comment, canContact);
} else if (countlyEnabled) {
CountlyAnalytics.instance.reportFeedback(rating, comment);
}
Modal.createTrackedDialog('Feedback sent', '', InfoDialog, {
title: _t('Feedback sent'),
description: _t('Thank you!'),
@ -57,56 +74,73 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
const brand = SdkConfig.get().brand;
let countlyFeedbackSection;
if (hasFeedback) {
countlyFeedbackSection = <React.Fragment>
<hr />
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{ _t("Rate %(brand)s", { brand }) }</h3>
let feedbackSection;
if (rageshakeUrl) {
feedbackSection = <div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{ _t("Comment") }</h3>
<p>{ _t("Tell us below how you feel about %(brand)s so far.", { brand }) }</p>
<p>{ _t("Please go into as much detail as you like, so we can track down the problem.") }</p>
<p>{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }</p>
<StyledRadioGroup
name="feedbackRating"
value={String(rating)}
onChange={(r) => setRating(parseInt(r, 10) as Rating)}
definitions={[
{ value: "1", label: "😠" },
{ value: "2", label: "😞" },
{ value: "3", label: "😑" },
{ value: "4", label: "😄" },
{ value: "5", label: "😍" },
]}
/>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
ref={feedbackRef}
/>
<Field
id="feedbackComment"
label={_t("Add comment")}
placeholder={_t("Comment")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
/>
</div>
</React.Fragment>;
}
<StyledCheckbox
checked={canContact}
onChange={toggleCanContact}
>
{ _t("You may contact me if you want to follow up or to let me test out upcoming ideas") }
</StyledCheckbox>
</div>;
} else if (countlyEnabled) {
feedbackSection = <div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{ _t("Rate %(brand)s", { brand }) }</h3>
let subheading;
if (hasFeedback) {
subheading = (
<h2>{ _t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand }) }</h2>
);
<p>{ _t("Tell us below how you feel about %(brand)s so far.", { brand }) }</p>
<p>{ _t("Please go into as much detail as you like, so we can track down the problem.") }</p>
<StyledRadioGroup
name="feedbackRating"
value={String(rating)}
onChange={(r) => setRating(parseInt(r, 10) as Rating)}
definitions={[
{ value: "1", label: "😠" },
{ value: "2", label: "😞" },
{ value: "3", label: "😑" },
{ value: "4", label: "😄" },
{ value: "5", label: "😍" },
]}
/>
<Field
id="feedbackComment"
label={_t("Add comment")}
placeholder={_t("Comment")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
ref={feedbackRef}
/>
</div>;
}
let bugReports = null;
if (SdkConfig.get().bug_report_endpoint_url) {
if (rageshakeUrl) {
bugReports = (
<p>{
<p className="mx_FeedbackDialog_section_microcopy">{
_t("PRO TIP: If you start a bug, please submit <debugLogsLink>debug logs</debugLogsLink> " +
"to help us track down the problem.", {}, {
debugLogsLink: sub => (
@ -122,8 +156,6 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
hasCancelButton={!!hasFeedback}
title={_t("Feedback")}
description={<React.Fragment>
{ subheading }
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
<h3>{ _t("Report a bug") }</h3>
<p>{
@ -139,10 +171,10 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
}</p>
{ bugReports }
</div>
{ countlyFeedbackSection }
{ feedbackSection }
</React.Fragment>}
button={hasFeedback ? _t("Send feedback") : _t("Go back")}
buttonDisabled={hasFeedback && !rating}
buttonDisabled={hasFeedback && !rating && !comment}
onFinished={onFinished}
/>);
};

View file

@ -25,7 +25,7 @@ import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { useSettingValue, useFeatureEnabled } from "../../../hooks/useSettings";
import { UIFeature } from "../../../settings/UIFeature";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import { avatarUrlForUser } from "../../../Avatar";
@ -43,7 +43,8 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { roomContextDetailsText } from "../../../Rooms";
const AVATAR_SIZE = 30;
@ -121,6 +122,8 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
/>;
}
const detailsText = roomContextDetailsText(room);
return <div className="mx_ForwardList_entry">
<AccessibleTooltipButton
className="mx_ForwardList_roomButton"
@ -131,6 +134,9 @@ const Entry: React.FC<IEntryProps> = ({ room, event, matrixClient: cli, onFinish
>
<DecoratedRoomAvatar room={room} avatarSize={32} />
<span className="mx_ForwardList_entry_name">{ room.name }</span>
{ detailsText && <span className="mx_ForwardList_entry_detail">
{ detailsText }
</span> }
</AccessibleTooltipButton>
<AccessibleTooltipButton
kind={sendState === SendState.Failed ? "danger_outline" : "primary_outline"}

View file

@ -253,8 +253,8 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
<AccessibleButton
className="mx_HostSignup_maximize_button"
onClick={this.maximizeDialog}
aria-label={_t("Maximize dialog")}
title={_t("Maximize dialog")}
aria-label={_t("Maximise dialog")}
title={_t("Maximise dialog")}
/>
</div>
}
@ -263,8 +263,8 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
<AccessibleButton
onClick={this.minimizeDialog}
className="mx_HostSignup_minimize_button"
aria-label={_t("Minimize dialog")}
title={_t("Minimize dialog")}
aria-label={_t("Minimise dialog")}
title={_t("Minimise dialog")}
/>
<AccessibleButton
onClick={this.onCloseClick}

View file

@ -39,7 +39,7 @@ const PHASE_VERIFIED = 3;
const PHASE_CANCELLED = 4;
interface IProps extends IDialogProps {
verifier: VerificationBase; // TODO types
verifier: VerificationBase;
}
interface IState {

View file

@ -44,6 +44,7 @@ export default class InfoDialog extends React.Component<IProps> {
};
render() {
// FIXME: Using a regular import will break the app
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (

View file

@ -18,9 +18,10 @@ import React from 'react';
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import * as sdk from "../../../index";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps extends IDialogProps {}
@ -32,8 +33,6 @@ export default class IntegrationsImpossibleDialog extends React.Component<IProps
public render(): JSX.Element {
const brand = SdkConfig.get().brand;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog

View file

@ -63,7 +63,6 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import * as ContextMenu from "../../structures/ContextMenu";
import { toRightOf } from "../../structures/ContextMenu";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
import { TransferCallPayload } from '../../../dispatcher/payloads/TransferCallPayload';
import Field from '../elements/Field';
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
import Dialpad from '../voip/DialPad';
@ -71,7 +70,8 @@ import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import CallHandler from "../../../CallHandler";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */
@ -675,7 +675,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}
if (existingRoom) {
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
room_id: existingRoom.roomId,
should_peek: false,
joining: false,
@ -804,19 +804,17 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return;
}
dis.dispatch({
action: Action.TransferCallToMatrixID,
call: this.props.call,
destination: targetIds[0],
consultFirst: this.state.consultFirst,
} as TransferCallPayload);
CallHandler.instance.startTransferToMatrixID(
this.props.call,
targetIds[0],
this.state.consultFirst,
);
} else {
dis.dispatch({
action: Action.TransferCallToPhoneNumber,
call: this.props.call,
destination: this.state.dialPadValue,
consultFirst: this.state.consultFirst,
} as TransferCallPayload);
CallHandler.instance.startTransferToPhoneNumber(
this.props.call,
this.state.dialPadValue,
this.state.consultFirst,
);
}
this.props.onFinished();
};
@ -1410,7 +1408,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
goButtonFn = this.startDm;
extraSection = <div className="mx_InviteDialog_section_hidden_suggestions_disclaimer">
<span>{ _t("Some suggestions may be hidden for privacy.") }</span>
<p>{ _t("If you can't see who youre looking for, send them your invite link below.") }</p>
<p>{ _t("If you can't see who you're looking for, send them your invite link below.") }</p>
</div>;
const link = makeUserPermalink(MatrixClientPeg.get().getUserId());
footer = <div className="mx_InviteDialog_footer">

View file

@ -21,7 +21,7 @@ import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
interface IProps {

View file

@ -17,16 +17,19 @@ limitations under the License.
import React, { ComponentType } from 'react';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { logger } from "matrix-js-sdk/src/logger";
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { logger } from "matrix-js-sdk/src/logger";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import Spinner from "../elements/Spinner";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
onFinished: (success: boolean) => void;
}
@ -133,8 +136,6 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
render() {
if (this.state.shouldLoadBackupStatus) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const description = <div>
<p>{ _t(
"Encrypted messages are secured with end-to-end encryption. " +
@ -145,11 +146,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
let dialogContent;
if (this.state.loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');
dialogContent = <Spinner />;
} else {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let setupButtonCaption;
if (this.state.backupInfo) {
setupButtonCaption = _t("Connect this session to Key Backup");
@ -192,7 +190,6 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
{ dialogContent }
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}

View file

@ -21,7 +21,7 @@ import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
@ -66,6 +66,17 @@ const Entry = ({ room, checked, onChange }) => {
</label>;
};
const addAllParents = (set: Set<Room>, room: Room): void => {
const cli = room.client;
const parents = Array.from(SpaceStore.instance.getKnownParents(room.roomId)).map(parentId => cli.getRoom(parentId));
parents.forEach(parent => {
if (set.has(parent)) return;
set.add(parent);
addAllParents(set, parent);
});
};
const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [], onFinished }) => {
const cli = room.client;
const [newSelected, setNewSelected] = useState(new Set<string>(selected));
@ -73,9 +84,10 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
const lcQuery = query.toLowerCase().trim();
const [spacesContainingRoom, otherEntries] = useMemo(() => {
const spaces = cli.getVisibleRooms().filter(r => r.getMyMembership() === "join" && r.isSpaceRoom());
const parents = new Set<Room>();
addAllParents(parents, room);
return [
spaces.filter(r => SpaceStore.instance.getSpaceFilteredRoomIds(r).has(room.roomId)),
Array.from(parents),
selected.map(roomId => {
const room = cli.getRoom(roomId);
if (!room) {
@ -86,9 +98,9 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
}
}).filter(Boolean),
];
}, [cli, selected, room.roomId]);
}, [cli, selected, room]);
const [filteredSpacesContainingRooms, filteredOtherEntries] = useMemo(() => [
const [filteredSpacesContainingRoom, filteredOtherEntries] = useMemo(() => [
spacesContainingRoom.filter(r => r.name.toLowerCase().includes(lcQuery)),
otherEntries.filter(r => r.name.toLowerCase().includes(lcQuery)),
], [spacesContainingRoom, otherEntries, lcQuery]);
@ -129,10 +141,14 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">
{ filteredSpacesContainingRooms.length > 0 ? (
{ filteredSpacesContainingRoom.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{ _t("Spaces you know that contain this room") }</h3>
{ filteredSpacesContainingRooms.map(space => {
<h3>
{ room.isSpaceRoom()
? _t("Spaces you know that contain this space")
: _t("Spaces you know that contain this room") }
</h3>
{ filteredSpacesContainingRoom.map(space => {
return <Entry
key={space.roomId}
room={space}
@ -164,7 +180,7 @@ const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [],
</div>
) : null }
{ filteredSpacesContainingRooms.length + filteredOtherEntries.length < 1
{ filteredSpacesContainingRoom.length + filteredOtherEntries.length < 1
? <span className="mx_ManageRestrictedJoinRuleDialog_noResults">
{ _t("No results") }
</span>

View file

@ -17,7 +17,7 @@ limitations under the License.
import * as React from "react";
import { useRef, useState } from "react";
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import Field from "../elements/Field";
import CountlyAnalytics from "../../../CountlyAnalytics";
@ -64,7 +64,7 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
<EmailField
fieldRef={fieldRef}
autoFocus={true}
label={_t("Email (optional)")}
label={_td("Email (optional)")}
value={email}
onChange={ev => {
const target = ev.target as HTMLInputElement;

View file

@ -114,7 +114,7 @@ export default class RoomSettingsDialog extends React.Component<IProps, IState>
ROOM_NOTIFICATIONS_TAB,
_td("Notifications"),
"mx_RoomSettingsDialog_notificationsIcon",
<NotificationSettingsTab roomId={this.props.roomId} />,
<NotificationSettingsTab roomId={this.props.roomId} closeSettingsFn={() => this.props.onFinished(true)} />,
));
if (SettingsStore.getValue("feature_bridge_state")) {

View file

@ -0,0 +1,116 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FormEvent } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Key } from "../../../Keyboard";
import { IDialogProps } from "./IDialogProps";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import FocusLock from "react-focus-lock";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
export interface IScrollableBaseState {
canSubmit: boolean;
title: string;
actionLabel: string;
}
/**
* Scrollable dialog base from Compound (Web Components).
*/
export default abstract class ScrollableBaseModal<TProps extends IDialogProps, TState extends IScrollableBaseState>
extends React.PureComponent<TProps, TState> {
protected constructor(props: TProps) {
super(props);
}
protected get matrixClient(): MatrixClient {
return MatrixClientPeg.get();
}
private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => {
if (e.key === Key.ESCAPE) {
e.stopPropagation();
e.preventDefault();
this.cancel();
}
};
private onCancel = () => {
this.cancel();
};
private onSubmit = (e: MouseEvent | FormEvent) => {
e.stopPropagation();
e.preventDefault();
if (!this.state.canSubmit) return; // pretend the submit button was disabled
this.submit();
};
protected abstract cancel(): void;
protected abstract submit(): void;
protected abstract renderContent(): React.ReactNode;
public render(): JSX.Element {
return (
<MatrixClientContext.Provider value={this.matrixClient}>
<FocusLock
returnFocus={true}
lockProps={{
onKeyDown: this.onKeyDown,
role: "dialog",
["aria-labelledby"]: "mx_CompoundDialog_title",
// Like BaseDialog, we'll just point this at the whole content
["aria-describedby"]: "mx_CompoundDialog_content",
}}
className="mx_CompoundDialog mx_ScrollableBaseDialog"
>
<div className="mx_CompoundDialog_header">
<h1>{ this.state.title }</h1>
<AccessibleButton
onClick={this.onCancel}
className="mx_CompoundDialog_cancelButton"
aria-label={_t("Close dialog")}
/>
</div>
<form onSubmit={this.onSubmit}>
<div className="mx_CompoundDialog_content">
{ this.renderContent() }
</div>
<div className="mx_CompoundDialog_footer">
<AccessibleButton onClick={this.onCancel} kind="primary_outline">
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton
onClick={this.onSubmit}
kind="primary"
disabled={!this.state.canSubmit}
type="submit"
element="button"
className="mx_Dialog_nonDialogButton"
>
{ this.state.actionLabel }
</AccessibleButton>
</div>
</form>
</FocusLock>
</MatrixClientContext.Provider>
);
}
}

View file

@ -166,7 +166,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
public render() {
let text;
if (this.defaultServer.hsName === "matrix.org") {
text = _t("Matrix.org is the biggest public homeserver in the world, so its a good place for many.");
text = _t("Matrix.org is the biggest public homeserver in the world, so it's a good place for many.");
}
let defaultServerName: React.ReactNode = this.defaultServer.hsName;
@ -188,7 +188,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
>
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
<p>
{ _t("We call the places where you can host your account homeservers.") } { text }
{ _t("We call the places where you can host your account 'homeservers'.") } { text }
</p>
<StyledRadioButton

View file

@ -29,13 +29,15 @@ import DialogButtons from "../elements/DialogButtons";
import { IDialogProps } from "./IDialogProps";
interface IProps extends IDialogProps {
error: string;
error: Error;
}
@replaceableComponent("views.dialogs.SessionRestoreErrorDialog")
export default class SessionRestoreErrorDialog extends React.Component<IProps> {
private sendBugReport = (): void => {
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {
error: this.props.error,
});
};
private onClearStorageClick = (): void => {

View file

@ -35,6 +35,7 @@ import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import BaseDialog from "./BaseDialog";
import { IDialogProps } from "./IDialogProps";
import SidebarUserSettingsTab from "../settings/tabs/user/SidebarUserSettingsTab";
export enum UserTab {
General = "USER_GENERAL_TAB",
@ -42,6 +43,7 @@ export enum UserTab {
Flair = "USER_FLAIR_TAB",
Notifications = "USER_NOTIFICATIONS_TAB",
Preferences = "USER_PREFERENCES_TAB",
Sidebar = "USER_SIDEBAR_TAB",
Voice = "USER_VOICE_TAB",
Security = "USER_SECURITY_TAB",
Labs = "USER_LABS_TAB",
@ -118,6 +120,15 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
if (SettingsStore.getValue("feature_spaces_metaspaces")) {
tabs.push(new Tab(
UserTab.Sidebar,
_td("Sidebar"),
"mx_UserSettingsDialog_sidebarIcon",
<SidebarUserSettingsTab />,
));
}
if (SettingsStore.getValue(UIFeature.Voip)) {
tabs.push(new Tab(
UserTab.Voice,

View file

@ -21,9 +21,8 @@ import { IProtocol } from "matrix-js-sdk/src/client";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
import {
import ContextMenu, {
ChevronFace,
ContextMenu,
ContextMenuButton,
MenuGroup,
MenuItem,

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton";
import Tooltip, { Alignment } from './Tooltip';
@ -70,13 +69,12 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title, tooltip, children, tooltipClassName, forceHide, yOffset, alignment, ...props } = this.props;
const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container"
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
const tip = this.state.hover && <Tooltip
tooltipClassName={tooltipClassName}
label={tooltip || title}
yOffset={yOffset}
alignment={alignment}
/> : null;
/>;
return (
<AccessibleButton
{...props}

View file

@ -19,11 +19,6 @@ limitations under the License.
import url from 'url';
import React, { createRef } from 'react';
import classNames from 'classnames';
import { MatrixCapabilities } from "matrix-widget-api";
import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler';
@ -32,22 +27,27 @@ import AppWarning from './AppWarning';
import Spinner from './Spinner';
import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames';
import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
import PersistedElement, { getPersistKey } from "./PersistedElement";
import { WidgetType } from "../../../widgets/WidgetType";
import { StopGapWidget } from "../../../stores/widgets/StopGapWidget";
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
import { MatrixCapabilities } from "matrix-widget-api";
import RoomWidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import CallHandler from '../../../CallHandler';
import { Room } from "matrix-js-sdk/src/models/room";
import { IApp } from "../../../stores/WidgetStore";
import { WidgetLayoutStore, Container } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
app: IApp;
// If room is not specified then it is an account level widget
// which bypasses permission prompts as it was added explicitly by that user
room: Room;
threadId?: string | null;
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth?: boolean;
@ -89,6 +89,8 @@ interface IState {
requiresClient: boolean;
}
import { logger } from "matrix-js-sdk/src/logger";
@replaceableComponent("views.elements.AppTile")
export default class AppTile extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
@ -99,6 +101,7 @@ export default class AppTile extends React.Component<IProps, IState> {
handleMinimisePointerEvents: false,
userWidget: false,
miniMode: false,
threadId: null,
};
private contextMenuButton = createRef<any>();
@ -227,7 +230,7 @@ export default class AppTile extends React.Component<IProps, IState> {
this.sgWidget.on("ready", this.onWidgetReady);
this.startWidget();
} catch (e) {
logger.log("Failed to construct widget", e);
logger.error("Failed to construct widget", e);
this.sgWidget = null;
}
}
@ -241,7 +244,13 @@ export default class AppTile extends React.Component<IProps, IState> {
private iframeRefChange = (ref: HTMLIFrameElement): void => {
this.iframe = ref;
if (ref) {
if (this.sgWidget) this.sgWidget.start(ref);
try {
if (this.sgWidget) {
this.sgWidget.start(ref);
}
} catch (e) {
logger.error("Failed to start widget", e);
}
} else {
this.resetWidget(this.props);
}
@ -284,7 +293,7 @@ export default class AppTile extends React.Component<IProps, IState> {
}
if (WidgetType.JITSI.matches(this.props.app.type)) {
dis.dispatch({ action: 'hangup_conference' });
CallHandler.instance.hangupCallApp(this.props.room.roomId);
}
// Delete the widget from the persisted store for good measure.
@ -315,7 +324,13 @@ export default class AppTile extends React.Component<IProps, IState> {
switch (payload.action) {
case 'm.sticker':
if (this.sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({ action: 'post_sticker_message', data: payload.data });
dis.dispatch({
action: 'post_sticker_message',
data: {
...payload.data,
threadId: this.props.threadId,
},
});
dis.dispatch({ action: 'stickerpicker_close' });
} else {
logger.warn('Ignoring sticker message. Invalid capability');
@ -394,6 +409,14 @@ export default class AppTile extends React.Component<IProps, IState> {
{ target: '_blank', href: this.sgWidget.popoutUrl, rel: 'noreferrer noopener' }).click();
};
private onMaxMinWidgetClick = (): void => {
const targetContainer =
WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center)
? Container.Right
: Container.Center;
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
};
private onContextMenuClick = (): void => {
this.setState({ menuDisplayed: true });
};
@ -420,7 +443,7 @@ export default class AppTile extends React.Component<IProps, IState> {
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
const appTileBodyStyles = {};
if (this.props.pointerEvents) {
appTileBodyStyles['pointer-events'] = this.props.pointerEvents;
appTileBodyStyles['pointerEvents'] = this.props.pointerEvents;
}
const loadingElement = (
@ -516,6 +539,23 @@ export default class AppTile extends React.Component<IProps, IState> {
/>
);
}
let maxMinButton;
if (SettingsStore.getValue("feature_maximised_widgets")) {
const widgetIsMaximised = WidgetLayoutStore.instance.
isInContainer(this.props.room, this.props.app, Container.Center);
maxMinButton = <AccessibleButton
className={
"mx_AppTileMenuBar_iconButton"
+ (widgetIsMaximised
? " mx_AppTileMenuBar_iconButton_minWidget"
: " mx_AppTileMenuBar_iconButton_maxWidget")
}
title={
widgetIsMaximised ? _t('Close'): _t('Maximise widget')
}
onClick={this.onMaxMinWidgetClick}
/>;
}
return <React.Fragment>
<div className={appTileClasses} id={this.props.app.id}>
@ -525,6 +565,7 @@ export default class AppTile extends React.Component<IProps, IState> {
{ this.props.showTitle && this.getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ maxMinButton }
{ (this.props.showPopout && !this.state.requiresClient) && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_popout"
title={_t('Popout widget')}

View file

@ -15,10 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import TagTile from './TagTile';
import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu";
import React from 'react';
import ContextMenu, { toRightOf, useContextMenu } from "../../structures/ContextMenu";
import * as sdk from '../../../index';
export default function DNDTagTile(props) {

View file

@ -178,26 +178,20 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev;
};
private onChevronClick = (ev: React.MouseEvent) => {
if (this.state.expanded) {
this.setState({ expanded: false });
ev.stopPropagation();
ev.preventDefault();
}
};
private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return;
if (!this.state.expanded) {
this.setState({
expanded: true,
});
this.setState({ expanded: true });
ev.preventDefault();
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
this.props.onOptionChange(this.state.highlightedOption);
this.close();
} else if (!(ev as React.KeyboardEvent).key) {
// collapse on other non-keyboard event activations
this.setState({ expanded: false });
ev.preventDefault();
}
};
@ -228,14 +222,22 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.close();
break;
case Key.ARROW_DOWN:
this.setState({
highlightedOption: this.nextOption(this.state.highlightedOption),
});
if (this.state.expanded) {
this.setState({
highlightedOption: this.nextOption(this.state.highlightedOption),
});
} else {
this.setState({ expanded: true });
}
break;
case Key.ARROW_UP:
this.setState({
highlightedOption: this.prevOption(this.state.highlightedOption),
});
if (this.state.expanded) {
this.setState({
highlightedOption: this.prevOption(this.state.highlightedOption),
});
} else {
this.setState({ expanded: true });
}
break;
default:
handled = false;
@ -383,7 +385,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
onKeyDown={this.onKeyDown}
>
{ currentValue }
<span onClick={this.onChevronClick} className="mx_Dropdown_arrow" />
<span className="mx_Dropdown_arrow" />
{ menu }
</AccessibleButton>
</div>;

View file

@ -105,13 +105,19 @@ export default class ErrorBoundary extends React.PureComponent<{}, IState> {
</React.Fragment>;
}
let clearCacheButton: JSX.Element;
// we only show this button if there is an initialised MatrixClient otherwise we can't clear the cache
if (MatrixClientPeg.get()) {
clearCacheButton = <AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{ _t("Clear cache and reload") }
</AccessibleButton>;
}
return <div className="mx_ErrorBoundary">
<div className="mx_ErrorBoundary_body">
<h1>{ _t("Something went wrong!") }</h1>
{ bugReportSection }
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{ _t("Clear cache and reload") }
</AccessibleButton>
{ clearCacheButton }
</div>
</div>;
}

View file

@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useEffect } from 'react';
import React, { ReactNode, useEffect } from "react";
import { uniqBy } from "lodash";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@ -22,7 +23,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import { useStateToggle } from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton";
import { Layout } from '../../../settings/Layout';
import { Layout } from '../../../settings/enums/Layout';
interface IProps {
// An array of member events to summarise
@ -80,7 +81,8 @@ const EventListSummary: React.FC<IProps> = ({
{ children }
</React.Fragment>;
} else {
const avatars = summaryMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
const uniqueMembers = uniqBy(summaryMembers, member => member.getMxcAvatarUrl());
const avatars = uniqueMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
body = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">

View file

@ -22,7 +22,7 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import * as Avatar from '../../../Avatar';
import EventTile from '../rooms/EventTile';
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';

View file

@ -46,6 +46,9 @@ interface IProps {
label?: string;
// The field's placeholder string. Defaults to the label.
placeholder?: string;
// When true (default false), the placeholder will be shown instead of the label when
// the component is unfocused & empty.
usePlaceholderAsHint?: boolean;
// Optional component to include inside the field before the input.
prefixComponent?: React.ReactNode;
// Optional component to include inside the field after the input.
@ -227,6 +230,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
usePlaceholderAsHint,
...inputProps } = this.props;
// Set some defaults for the <input> element
@ -257,7 +261,8 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint,
mx_Field_placeholderIsHint: usePlaceholderAsHint,
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag
? !forceValidity

View file

@ -31,6 +31,7 @@ import MessageTimestamp from "../messages/MessageTimestamp";
import SettingsStore from "../../../settings/SettingsStore";
import { formatFullDate } from "../../../DateUtils";
import dis from '../../../dispatcher/dispatcher';
import { Action } from '../../../dispatcher/actions';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { normalizeWheelEvent } from "../../../utils/Mouse";
@ -333,7 +334,7 @@ export default class ImageView extends React.Component<IProps, IState> {
// matrix.to, but also for it to enable routing within Element when clicked.
ev.preventDefault();
dis.dispatch({
action: 'view_room',
action: Action.ViewRoom,
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),

View file

@ -0,0 +1,496 @@
/*
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { CSSProperties, MouseEventHandler, ReactNode, RefCallback } from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import UIStore from "../../../stores/UIStore";
import { ChevronFace } from "../../structures/ContextMenu";
const InteractiveTooltipContainerId = "mx_InteractiveTooltip_Container";
// If the distance from tooltip to window edge is below this value, the tooltip
// will flip around to the other side of the target.
const MIN_SAFE_DISTANCE_TO_WINDOW_EDGE = 20;
function getOrCreateContainer(): HTMLElement {
let container = document.getElementById(InteractiveTooltipContainerId);
if (!container) {
container = document.createElement("div");
container.id = InteractiveTooltipContainerId;
document.body.appendChild(container);
}
return container;
}
interface IRect {
top: number;
right: number;
bottom: number;
left: number;
}
function isInRect(x: number, y: number, rect: IRect): boolean {
const { top, right, bottom, left } = rect;
return x >= left && x <= right && y >= top && y <= bottom;
}
/**
* Returns the positive slope of the diagonal of the rect.
*
* @param {DOMRect} rect
* @return {number}
*/
function getDiagonalSlope(rect: IRect): number {
const { top, right, bottom, left } = rect;
return (bottom - top) / (right - left);
}
function isInUpperLeftHalf(x: number, y: number, rect: IRect): boolean {
const { bottom, left } = rect;
// Negative slope because Y values grow downwards and for this case, the
// diagonal goes from larger to smaller Y values.
const diagonalSlope = getDiagonalSlope(rect) * -1;
return isInRect(x, y, rect) && (y <= bottom + diagonalSlope * (x - left));
}
function isInLowerRightHalf(x: number, y: number, rect: IRect): boolean {
const { bottom, left } = rect;
// Negative slope because Y values grow downwards and for this case, the
// diagonal goes from larger to smaller Y values.
const diagonalSlope = getDiagonalSlope(rect) * -1;
return isInRect(x, y, rect) && (y >= bottom + diagonalSlope * (x - left));
}
function isInUpperRightHalf(x: number, y: number, rect: IRect): boolean {
const { top, left } = rect;
// Positive slope because Y values grow downwards and for this case, the
// diagonal goes from smaller to larger Y values.
const diagonalSlope = getDiagonalSlope(rect) * 1;
return isInRect(x, y, rect) && (y <= top + diagonalSlope * (x - left));
}
function isInLowerLeftHalf(x: number, y: number, rect: IRect): boolean {
const { top, left } = rect;
// Positive slope because Y values grow downwards and for this case, the
// diagonal goes from smaller to larger Y values.
const diagonalSlope = getDiagonalSlope(rect) * 1;
return isInRect(x, y, rect) && (y >= top + diagonalSlope * (x - left));
}
export enum Direction {
Top,
Left,
Bottom,
Right,
}
// exported for tests
export function mouseWithinRegion(
x: number,
y: number,
direction: Direction,
targetRect: DOMRect,
contentRect: DOMRect,
): boolean {
// When moving the mouse from the target to the tooltip, we create a safe area
// that includes the tooltip, the target, and the trapezoid ABCD between them:
// ┌───────────┐
// │ │
// │ │
// A └───E───F───┘ B
// V
// ┌─┐
// │ │
// C└─┘D
//
// As long as the mouse remains inside the safe area, the tooltip will stay open.
const buffer = 50;
if (isInRect(x, y, targetRect)) {
return true;
}
switch (direction) {
case Direction.Left: {
const contentRectWithBuffer = {
top: contentRect.top - buffer,
right: contentRect.right,
bottom: contentRect.bottom + buffer,
left: contentRect.left - buffer,
};
const trapezoidTop = {
top: contentRect.top - buffer,
right: targetRect.right,
bottom: targetRect.top,
left: contentRect.right,
};
const trapezoidCenter = {
top: targetRect.top,
right: targetRect.left,
bottom: targetRect.bottom,
left: contentRect.right,
};
const trapezoidBottom = {
top: targetRect.bottom,
right: targetRect.right,
bottom: contentRect.bottom + buffer,
left: contentRect.right,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInLowerLeftHalf(x, y, trapezoidTop) ||
isInRect(x, y, trapezoidCenter) ||
isInUpperLeftHalf(x, y, trapezoidBottom)
) {
return true;
}
break;
}
case Direction.Right: {
const contentRectWithBuffer = {
top: contentRect.top - buffer,
right: contentRect.right + buffer,
bottom: contentRect.bottom + buffer,
left: contentRect.left,
};
const trapezoidTop = {
top: contentRect.top - buffer,
right: contentRect.left,
bottom: targetRect.top,
left: targetRect.left,
};
const trapezoidCenter = {
top: targetRect.top,
right: contentRect.left,
bottom: targetRect.bottom,
left: targetRect.right,
};
const trapezoidBottom = {
top: targetRect.bottom,
right: contentRect.left,
bottom: contentRect.bottom + buffer,
left: targetRect.left,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInLowerRightHalf(x, y, trapezoidTop) ||
isInRect(x, y, trapezoidCenter) ||
isInUpperRightHalf(x, y, trapezoidBottom)
) {
return true;
}
break;
}
case Direction.Top: {
const contentRectWithBuffer = {
top: contentRect.top - buffer,
right: contentRect.right + buffer,
bottom: contentRect.bottom,
left: contentRect.left - buffer,
};
const trapezoidLeft = {
top: contentRect.bottom,
right: targetRect.left,
bottom: targetRect.bottom,
left: contentRect.left - buffer,
};
const trapezoidCenter = {
top: contentRect.bottom,
right: targetRect.right,
bottom: targetRect.top,
left: targetRect.left,
};
const trapezoidRight = {
top: contentRect.bottom,
right: contentRect.right + buffer,
bottom: targetRect.bottom,
left: targetRect.right,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInUpperRightHalf(x, y, trapezoidLeft) ||
isInRect(x, y, trapezoidCenter) ||
isInUpperLeftHalf(x, y, trapezoidRight)
) {
return true;
}
break;
}
case Direction.Bottom: {
const contentRectWithBuffer = {
top: contentRect.top,
right: contentRect.right + buffer,
bottom: contentRect.bottom + buffer,
left: contentRect.left - buffer,
};
const trapezoidLeft = {
top: targetRect.top,
right: targetRect.left,
bottom: contentRect.top,
left: contentRect.left - buffer,
};
const trapezoidCenter = {
top: targetRect.bottom,
right: targetRect.right,
bottom: contentRect.top,
left: targetRect.left,
};
const trapezoidRight = {
top: targetRect.top,
right: contentRect.right + buffer,
bottom: contentRect.top,
left: targetRect.right,
};
if (
isInRect(x, y, contentRectWithBuffer) ||
isInLowerRightHalf(x, y, trapezoidLeft) ||
isInRect(x, y, trapezoidCenter) ||
isInLowerLeftHalf(x, y, trapezoidRight)
) {
return true;
}
break;
}
}
return false;
}
interface IProps {
children(props: {
ref: RefCallback<HTMLElement>;
onMouseOver: MouseEventHandler;
}): ReactNode;
// Content to show in the tooltip
content: ReactNode;
direction?: Direction;
// Function to call when visibility of the tooltip changes
onVisibilityChange?(visible: boolean): void;
}
interface IState {
contentRect: DOMRect;
visible: boolean;
}
/*
* This style of tooltip takes a "target" element as its child and centers the
* tooltip along one edge of the target.
*/
export default class InteractiveTooltip extends React.Component<IProps, IState> {
private target: HTMLElement;
public static defaultProps = {
side: Direction.Top,
};
constructor(props, context) {
super(props, context);
this.state = {
contentRect: null,
visible: false,
};
}
componentDidUpdate() {
// Whenever this passthrough component updates, also render the tooltip
// in a separate DOM tree. This allows the tooltip content to participate
// the normal React rendering cycle: when this component re-renders, the
// tooltip content re-renders.
// Once we upgrade to React 16, this could be done a bit more naturally
// using the portals feature instead.
this.renderTooltip();
}
componentWillUnmount() {
document.removeEventListener("mousemove", this.onMouseMove);
}
private collectContentRect = (element: HTMLElement): void => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
this.setState({
contentRect: element.getBoundingClientRect(),
});
};
private collectTarget = (element: HTMLElement) => {
this.target = element;
};
private onLeftOfTarget(): boolean {
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
if (this.props.direction === Direction.Left) {
const targetLeft = targetRect.left + window.pageXOffset;
return !contentRect || (targetLeft - contentRect.width > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
} else {
const targetRight = targetRect.right + window.pageXOffset;
const spaceOnRight = UIStore.instance.windowWidth - targetRight;
return !contentRect || (spaceOnRight - contentRect.width < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
}
}
private aboveTarget(): boolean {
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
if (this.props.direction === Direction.Top) {
const targetTop = targetRect.top + window.pageYOffset;
return !contentRect || (targetTop - contentRect.height > MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
} else {
const targetBottom = targetRect.bottom + window.pageYOffset;
const spaceBelow = UIStore.instance.windowHeight - targetBottom;
return !contentRect || (spaceBelow - contentRect.height < MIN_SAFE_DISTANCE_TO_WINDOW_EDGE);
}
}
private get isOnTheSide(): boolean {
return this.props.direction === Direction.Left || this.props.direction === Direction.Right;
}
private onMouseMove = (ev: MouseEvent) => {
const { clientX: x, clientY: y } = ev;
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
let direction: Direction;
if (this.isOnTheSide) {
direction = this.onLeftOfTarget() ? Direction.Left : Direction.Right;
} else {
direction = this.aboveTarget() ? Direction.Top : Direction.Bottom;
}
if (!mouseWithinRegion(x, y, direction, targetRect, contentRect)) {
this.hideTooltip();
}
};
private onTargetMouseOver = (): void => {
this.showTooltip();
};
private showTooltip(): void {
// Don't enter visible state if we haven't collected the target yet
if (!this.target) return;
this.setState({
visible: true,
});
this.props.onVisibilityChange?.(true);
document.addEventListener("mousemove", this.onMouseMove);
}
public hideTooltip() {
this.setState({
visible: false,
});
this.props.onVisibilityChange?.(false);
document.removeEventListener("mousemove", this.onMouseMove);
}
private renderTooltip() {
const { contentRect, visible } = this.state;
if (!visible) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
return null;
}
const targetRect = this.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const targetLeft = targetRect.left + window.pageXOffset;
const targetRight = targetRect.right + window.pageXOffset;
const targetBottom = targetRect.bottom + window.pageYOffset;
const targetTop = targetRect.top + window.pageYOffset;
// Place the tooltip above the target by default. If we find that the
// tooltip content would extend past the safe area towards the window
// edge, flip around to below the target.
const position: Partial<IRect> = {};
let chevronFace: ChevronFace = null;
if (this.isOnTheSide) {
if (this.onLeftOfTarget()) {
position.left = targetLeft;
chevronFace = ChevronFace.Right;
} else {
position.left = targetRight;
chevronFace = ChevronFace.Left;
}
position.top = targetTop;
} else {
if (this.aboveTarget()) {
position.bottom = UIStore.instance.windowHeight - targetTop;
chevronFace = ChevronFace.Bottom;
} else {
position.top = targetBottom;
chevronFace = ChevronFace.Top;
}
// Center the tooltip horizontally with the target's center.
position.left = targetLeft + targetRect.width / 2;
}
const chevron = <div className={"mx_InteractiveTooltip_chevron_" + chevronFace} />;
const menuClasses = classNames({
'mx_InteractiveTooltip': true,
'mx_InteractiveTooltip_withChevron_top': chevronFace === ChevronFace.Top,
'mx_InteractiveTooltip_withChevron_left': chevronFace === ChevronFace.Left,
'mx_InteractiveTooltip_withChevron_right': chevronFace === ChevronFace.Right,
'mx_InteractiveTooltip_withChevron_bottom': chevronFace === ChevronFace.Bottom,
});
const menuStyle: CSSProperties = {};
if (contentRect && !this.isOnTheSide) {
menuStyle.left = `-${contentRect.width / 2}px`;
}
const tooltip = <div className="mx_InteractiveTooltip_wrapper" style={{ ...position }}>
<div className={menuClasses} style={menuStyle} ref={this.collectContentRect}>
{ chevron }
{ this.props.content }
</div>
</div>;
ReactDOM.render(tooltip, getOrCreateContainer());
}
render() {
return this.props.children({
ref: this.collectTarget,
onMouseOver: this.onTargetMouseOver,
});
}
}

View file

@ -19,7 +19,6 @@ limitations under the License.
import React, { ComponentProps } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
@ -31,7 +30,8 @@ import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { Action } from '../../../dispatcher/actions';
import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
import { jsxJoin } from '../../../utils/ReactUtils';
import { Layout } from '../../../settings/Layout';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { Layout } from '../../../settings/enums/Layout';
const onPinnedMessagesClick = (): void => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({

View file

@ -0,0 +1,180 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import ScrollableBaseModal, { IScrollableBaseState } from "../dialogs/ScrollableBaseModal";
import { IDialogProps } from "../dialogs/IDialogProps";
import QuestionDialog from "../dialogs/QuestionDialog";
import React, { ChangeEvent, createRef } from "react";
import Modal from '../../../Modal';
import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room";
import { arrayFastClone, arraySeed } from "../../../utils/arrays";
import Field from "./Field";
import AccessibleButton from "./AccessibleButton";
import { makePollContent, POLL_KIND_DISCLOSED, POLL_START_EVENT_TYPE } from "../../../polls/consts";
import Spinner from "./Spinner";
interface IProps extends IDialogProps {
room: Room;
}
interface IState extends IScrollableBaseState {
question: string;
options: string[];
busy: boolean;
}
const MIN_OPTIONS = 2;
const MAX_OPTIONS = 20;
const DEFAULT_NUM_OPTIONS = 2;
const MAX_QUESTION_LENGTH = 340;
const MAX_OPTION_LENGTH = 340;
export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState> {
private addOptionRef = createRef<HTMLDivElement>();
public constructor(props: IProps) {
super(props);
this.state = {
title: _t("Create poll"),
actionLabel: _t("Create Poll"),
canSubmit: false, // need to add a question and at least one option first
question: "",
options: arraySeed("", DEFAULT_NUM_OPTIONS),
busy: false,
};
}
private checkCanSubmit() {
this.setState({
canSubmit:
!this.state.busy &&
this.state.question.trim().length > 0 &&
this.state.options.filter(op => op.trim().length > 0).length >= MIN_OPTIONS,
});
}
private onQuestionChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ question: e.target.value }, () => this.checkCanSubmit());
};
private onOptionChange = (i: number, e: ChangeEvent<HTMLInputElement>) => {
const newOptions = arrayFastClone(this.state.options);
newOptions[i] = e.target.value;
this.setState({ options: newOptions }, () => this.checkCanSubmit());
};
private onOptionRemove = (i: number) => {
const newOptions = arrayFastClone(this.state.options);
newOptions.splice(i, 1);
this.setState({ options: newOptions }, () => this.checkCanSubmit());
};
private onOptionAdd = () => {
const newOptions = arrayFastClone(this.state.options);
newOptions.push("");
this.setState({ options: newOptions }, () => {
// Scroll the button into view after the state update to ensure we don't experience
// a pop-in effect, and to avoid the button getting cut off due to a mid-scroll render.
this.addOptionRef.current?.scrollIntoView?.();
});
};
protected submit(): void {
this.setState({ busy: true, canSubmit: false });
this.matrixClient.sendEvent(
this.props.room.roomId,
POLL_START_EVENT_TYPE.name,
makePollContent(
this.state.question, this.state.options, POLL_KIND_DISCLOSED.name,
),
).then(
() => this.props.onFinished(true),
).catch(e => {
console.error("Failed to post poll:", e);
Modal.createTrackedDialog(
'Failed to post poll',
'',
QuestionDialog,
{
title: _t("Failed to post poll"),
description: _t(
"Sorry, the poll you tried to create was not posted."),
button: _t('Try again'),
cancelButton: _t('Cancel'),
onFinished: (tryAgain: boolean) => {
if (!tryAgain) {
this.cancel();
} else {
this.setState({ busy: false, canSubmit: true });
}
},
},
);
});
}
protected cancel(): void {
this.props.onFinished(false);
}
protected renderContent(): React.ReactNode {
return <div className="mx_PollCreateDialog">
<h2>{ _t("What is your poll question or topic?") }</h2>
<Field
value={this.state.question}
maxLength={MAX_QUESTION_LENGTH}
label={_t("Question or topic")}
placeholder={_t("Write something...")}
onChange={this.onQuestionChange}
usePlaceholderAsHint={true}
disabled={this.state.busy}
/>
<h2>{ _t("Create options") }</h2>
{
this.state.options.map((op, i) => <div key={`option_${i}`} className="mx_PollCreateDialog_option">
<Field
value={op}
maxLength={MAX_OPTION_LENGTH}
label={_t("Option %(number)s", { number: i + 1 })}
placeholder={_t("Write an option")}
onChange={e => this.onOptionChange(i, e)}
usePlaceholderAsHint={true}
disabled={this.state.busy}
/>
<AccessibleButton
onClick={() => this.onOptionRemove(i)}
className="mx_PollCreateDialog_removeOption"
disabled={this.state.busy}
/>
</div>)
}
<AccessibleButton
onClick={this.onOptionAdd}
disabled={this.state.busy || this.state.options.length >= MAX_OPTIONS}
kind="secondary"
className="mx_PollCreateDialog_addOption"
inputRef={this.addOptionRef}
>{ _t("Add option") }</AccessibleButton>
{
this.state.busy &&
<div className="mx_PollCreateDialog_busy"><Spinner /></div>
}
</div>;
}
}

View file

@ -17,25 +17,25 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import escapeHtml from "escape-html";
import sanitizeHtml from "sanitize-html";
import { Room } from 'matrix-js-sdk/src/models/room';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout";
import { Layout } from "../../../settings/enums/Layout";
import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
/**
* This number is based on the previous behavior - if we have message of height
@ -110,6 +110,8 @@ export default class ReplyChain extends React.Component<IProps, IState> {
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to'];
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];
} else if (!SettingsStore.getValue("feature_thread") && ev.isThreadRelation) {
return ev.threadRootId;
}
}

View file

@ -53,8 +53,10 @@ const onHelpClick = () => {
};
const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => {
const disableCustomUrls = SdkConfig.get()["disable_custom_urls"];
let editBtn;
if (!SdkConfig.get()["disable_custom_urls"] && onServerConfigChange) {
if (!disableCustomUrls && onServerConfigChange) {
const onClick = () => {
showPickerDialog(dialogTitle, serverConfig, (config?: ValidatedServerConfig) => {
if (config) {
@ -83,7 +85,7 @@ const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }
return <div className="mx_ServerPicker">
<h3>{ title || _t("Homeserver") }</h3>
<AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} />
{ !disableCustomUrls ? <AccessibleButton className="mx_ServerPicker_help" onClick={onHelpClick} /> : null }
<span className="mx_ServerPicker_server">{ serverName }</span>
{ editBtn }
{ desc }

View file

@ -18,8 +18,15 @@ import React from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import classnames from 'classnames';
export enum CheckboxStyle {
Solid = "solid",
Outline = "outline",
}
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
kind?: CheckboxStyle;
}
interface IState {
@ -41,13 +48,21 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
public render() {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { children, className, ...otherProps } = this.props;
return <span className={"mx_Checkbox " + className}>
const { children, className, kind = CheckboxStyle.Solid, ...otherProps } = this.props;
const newClassName = classnames(
"mx_Checkbox",
className,
{
"mx_Checkbox_hasKind": kind,
[`mx_Checkbox_kind_${kind}`]: kind,
},
);
return <span className={newClassName}>
<input id={this.id} {...otherProps} type="checkbox" />
<label htmlFor={this.id}>
{ /* Using the div to center the image */ }
<div className="mx_Checkbox_background">
<img src={require("../../../../res/img/feather-customised/check.svg")} />
<div className="mx_Checkbox_checkmark" />
</div>
<div>
{ this.props.children }

View file

@ -40,13 +40,13 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
public render() {
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
const _className = classnames(
'mx_RadioButton',
'mx_StyledRadioButton',
className,
{
"mx_RadioButton_disabled": disabled,
"mx_RadioButton_enabled": !disabled,
"mx_RadioButton_checked": this.props.checked,
"mx_RadioButton_outlined": outlined,
"mx_StyledRadioButton_disabled": disabled,
"mx_StyledRadioButton_enabled": !disabled,
"mx_StyledRadioButton_checked": this.props.checked,
"mx_StyledRadioButton_outlined": outlined,
});
const radioButton = <React.Fragment>
@ -58,16 +58,16 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
if (childrenInLabel) {
return <label className={_className}>
{ radioButton }
<div className="mx_RadioButton_content">{ children }</div>
<div className="mx_RadioButton_spacer" />
<div className="mx_StyledRadioButton_content">{ children }</div>
<div className="mx_StyledRadioButton_spacer" />
</label>;
} else {
return <div className={_className}>
<label className="mx_RadioButton_innerLabel">
<label className="mx_StyledRadioButton_innerLabel">
{ radioButton }
</label>
<div className="mx_RadioButton_content">{ children }</div>
<div className="mx_RadioButton_spacer" />
<div className="mx_StyledRadioButton_content">{ children }</div>
<div className="mx_StyledRadioButton_spacer" />
</div>;
}
}

View file

@ -21,6 +21,12 @@ import classNames from "classnames";
type Data = Pick<IFieldState, "value" | "allowEmpty">;
interface IResult {
key: string;
valid: boolean;
text: string;
}
interface IRule<T, D = void> {
key: string;
final?: boolean;
@ -32,7 +38,7 @@ interface IRule<T, D = void> {
interface IArgs<T, D = void> {
rules: IRule<T, D>[];
description?(this: T, derivedData: D): React.ReactChild;
description?(this: T, derivedData: D, results: IResult[]): React.ReactChild;
hideDescriptionIfValid?: boolean;
deriveData?(data: Data): Promise<D>;
}
@ -88,7 +94,7 @@ export default function withValidation<T = undefined, D = void>({
const data = { value, allowEmpty };
const derivedData = deriveData ? await deriveData(data) : undefined;
const results = [];
const results: IResult[] = [];
let valid = true;
if (rules && rules.length) {
for (const rule of rules) {
@ -164,8 +170,8 @@ export default function withValidation<T = undefined, D = void>({
if (description && (details || !hideDescriptionIfValid)) {
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const content = description.call(this, derivedData);
summary = <div className="mx_Validation_description">{ content }</div>;
const content = description.call(this, derivedData, results);
summary = content ? <div className="mx_Validation_description">{ content }</div> : undefined;
}
let feedback;

View file

@ -28,8 +28,8 @@ import QuickReactions from "./QuickReactions";
import Category, { ICategory, CategoryKey } from "./Category";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export const CATEGORY_HEADER_HEIGHT = 22;
export const EMOJI_HEIGHT = 37;
export const CATEGORY_HEADER_HEIGHT = 20;
export const EMOJI_HEIGHT = 35;
export const EMOJIS_PER_ROW = 8;
const ZERO_WIDTH_JOINER = "\u200D";

View file

@ -94,7 +94,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
this.props.mxEvent.getRoomId(),
myReactions[reaction],
);
dis.dispatch({ action: Action.FocusAComposer });
dis.fire(Action.FocusAComposer);
// Tell the emoji picker not to bump this in the more frequently used list.
return false;
} else {
@ -106,7 +106,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
},
});
dis.dispatch({ action: "message_sent" });
dis.dispatch({ action: Action.FocusAComposer });
dis.fire(Action.FocusAComposer);
return true;
}
};

View file

@ -63,7 +63,7 @@ class Search extends React.PureComponent<IProps> {
<input
autoFocus
type="text"
placeholder="Search"
placeholder={_t("Search")}
value={this.props.query}
onChange={ev => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown}

View file

@ -24,7 +24,7 @@ import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { ContextMenu, ContextMenuButton, toRightOf } from "../../structures/ContextMenu";
import ContextMenu, { ContextMenuButton, toRightOf } from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { replaceableComponent } from "../../../utils/replaceableComponent";

View file

@ -0,0 +1,266 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import maplibregl from 'maplibre-gl';
import SdkConfig from '../../../SdkConfig';
import Field from "../elements/Field";
import DialogButtons from "../elements/DialogButtons";
import Dropdown from "../elements/Dropdown";
import LocationShareType from "./LocationShareType";
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { logger } from "matrix-js-sdk/src/logger";
interface IDropdownProps {
value: LocationShareType;
label: string;
width?: number;
onChange(type: LocationShareType): void;
}
const LocationShareTypeDropdown = ({
value,
label,
width,
onChange,
}: IDropdownProps) => {
const options = [
<div key={LocationShareType.Custom}>{ _t("Share custom location") }</div>,
<div key={LocationShareType.OnceOff}>{ _t("Share my current location as a once off") }</div>,
// <div key={LocationShareType.OneMin}>{ _t("Share my current location for one minute") }</div>,
// <div key={LocationShareType.FiveMins}>{ _t("Share my current location for five minutes") }</div>,
// <div key={LocationShareType.ThirtyMins}>{ _t("Share my current location for thirty minutes") }</div>,
// <div key={LocationShareType.OneHour}>{ _t("Share my current location for one hour") }</div>,
// <div key={LocationShareType.ThreeHours}>{ _t("Share my current location for three hours") }</div>,
// <div key={LocationShareType.SixHours}>{ _t("Share my current location for six hours") }</div>,
// <div key={LocationShareType.OneDay}>{ _t("Share my current location for one day") }</div>,
// <div key={LocationShareType.Forever}>{ _t("Share my current location until I disable it") }</div>,
];
return <Dropdown
id="mx_LocationShareTypeDropdown"
className="mx_LocationShareTypeDropdown"
onOptionChange={(key: string) => {
onChange(LocationShareType[LocationShareType[parseInt(key)]]);
}}
menuWidth={width}
label={label}
value={value.toString()}
>
{ options }
</Dropdown>;
};
interface IProps {
onChoose(uri: string, ts: number, type: LocationShareType, description: string): boolean;
onFinished();
}
interface IState {
description: string;
type: LocationShareType;
position?: GeolocationPosition;
manualPosition?: GeolocationPosition;
error: Error;
}
@replaceableComponent("views.location.LocationPicker")
class LocationPicker extends React.Component<IProps, IState> {
private map: maplibregl.Map;
private marker: maplibregl.Marker;
private geolocate: maplibregl.GeolocateControl;
constructor(props) {
super(props);
this.state = {
description: _t("My location"),
type: LocationShareType.OnceOff,
position: undefined,
manualPosition: undefined,
error: undefined,
};
}
componentDidMount() {
const config = SdkConfig.get();
this.map = new maplibregl.Map({
container: 'mx_LocationPicker_map',
style: config.map_style_url,
center: [0, 0],
zoom: 1,
});
try {
// Add geolocate control to the map.
this.geolocate = new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
trackUserLocation: true,
});
this.map.addControl(this.geolocate);
this.map.on('error', (e) => {
logger.error("Failed to load map: check map_style_url in config.json has a valid URL and API key",
e.error);
this.setState({ error: e.error });
});
this.map.on('load', () => {
this.geolocate.trigger();
});
this.map.on('click', (e) => {
this.addMarker(e.lngLat);
this.storeManualPosition(e.lngLat);
this.setState({ type: LocationShareType.Custom });
});
this.geolocate.on('geolocate', this.onGeolocate);
} catch (e) {
logger.error("Failed to render map", e.error);
this.setState({ error: e.error });
}
}
private addMarker(lngLat: maplibregl.LngLat): void {
if (this.marker) return;
this.marker = new maplibregl.Marker({
draggable: true,
})
.setLngLat(lngLat)
.addTo(this.map)
.on('dragend', () => {
this.storeManualPosition(this.marker.getLngLat());
});
}
private removeMarker(): void {
if (!this.marker) return;
this.marker.remove();
this.marker = undefined;
}
private storeManualPosition(lngLat: maplibregl.LngLat): void {
const manualPosition: GeolocationPosition = {
coords: {
longitude: lngLat.lng,
latitude: lngLat.lat,
altitude: undefined,
accuracy: undefined,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
},
timestamp: Date.now(),
};
this.setState({ manualPosition });
}
componentWillUnmount() {
this.geolocate?.off('geolocate', this.onGeolocate);
}
private onGeolocate = (position: GeolocationPosition) => {
this.setState({ position });
};
private onDescriptionChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ description: ev.target.value });
};
private getGeoUri = (position) => {
return (`geo:${ position.coords.latitude },` +
position.coords.longitude +
( position.coords.altitude != null ?
`,${ position.coords.altitude }` : '' ) +
`;u=${ position.coords.accuracy }`);
};
private onOk = () => {
const position = (this.state.type == LocationShareType.Custom) ?
this.state.manualPosition : this.state.position;
this.props.onChoose(
position ? this.getGeoUri(position) : undefined,
position ? position.timestamp : undefined,
this.state.type,
this.state.description,
);
this.props.onFinished();
};
private onTypeChange= (type: LocationShareType) => {
if (type == LocationShareType.Custom) {
if (!this.state.manualPosition) {
this.setState({ manualPosition: this.state.position });
}
if (this.state.manualPosition) {
this.addMarker(new maplibregl.LngLat(
this.state.manualPosition?.coords.longitude,
this.state.manualPosition?.coords.latitude,
));
}
} else {
this.removeMarker();
}
this.setState({ type });
};
render() {
const error = this.state.error ?
<div className="mx_LocationPicker_error">
{ _t("Failed to load map") }
</div> : null;
return (
<div className="mx_LocationPicker">
<div id="mx_LocationPicker_map" />
{ error }
<div className="mx_LocationPicker_footer">
<form onSubmit={this.onOk}>
<LocationShareTypeDropdown
value={this.state.type}
label={_t("Type of location share")}
onChange={this.onTypeChange}
width={400}
/>
<Field
label={_t('Description')}
onChange={this.onDescriptionChange}
value={this.state.description}
width={400}
className="mx_LocationPicker_description"
/>
<DialogButtons primaryButton={_t('Share')}
onPrimaryButtonClick={this.onOk}
onCancel={this.props.onFinished}
primaryDisabled={!this.state.position && !this.state.manualPosition} />
</form>
</div>
</div>
);
}
}
export default LocationPicker;

View file

@ -0,0 +1,30 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
enum LocationShareType {
Custom = -1,
OnceOff = 0,
OneMine = 60,
FiveMins = 5 * 60,
ThirtyMins = 30 * 60,
OneHour = 60 * 60,
ThreeHours = 3 * 60 * 60,
SixHours = 6 * 60 * 60,
OneDay = 24 * 60 * 60,
Forever = Number.MAX_SAFE_INTEGER,
}
export default LocationShareType;

View file

@ -65,9 +65,9 @@ export default class DateSeparator extends React.Component<IProps> {
render() {
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return <h2 className="mx_DateSeparator" role="separator" tabIndex={-1}>
return <h2 className="mx_DateSeparator" role="separator" tabIndex={-1} aria-label={this.getLabel()}>
<hr role="none" />
<div>{ this.getLabel() }</div>
<div aria-hidden="true">{ this.getLabel() }</div>
<hr role="none" />
</h2>;
}

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { TileShape } from "../rooms/EventTile";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { Relations } from "matrix-js-sdk/src/models/relations";
export interface IBodyProps {
mxEvent: MatrixEvent;
@ -42,4 +42,7 @@ export interface IBodyProps {
onMessageAllowed: () => void; // TODO: Docs
permalinkCreator: RoomPermalinkCreator;
mediaEventHelper: MediaEventHelper;
// helper function to access relations for this event
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
}

View file

@ -17,10 +17,6 @@ limitations under the License.
import React, { ComponentProps, createRef } from 'react';
import { Blurhash } from "react-blurhash";
import { SyncState } from 'matrix-js-sdk/src/sync.api';
import classNames from 'classnames';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
import { logger } from "matrix-js-sdk/src/logger";
import MFileBody from './MFileBody';
import Modal from '../../../Modal';
@ -33,7 +29,14 @@ import { Media, mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages";
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
import ImageView from '../elements/ImageView';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
import { IBodyProps } from "./IBodyProps";
import classNames from 'classnames';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
import { logger } from "matrix-js-sdk/src/logger";
import { TileShape } from '../rooms/EventTile';
import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
interface IState {
decryptedUrl?: string;
@ -57,6 +60,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
private unmounted = true;
private image = createRef<HTMLImageElement>();
private timeout?: number;
private sizeWatcher: string;
constructor(props: IBodyProps) {
super(props);
@ -316,12 +320,17 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
}
}, 150);
}
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
});
}
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener('sync', this.onClientSync);
this.clearBlurhashTimeout();
SettingsStore.unwatchSetting(this.sizeWatcher);
}
protected messageContent(
@ -366,11 +375,28 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
infoHeight = this.state.loadedImageDimensions.naturalHeight;
}
// The maximum height of the thumbnail as it is rendered as an <img>
const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight);
// The maximum width of the thumbnail, as dictated by its natural
// maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight;
// The maximum size of the thumbnail as it is rendered as an <img>
// check for any height constraints
const imageSize = SettingsStore.getValue("Images.size") as ImageSize;
const isPortrait = infoWidth < infoHeight;
const suggestedAndPossibleWidth = Math.min(suggestedImageSize(imageSize, isPortrait).w, infoWidth);
const suggestedAndPossibleHeight = Math.min(suggestedImageSize(imageSize, isPortrait).h, infoHeight);
const aspectRatio = infoWidth / infoHeight;
let maxWidth;
let maxHeight;
const maxHeightConstraint = forcedHeight || this.props.maxImageHeight || suggestedAndPossibleHeight;
if (maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth || imageSize === ImageSize.Large) {
// The width is dictated by the maximum height that was defined by the props or the function param `forcedHeight`
// If the thumbnail size is set to Large, we always let the size be dictated by the height.
maxWidth = maxHeightConstraint * aspectRatio;
// there is no need to check for infoHeight here since this is done with `maxHeightConstraint * aspectRatio < suggestedAndPossibleWidth`
maxHeight = maxHeightConstraint;
} else {
// height is dictated by suggestedWidth (based on the Image.size setting)
maxWidth = suggestedAndPossibleWidth;
maxHeight = suggestedAndPossibleWidth / aspectRatio;
}
let img = null;
let placeholder = null;
@ -495,8 +521,14 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody
protected getFileBody(): string | JSX.Element {
if (this.props.forExport) return null;
// We only ever need the download bar if we're appearing outside of the timeline
if (this.props.tileShape) {
/*
* In the room timeline or the thread context we don't need the download
* link as the message action bar will fullfil that
*/
const hasMessageActionBar = !this.props.tileShape
|| this.props.tileShape === TileShape.Thread
|| this.props.tileShape === TileShape.ThreadPanel;
if (!hasMessageActionBar) {
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
}
}

View file

@ -0,0 +1,118 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import maplibregl from 'maplibre-gl';
import SdkConfig from '../../../SdkConfig';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IBodyProps } from "./IBodyProps";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../languageHandler';
interface IState {
error: Error;
}
@replaceableComponent("views.messages.MLocationBody")
export default class MLocationBody extends React.Component<IBodyProps, IState> {
private map: maplibregl.Map;
private coords: GeolocationCoordinates;
private description: string;
constructor(props: IBodyProps) {
super(props);
// unfortunately we're stuck supporting legacy `content.geo_uri`
// events until the end of days, or until we figure out mutable
// events - so folks can read their old chat history correctly.
// https://github.com/matrix-org/matrix-doc/issues/3516
const content = this.props.mxEvent.getContent();
const uri = content['org.matrix.msc3488.location'] ?
content['org.matrix.msc3488.location'].uri :
content['geo_uri'];
this.coords = this.parseGeoUri(uri);
this.state = {
error: undefined,
};
this.description =
content['org.matrix.msc3488.location']?.description ?? content['body'];
}
private parseGeoUri = (uri: string): GeolocationCoordinates => {
const m = uri.match(/^\s*geo:(.*?)\s*$/);
if (!m) return;
const parts = m[1].split(';');
const coords = parts[0].split(',');
let uncertainty: number;
for (const param of parts.slice(1)) {
const m = param.match(/u=(.*)/);
if (m) uncertainty = parseFloat(m[1]);
}
return {
latitude: parseFloat(coords[0]),
longitude: parseFloat(coords[1]),
altitude: parseFloat(coords[2]),
accuracy: uncertainty,
altitudeAccuracy: undefined,
heading: undefined,
speed: undefined,
};
};
componentDidMount() {
const config = SdkConfig.get();
const coordinates = new maplibregl.LngLat(this.coords.longitude, this.coords.latitude);
this.map = new maplibregl.Map({
container: this.getBodyId(),
style: config.map_style_url,
center: coordinates,
zoom: 13,
});
new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
closeOnMove: false,
})
.setLngLat(coordinates)
.setHTML(this.description)
.addTo(this.map);
this.map.on('error', (e)=>{
logger.error("Failed to load map: check map_style_url in config.json has a valid URL and API key", e.error);
this.setState({ error: e.error });
});
}
private getBodyId = () => {
return `mx_MLocationBody_${this.props.mxEvent.getId()}`;
};
render() {
const error = this.state.error ?
<div className="mx_EventTile_tileError mx_EventTile_body">
{ _t("Failed to load map") }
</div> : null;
return <div className="mx_MLocationBody">
<div id={this.getBodyId()} className="mx_MLocationBody_map" />
{ error }
</div>;
}
}

View file

@ -0,0 +1,635 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Modal from '../../../Modal';
import { IBodyProps } from "./IBodyProps";
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import {
IPollAnswer,
IPollContent,
IPollResponseContent,
POLL_END_EVENT_TYPE,
POLL_RESPONSE_EVENT_TYPE,
POLL_START_EVENT_TYPE,
TEXT_NODE_TYPE,
} from '../../../polls/consts';
import StyledRadioButton from '../elements/StyledRadioButton';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from 'matrix-js-sdk/src/models/relations';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import ErrorDialog from '../dialogs/ErrorDialog';
import { MatrixClient } from 'matrix-js-sdk/src/matrix';
interface IState {
selected?: string; // Which option was clicked by the local user
voteRelations: Relations; // Voting (response) events
endRelations: Relations; // Poll end events
}
export function findTopAnswer(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations,
): string {
if (!getRelationsForEvent) {
return "";
}
const pollContents: IPollContent = pollEvent.getContent();
const findAnswerText = (answerId: string) => {
for (const answer of pollContents[POLL_START_EVENT_TYPE.name].answers) {
if (answer.id == answerId) {
return answer[TEXT_NODE_TYPE.name];
}
}
return "";
};
const voteRelations: Relations = getRelationsForEvent(
pollEvent.getId(),
"m.reference",
POLL_RESPONSE_EVENT_TYPE.name,
);
const endRelations: Relations = getRelationsForEvent(
pollEvent.getId(),
"m.reference",
POLL_END_EVENT_TYPE.name,
);
const userVotes: Map<string, UserVote> = collectUserVotes(
allVotes(pollEvent, matrixClient, voteRelations, endRelations),
matrixClient.getUserId(),
null,
);
const votes: Map<string, number> = countVotes(userVotes, pollEvent.getContent());
const highestScore: number = Math.max(...votes.values());
const bestAnswerIds: string[] = [];
for (const [answerId, score] of votes) {
if (score == highestScore) {
bestAnswerIds.push(answerId);
}
}
const bestAnswerTexts = bestAnswerIds.map(findAnswerText);
return formatCommaSeparatedList(bestAnswerTexts, 3);
}
export function isPollEnded(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
getRelationsForEvent?: (
eventId: string,
relationType: string,
eventType: string
) => Relations,
): boolean {
if (!getRelationsForEvent) {
return false;
}
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
function userCanRedact(endEvent: MatrixEvent) {
return roomCurrentState.maySendRedactionForEvent(
pollEvent,
endEvent.getSender(),
);
}
const endRelations = getRelationsForEvent(
pollEvent.getId(),
"m.reference",
POLL_END_EVENT_TYPE.name,
);
if (!endRelations) {
return false;
}
const authorisedRelations = endRelations.getRelations().filter(userCanRedact);
return authorisedRelations.length > 0;
}
@replaceableComponent("views.messages.MPollBody")
export default class MPollBody extends React.Component<IBodyProps, IState> {
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
private seenEventIds: string[] = []; // Events we have already seen
private voteRelationsReceived = false;
private endRelationsReceived = false;
constructor(props: IBodyProps) {
super(props);
this.state = {
selected: null,
voteRelations: this.fetchVoteRelations(),
endRelations: this.fetchEndRelations(),
};
this.addListeners(this.state.voteRelations, this.state.endRelations);
this.props.mxEvent.on("Event.relationsCreated", this.onRelationsCreated);
}
componentWillUnmount() {
this.props.mxEvent.off("Event.relationsCreated", this.onRelationsCreated);
this.removeListeners(this.state.voteRelations, this.state.endRelations);
}
private addListeners(voteRelations?: Relations, endRelations?: Relations) {
if (voteRelations) {
voteRelations.on("Relations.add", this.onRelationsChange);
voteRelations.on("Relations.remove", this.onRelationsChange);
voteRelations.on("Relations.redaction", this.onRelationsChange);
}
if (endRelations) {
endRelations.on("Relations.add", this.onRelationsChange);
endRelations.on("Relations.remove", this.onRelationsChange);
endRelations.on("Relations.redaction", this.onRelationsChange);
}
}
private removeListeners(voteRelations?: Relations, endRelations?: Relations) {
if (voteRelations) {
voteRelations.off("Relations.add", this.onRelationsChange);
voteRelations.off("Relations.remove", this.onRelationsChange);
voteRelations.off("Relations.redaction", this.onRelationsChange);
}
if (endRelations) {
endRelations.off("Relations.add", this.onRelationsChange);
endRelations.off("Relations.remove", this.onRelationsChange);
endRelations.off("Relations.redaction", this.onRelationsChange);
}
}
private onRelationsCreated = (relationType: string, eventType: string) => {
if (relationType !== "m.reference") {
return;
}
if (POLL_RESPONSE_EVENT_TYPE.matches(eventType)) {
this.voteRelationsReceived = true;
const newVoteRelations = this.fetchVoteRelations();
this.addListeners(newVoteRelations);
this.removeListeners(this.state.voteRelations);
this.setState({ voteRelations: newVoteRelations });
} else if (POLL_END_EVENT_TYPE.matches(eventType)) {
this.endRelationsReceived = true;
const newEndRelations = this.fetchEndRelations();
this.addListeners(newEndRelations);
this.removeListeners(this.state.endRelations);
this.setState({ endRelations: newEndRelations });
}
if (this.voteRelationsReceived && this.endRelationsReceived) {
this.props.mxEvent.removeListener(
"Event.relationsCreated", this.onRelationsCreated);
}
};
private onRelationsChange = () => {
// We hold Relations in our state, and they changed under us.
// Check whether we should delete our selection, and then
// re-render.
// Note: re-rendering is a side effect of unselectIfNewEventFromMe().
this.unselectIfNewEventFromMe();
};
private selectOption(answerId: string) {
if (answerId === this.state.selected) {
return;
}
if (this.isEnded()) {
return;
}
const responseContent: IPollResponseContent = {
[POLL_RESPONSE_EVENT_TYPE.name]: {
"answers": [answerId],
},
"m.relates_to": {
"event_id": this.props.mxEvent.getId(),
"rel_type": "m.reference",
},
};
this.context.sendEvent(
this.props.mxEvent.getRoomId(),
POLL_RESPONSE_EVENT_TYPE.name,
responseContent,
).catch((e: any) => {
console.error("Failed to submit poll response event:", e);
Modal.createTrackedDialog(
'Vote not registered',
'',
ErrorDialog,
{
title: _t("Vote not registered"),
description: _t(
"Sorry, your vote was not registered. Please try again."),
},
);
});
this.setState({ selected: answerId });
}
private onOptionSelected = (e: React.FormEvent<HTMLInputElement>): void => {
this.selectOption(e.currentTarget.value);
};
private fetchVoteRelations(): Relations | null {
return this.fetchRelations(POLL_RESPONSE_EVENT_TYPE.name);
}
private fetchEndRelations(): Relations | null {
return this.fetchRelations(POLL_END_EVENT_TYPE.name);
}
private fetchRelations(eventType: string): Relations | null {
if (this.props.getRelationsForEvent) {
return this.props.getRelationsForEvent(
this.props.mxEvent.getId(),
"m.reference",
eventType,
);
} else {
return null;
}
}
/**
* @returns userId -> UserVote
*/
private collectUserVotes(): Map<string, UserVote> {
return collectUserVotes(
allVotes(
this.props.mxEvent,
this.context,
this.state.voteRelations,
this.state.endRelations,
),
this.context.getUserId(),
this.state.selected,
);
}
/**
* If we've just received a new event that we hadn't seen
* before, and that event is me voting (e.g. from a different
* device) then forget when the local user selected.
*
* Either way, calls setState to update our list of events we
* have already seen.
*/
private unselectIfNewEventFromMe() {
const newEvents: MatrixEvent[] = this.state.voteRelations.getRelations()
.filter(isPollResponse)
.filter((mxEvent: MatrixEvent) =>
!this.seenEventIds.includes(mxEvent.getId()));
let newSelected = this.state.selected;
if (newEvents.length > 0) {
for (const mxEvent of newEvents) {
if (mxEvent.getSender() === this.context.getUserId()) {
newSelected = null;
}
}
}
const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId());
this.seenEventIds = this.seenEventIds.concat(newEventIds);
this.setState( { selected: newSelected } );
}
private totalVotes(collectedVotes: Map<string, number>): number {
let sum = 0;
for (const v of collectedVotes.values()) {
sum += v;
}
return sum;
}
private isEnded(): boolean {
return isPollEnded(
this.props.mxEvent,
this.context,
this.props.getRelationsForEvent,
);
}
render() {
const pollStart: IPollContent = this.props.mxEvent.getContent();
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
if (pollInfo.answers.length < 1 || pollInfo.answers.length > 20) {
return null;
}
const ended = this.isEnded();
const pollId = this.props.mxEvent.getId();
const userVotes = this.collectUserVotes();
const votes = countVotes(userVotes, this.props.mxEvent.getContent());
const totalVotes = this.totalVotes(votes);
const winCount = Math.max(...votes.values());
const userId = this.context.getUserId();
const myVote = userVotes.get(userId)?.answers[0];
let totalText: string;
if (ended) {
totalText = _t(
"Final result based on %(count)s votes",
{ count: totalVotes },
);
} else if (myVote === undefined) {
if (totalVotes === 0) {
totalText = _t("No votes cast");
} else {
totalText = _t(
"%(count)s votes cast. Vote to see the results",
{ count: totalVotes },
);
}
} else {
totalText = _t( "Based on %(count)s votes", { count: totalVotes } );
}
return <div className="mx_MPollBody">
<h2>{ pollInfo.question[TEXT_NODE_TYPE.name] }</h2>
<div className="mx_MPollBody_allOptions">
{
pollInfo.answers.map((answer: IPollAnswer) => {
let answerVotes = 0;
let votesText = "";
// Votes are hidden until I vote or the poll ends
if (ended || myVote !== undefined) {
answerVotes = votes.get(answer.id) ?? 0;
votesText = _t("%(count)s votes", { count: answerVotes });
}
const checked = (
(!ended && myVote === answer.id) ||
(ended && answerVotes === winCount)
);
const cls = classNames({
"mx_MPollBody_option": true,
"mx_MPollBody_option_checked": checked,
});
const answerPercent = (
totalVotes === 0
? 0
: Math.round(100.0 * answerVotes / totalVotes)
);
return <div
key={answer.id}
className={cls}
onClick={() => this.selectOption(answer.id)}
>
{ (
ended
? <EndedPollOption
answer={answer}
checked={checked}
votesText={votesText} />
: <LivePollOption
pollId={pollId}
answer={answer}
checked={checked}
votesText={votesText}
onOptionSelected={this.onOptionSelected} />
) }
<div className="mx_MPollBody_popularityBackground">
<div
className="mx_MPollBody_popularityAmount"
style={{ "width": `${answerPercent}%` }}
/>
</div>
</div>;
})
}
</div>
<div className="mx_MPollBody_totalVotes">
{ totalText }
</div>
</div>;
}
}
interface IEndedPollOptionProps {
answer: IPollAnswer;
checked: boolean;
votesText: string;
}
function EndedPollOption(props: IEndedPollOptionProps) {
const cls = classNames({
"mx_MPollBody_endedOption": true,
"mx_MPollBody_endedOptionWinner": props.checked,
});
return <div className={cls} data-value={props.answer.id}>
<div className="mx_MPollBody_optionDescription">
<div className="mx_MPollBody_optionText">
{ props.answer[TEXT_NODE_TYPE.name] }
</div>
<div className="mx_MPollBody_optionVoteCount">
{ props.votesText }
</div>
</div>
</div>;
}
interface ILivePollOptionProps {
pollId: string;
answer: IPollAnswer;
checked: boolean;
votesText: string;
onOptionSelected: (e: React.FormEvent<HTMLInputElement>) => void;
}
function LivePollOption(props: ILivePollOptionProps) {
return <StyledRadioButton
name={`poll_answer_select-${props.pollId}`}
value={props.answer.id}
checked={props.checked}
onChange={props.onOptionSelected}
>
<div className="mx_MPollBody_optionDescription">
<div className="mx_MPollBody_optionText">
{ props.answer[TEXT_NODE_TYPE.name] }
</div>
<div className="mx_MPollBody_optionVoteCount">
{ props.votesText }
</div>
</div>
</StyledRadioButton>;
}
export class UserVote {
constructor(public readonly ts: number, public readonly sender: string, public readonly answers: string[]) {
}
}
function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
const pr = event.getContent() as IPollResponseContent;
const answers = pr[POLL_RESPONSE_EVENT_TYPE.name].answers;
return new UserVote(
event.getTs(),
event.getSender(),
answers,
);
}
export function allVotes(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
voteRelations: Relations,
endRelations: Relations,
): Array<UserVote> {
const endTs = pollEndTs(pollEvent, matrixClient, endRelations);
function isOnOrBeforeEnd(responseEvent: MatrixEvent): boolean {
// From MSC3381:
// "Votes sent on or before the end event's timestamp are valid votes"
return (
endTs === null ||
responseEvent.getTs() <= endTs
);
}
if (voteRelations) {
return voteRelations.getRelations()
.filter(isPollResponse)
.filter(isOnOrBeforeEnd)
.map(userResponseFromPollResponseEvent);
} else {
return [];
}
}
/**
* Returns the earliest timestamp from the supplied list of end_poll events
* or null if there are no authorised events.
*/
export function pollEndTs(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
endRelations: Relations,
): number | null {
if (!endRelations) {
return null;
}
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
function userCanRedact(endEvent: MatrixEvent) {
return roomCurrentState.maySendRedactionForEvent(
pollEvent,
endEvent.getSender(),
);
}
const tss: number[] = (
endRelations
.getRelations()
.filter(userCanRedact)
.map((evt: MatrixEvent) => evt.getTs())
);
if (tss.length === 0) {
return null;
} else {
return Math.min(...tss);
}
}
function isPollResponse(responseEvent: MatrixEvent): boolean {
return (
POLL_RESPONSE_EVENT_TYPE.matches(responseEvent.getType()) &&
POLL_RESPONSE_EVENT_TYPE.findIn(responseEvent.getContent())
);
}
/**
* Figure out the correct vote for each user.
* @returns a Map of user ID to their vote info
*/
function collectUserVotes(
userResponses: Array<UserVote>,
userId: string,
selected?: string,
): Map<string, UserVote> {
const userVotes: Map<string, UserVote> = new Map();
for (const response of userResponses) {
const otherResponse = userVotes.get(response.sender);
if (!otherResponse || otherResponse.ts < response.ts) {
userVotes.set(response.sender, response);
}
}
if (selected) {
userVotes.set(userId, new UserVote(0, userId, [selected]));
}
return userVotes;
}
function countVotes(
userVotes: Map<string, UserVote>,
pollStart: IPollContent,
): Map<string, number> {
const collected = new Map<string, number>();
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
const maxSelections = 1; // See MSC3381 - later this will be in pollInfo
const allowedAnswerIds = pollInfo.answers.map((ans: IPollAnswer) => ans.id);
function isValidAnswer(answerId: string) {
return allowedAnswerIds.includes(answerId);
}
for (const response of userVotes.values()) {
if (response.answers.every(isValidAnswer)) {
for (const [index, answerId] of response.answers.entries()) {
if (index >= maxSelections) {
break;
}
if (collected.has(answerId)) {
collected.set(answerId, collected.get(answerId) + 1);
} else {
collected.set(answerId, 1);
}
}
}
}
return collected;
}

Some files were not shown because too many files have changed in this diff Show more