Merge branch 'develop' into sort-imports
Signed-off-by: Aaron Raimist <aaron@raim.ist>
This commit is contained in:
commit
7b94e13a84
642 changed files with 30052 additions and 8035 deletions
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -131,7 +131,7 @@ const LeftPanelWidget: React.FC = () => {
|
|||
}}
|
||||
className="mx_LeftPanelWidget_maximizeButton"
|
||||
tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip"
|
||||
title={_t("Maximize")}
|
||||
title={_t("Maximise")}
|
||||
/>*/ }
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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('You’re all caught up') }</h2>
|
||||
<h2>{ _t("You're all caught up") }</h2>
|
||||
<p>{ _t('You have no visible notifications.') }</p>
|
||||
</div>);
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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`;
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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 you’ve 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>
|
||||
: <> </>
|
||||
}
|
||||
</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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 won’t 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">
|
||||
|
|
|
@ -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 won’t 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) {
|
||||
|
|
83
src/components/views/auth/PassphraseConfirmField.tsx
Normal file
83
src/components/views/auth/PassphraseConfirmField.tsx
Normal 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;
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
313
src/components/views/context_menus/RoomContextMenu.tsx
Normal file
313
src/components/views/context_menus/RoomContextMenu.tsx
Normal 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;
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
119
src/components/views/context_menus/ThreadListContextMenu.tsx
Normal file
119
src/components/views/context_menus/ThreadListContextMenu.tsx
Normal 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;
|
|
@ -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";
|
||||
|
|
109
src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx
Normal file
109
src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx
Normal 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;
|
|
@ -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>;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 can’t disable this later. Bridges & most bots won’t 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.");
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
103
src/components/views/dialogs/EndPollDialog.tsx
Normal file
103
src/components/views/dialogs/EndPollDialog.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>);
|
||||
};
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -39,7 +39,7 @@ const PHASE_VERIFIED = 3;
|
|||
const PHASE_CANCELLED = 4;
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
verifier: VerificationBase; // TODO types
|
||||
verifier: VerificationBase;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 you’re 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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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")) {
|
||||
|
|
116
src/components/views/dialogs/ScrollableBaseModal.tsx
Normal file
116
src/components/views/dialogs/ScrollableBaseModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 it’s 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
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
|
|
496
src/components/views/elements/InteractiveTooltip.tsx
Normal file
496
src/components/views/elements/InteractiveTooltip.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>({
|
||||
|
|
180
src/components/views/elements/PollCreateDialog.tsx
Normal file
180
src/components/views/elements/PollCreateDialog.tsx
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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";
|
||||
|
|
266
src/components/views/location/LocationPicker.tsx
Normal file
266
src/components/views/location/LocationPicker.tsx
Normal 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;
|
30
src/components/views/location/LocationShareType.tsx
Normal file
30
src/components/views/location/LocationShareType.tsx
Normal 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;
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
118
src/components/views/messages/MLocationBody.tsx
Normal file
118
src/components/views/messages/MLocationBody.tsx
Normal 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>;
|
||||
}
|
||||
}
|
635
src/components/views/messages/MPollBody.tsx
Normal file
635
src/components/views/messages/MPollBody.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue