Update develop
This commit is contained in:
commit
08c44333cf
93 changed files with 4391 additions and 1307 deletions
|
@ -116,6 +116,7 @@ export class ContextMenu extends React.Component {
|
|||
this.props.onFinished();
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
|
@ -133,6 +134,19 @@ export class ContextMenu extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onContextMenuPreventBubbling = (e) => {
|
||||
// stop propagation so that any context menu handlers don't leak out of this context menu
|
||||
// but do not inhibit the default browser menu
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Prevent clicks on the background from going through to the component which opened the menu.
|
||||
_onFinished = (ev: InputEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (this.props.onFinished) this.props.onFinished();
|
||||
};
|
||||
|
||||
_onMoveFocus = (element, up) => {
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
|
||||
|
@ -319,12 +333,12 @@ export class ContextMenu extends React.Component {
|
|||
let background;
|
||||
if (hasBackground) {
|
||||
background = (
|
||||
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={props.onFinished} onContextMenu={this.onContextMenu} />
|
||||
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={this._onFinished} onContextMenu={this.onContextMenu} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
|
||||
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown} onContextMenu={this.onContextMenuPreventBubbling}>
|
||||
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
|
||||
{ chevron }
|
||||
{ props.children }
|
||||
|
@ -340,10 +354,18 @@ export class ContextMenu extends React.Component {
|
|||
}
|
||||
|
||||
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||
export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => {
|
||||
export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} title={label} aria-label={label} aria-haspopup={true} aria-expanded={isExpanded}>
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu || onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-haspopup={true}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
|
|
|
@ -30,6 +30,8 @@ import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
|||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2";
|
||||
import {Key} from "../../Keyboard";
|
||||
|
||||
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
|
@ -56,6 +58,7 @@ interface IState {
|
|||
export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private tagPanelWatcherRef: string;
|
||||
private focusedElement = null;
|
||||
|
||||
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
|
||||
|
||||
|
@ -69,6 +72,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
|
||||
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
|
||||
});
|
||||
|
@ -81,6 +85,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
public componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
|
||||
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
|
||||
}
|
||||
|
||||
|
@ -151,11 +156,74 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
this.handleStickyHeaders(this.listContainerRef.current);
|
||||
};
|
||||
|
||||
private onFocus = (ev: React.FocusEvent) => {
|
||||
this.focusedElement = ev.target;
|
||||
};
|
||||
|
||||
private onBlur = () => {
|
||||
this.focusedElement = null;
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.onMoveFocus(ev.key === Key.ARROW_UP);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private onMoveFocus = (up: boolean) => {
|
||||
let element = this.focusedElement;
|
||||
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
let classes: DOMTokenList;
|
||||
|
||||
do {
|
||||
const child = up ? element.lastElementChild : element.firstElementChild;
|
||||
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||
|
||||
if (descending) {
|
||||
if (child) {
|
||||
element = child;
|
||||
} else if (sibling) {
|
||||
element = sibling;
|
||||
} else {
|
||||
descending = false;
|
||||
element = element.parentElement;
|
||||
}
|
||||
} else {
|
||||
if (sibling) {
|
||||
element = sibling;
|
||||
descending = true;
|
||||
} else {
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
classes = element.classList;
|
||||
}
|
||||
} while (element && !(
|
||||
classes.contains("mx_RoomTile2") ||
|
||||
classes.contains("mx_RoomSublist2_headerText") ||
|
||||
classes.contains("mx_RoomSearch_input")));
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
this.focusedElement = element;
|
||||
}
|
||||
};
|
||||
|
||||
private renderHeader(): React.ReactNode {
|
||||
let breadcrumbs;
|
||||
if (this.state.showBreadcrumbs) {
|
||||
breadcrumbs = (
|
||||
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
|
||||
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar">
|
||||
{this.props.isMinimized ? null : <RoomBreadcrumbs2 />}
|
||||
</div>
|
||||
);
|
||||
|
@ -171,11 +239,15 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
|
||||
private renderSearchExplore(): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_LeftPanel2_filterContainer">
|
||||
<RoomSearch onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} />
|
||||
<div className="mx_LeftPanel2_filterContainer" onFocus={this.onFocus} onBlur={this.onBlur}>
|
||||
<RoomSearch
|
||||
onQueryUpdate={this.onSearch}
|
||||
isMinimized={this.props.isMinimized}
|
||||
onVerticalArrow={this.onKeyDown}
|
||||
/>
|
||||
<AccessibleButton
|
||||
tabIndex={-1}
|
||||
className='mx_LeftPanel2_exploreButton'
|
||||
// TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180
|
||||
className="mx_LeftPanel2_exploreButton"
|
||||
onClick={this.onExplore}
|
||||
alt={_t("Explore rooms")}
|
||||
/>
|
||||
|
@ -190,14 +262,13 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
);
|
||||
|
||||
// TODO: Determine what these onWhatever handlers do: https://github.com/vector-im/riot-web/issues/14180
|
||||
const roomList = <RoomList2
|
||||
onKeyDown={() => {/*TODO*/}}
|
||||
onKeyDown={this.onKeyDown}
|
||||
resizeNotifier={null}
|
||||
collapsed={false}
|
||||
searchFilter={this.state.searchFilter}
|
||||
onFocus={() => {/*TODO*/}}
|
||||
onBlur={() => {/*TODO*/}}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
isMinimized={this.props.isMinimized}
|
||||
/>;
|
||||
|
||||
|
@ -209,6 +280,11 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
"mx_LeftPanel2_minimized": this.props.isMinimized,
|
||||
});
|
||||
|
||||
const roomListClasses = classNames(
|
||||
"mx_LeftPanel2_actualRoomListContainer",
|
||||
"mx_AutoHideScrollbar",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{tagPanel}
|
||||
|
@ -216,10 +292,15 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
<div
|
||||
className="mx_LeftPanel2_actualRoomListContainer"
|
||||
className={roomListClasses}
|
||||
onScroll={this.onScroll}
|
||||
ref={this.listContainerRef}
|
||||
>{roomList}</div>
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{roomList}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -146,6 +146,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
|
||||
protected readonly _sessionStore: sessionStore;
|
||||
protected readonly _sessionStoreToken: { remove: () => void };
|
||||
protected readonly _compactLayoutWatcherRef: string;
|
||||
protected resizer: Resizer;
|
||||
|
||||
constructor(props, context) {
|
||||
|
@ -177,6 +178,10 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this._matrixClient.on("sync", this.onSync);
|
||||
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
|
||||
|
||||
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
|
||||
"useCompactLayout", null, this.onCompactLayoutChanged,
|
||||
);
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = React.createRef();
|
||||
|
@ -194,6 +199,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
this._matrixClient.removeListener("accountData", this.onAccountData);
|
||||
this._matrixClient.removeListener("sync", this.onSync);
|
||||
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
|
||||
if (this._sessionStoreToken) {
|
||||
this._sessionStoreToken.remove();
|
||||
}
|
||||
|
@ -263,16 +269,17 @@ class LoggedInView extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
onAccountData = (event) => {
|
||||
if (event.getType() === "im.vector.web.settings") {
|
||||
this.setState({
|
||||
useCompactLayout: event.getContent().useCompactLayout,
|
||||
});
|
||||
}
|
||||
if (event.getType() === "m.ignored_user_list") {
|
||||
dis.dispatch({action: "ignore_state_changed"});
|
||||
}
|
||||
};
|
||||
|
||||
onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => {
|
||||
this.setState({
|
||||
useCompactLayout: valueAtLevel,
|
||||
});
|
||||
};
|
||||
|
||||
onSync = (syncState, oldSyncState, data) => {
|
||||
const oldErrCode = (
|
||||
this.state.syncErrorData &&
|
||||
|
|
|
@ -23,7 +23,6 @@ import * as Matrix from "matrix-js-sdk";
|
|||
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 { isCryptoAvailable } from 'matrix-js-sdk/src/crypto';
|
||||
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
|
||||
import 'focus-visible';
|
||||
// what-input helps improve keyboard accessibility
|
||||
|
@ -1932,11 +1931,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
|
||||
getFragmentAfterLogin() {
|
||||
let fragmentAfterLogin = "";
|
||||
if (this.props.initialScreenAfterLogin &&
|
||||
const initialScreenAfterLogin = this.props.initialScreenAfterLogin;
|
||||
if (initialScreenAfterLogin &&
|
||||
// XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop
|
||||
!["welcome", "login", "register"].includes(this.props.initialScreenAfterLogin.screen)
|
||||
!["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen)
|
||||
) {
|
||||
fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`;
|
||||
fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`;
|
||||
}
|
||||
return fragmentAfterLogin;
|
||||
}
|
||||
|
|
|
@ -388,8 +388,11 @@ export default class MessagePanel extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<li key={"readMarker_"+eventId} ref={this._readMarkerNode}
|
||||
className="mx_RoomView_myReadMarker_container">
|
||||
<li key={"readMarker_"+eventId}
|
||||
ref={this._readMarkerNode}
|
||||
className="mx_RoomView_myReadMarker_container"
|
||||
data-scroll-tokens={eventId}
|
||||
>
|
||||
{ hr }
|
||||
</li>
|
||||
);
|
||||
|
|
|
@ -38,6 +38,7 @@ import { Action } from "../../dispatcher/actions";
|
|||
interface IProps {
|
||||
onQueryUpdate: (newQuery: string) => void;
|
||||
isMinimized: boolean;
|
||||
onVerticalArrow(ev: React.KeyboardEvent);
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -111,6 +112,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
if (ev.key === Key.ESCAPE) {
|
||||
this.clearInput();
|
||||
defaultDispatcher.fire(Action.FocusComposer);
|
||||
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
|
||||
this.props.onVerticalArrow(ev);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1819,6 +1819,7 @@ export default createReactClass({
|
|||
);
|
||||
|
||||
const showRoomRecoveryReminder = (
|
||||
this.context.isCryptoEnabled() &&
|
||||
SettingsStore.getValue("showRoomRecoveryReminder") &&
|
||||
this.context.isRoomEncrypted(this.state.room.roomId) &&
|
||||
this.context.getKeyBackupEnabled() === false
|
||||
|
|
|
@ -21,7 +21,7 @@ import { ActionPayload } from "../../dispatcher/payloads";
|
|||
import { Action } from "../../dispatcher/actions";
|
||||
import { createRef } from "react";
|
||||
import { _t } from "../../languageHandler";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu";
|
||||
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||
|
@ -30,23 +30,39 @@ import LogoutDialog from "../views/dialogs/LogoutDialog";
|
|||
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||
import {getCustomTheme} from "../../theme";
|
||||
import {getHostingLink} from "../../utils/HostingLink";
|
||||
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import {ButtonEvent} from "../views/elements/AccessibleButton";
|
||||
import SdkConfig from "../../SdkConfig";
|
||||
import {getHomePageUrl} from "../../utils/pages";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import classNames from "classnames";
|
||||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
menuDisplayed: boolean;
|
||||
contextMenuPosition: PartialDOMRect;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
interface IMenuButtonProps {
|
||||
iconClassName: string;
|
||||
label: string;
|
||||
onClick(ev: ButtonEvent);
|
||||
}
|
||||
|
||||
const MenuButton: React.FC<IMenuButtonProps> = ({iconClassName, label, onClick}) => {
|
||||
return <MenuItem label={label} onClick={onClick}>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{label}</span>
|
||||
</MenuItem>;
|
||||
};
|
||||
|
||||
export default class UserMenu extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private themeWatcherRef: string;
|
||||
|
@ -56,7 +72,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
menuDisplayed: false,
|
||||
contextMenuPosition: null,
|
||||
isDarkTheme: this.isUserOnDarkTheme(),
|
||||
};
|
||||
|
||||
|
@ -99,23 +115,41 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
private onAction = (ev: ActionPayload) => {
|
||||
if (ev.action !== Action.ToggleUserMenu) return; // not interested
|
||||
|
||||
// For accessibility
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
if (this.state.contextMenuPosition) {
|
||||
this.setState({contextMenuPosition: null});
|
||||
} else {
|
||||
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: true});
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({contextMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onCloseMenu = (ev: InputEvent) => {
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: false});
|
||||
this.setState({
|
||||
contextMenuPosition: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
width: 20,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = () => {
|
||||
private onCloseMenu = () => {
|
||||
this.setState({contextMenuPosition: null});
|
||||
};
|
||||
|
||||
private onSwitchThemeClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// Disable system theme matching if the user hits this button
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||
|
||||
|
@ -129,7 +163,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
|
||||
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
|
||||
defaultDispatcher.dispatch(payload);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onShowArchived = (ev: ButtonEvent) => {
|
||||
|
@ -145,7 +179,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onSignOutClick = (ev: ButtonEvent) => {
|
||||
|
@ -153,7 +187,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
ev.stopPropagation();
|
||||
|
||||
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||
this.setState({menuDisplayed: false}); // also close the menu
|
||||
this.setState({contextMenuPosition: null}); // also close the menu
|
||||
};
|
||||
|
||||
private onHomeClick = (ev: ButtonEvent) => {
|
||||
|
@ -164,7 +198,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private renderContextMenu = (): React.ReactNode => {
|
||||
if (!this.state.menuDisplayed) return null;
|
||||
if (!this.state.contextMenuPosition) return null;
|
||||
|
||||
let hostingLink;
|
||||
const signupLink = getHostingLink("user-context-menu");
|
||||
|
@ -191,21 +225,20 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
let homeButton = null;
|
||||
if (this.hasHomePage) {
|
||||
homeButton = (
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onHomeClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
|
||||
<span>{_t("Home")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconHome"
|
||||
label={_t("Home")}
|
||||
onClick={this.onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const elementRect = this.buttonRef.current.getBoundingClientRect();
|
||||
return (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.width + elementRect.left}
|
||||
top={elementRect.top + elementRect.height}
|
||||
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
|
||||
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
|
||||
|
@ -218,7 +251,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
{MatrixClientPeg.get().getUserId()}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
<AccessibleTooltipButton
|
||||
className="mx_UserMenu_contextMenu_themeButton"
|
||||
onClick={this.onSwitchThemeClick}
|
||||
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
|
||||
|
@ -228,53 +261,43 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
alt={_t("Switch theme")}
|
||||
width={16}
|
||||
/>
|
||||
</div>
|
||||
</AccessibleTooltipButton>
|
||||
</div>
|
||||
{hostingLink}
|
||||
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
|
||||
<ul>
|
||||
{homeButton}
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
|
||||
<span>{_t("Notification settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
|
||||
<span>{_t("Security & privacy")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
|
||||
<span>{_t("All settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onShowArchived}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
|
||||
<span>{_t("Archived rooms")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onProvideFeedback}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
|
||||
<span>{_t("Feedback")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
{homeButton}
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconBell"
|
||||
label={_t("Notification settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconLock"
|
||||
label={_t("Security & privacy")}
|
||||
onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSettings"
|
||||
label={_t("All settings")}
|
||||
onClick={(e) => this.onSettingsOpen(e, null)}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconArchive"
|
||||
label={_t("Archived rooms")}
|
||||
onClick={this.onShowArchived}
|
||||
/>
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconMessage"
|
||||
label={_t("Feedback")}
|
||||
onClick={this.onProvideFeedback}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li className="mx_UserMenu_contextMenu_redRow">
|
||||
<AccessibleButton onClick={this.onSignOutClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
|
||||
<span>{_t("Sign out")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
|
||||
<MenuButton
|
||||
iconClassName="mx_UserMenu_iconSignOut"
|
||||
label={_t("Sign out")}
|
||||
onClick={this.onSignOutClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
@ -310,7 +333,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.buttonRef}
|
||||
label={_t("Account settings")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<div className="mx_UserMenu_row">
|
||||
<span className="mx_UserMenu_userAvatarContainer">
|
||||
|
|
65
src/components/views/avatars/DecoratedRoomAvatar.tsx
Normal file
65
src/components/views/avatars/DecoratedRoomAvatar.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
Copyright 2020 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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { TagID } from '../../../stores/room-list/models';
|
||||
import RoomAvatar from "./RoomAvatar";
|
||||
import RoomTileIcon from "../rooms/RoomTileIcon";
|
||||
import NotificationBadge from '../rooms/NotificationBadge';
|
||||
import { INotificationState } from "../../../stores/notifications/INotificationState";
|
||||
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
avatarSize: number;
|
||||
tag: TagID;
|
||||
displayBadge?: boolean;
|
||||
forceCount?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
notificationState?: INotificationState;
|
||||
}
|
||||
|
||||
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
|
||||
};
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let badge: React.ReactNode;
|
||||
if (this.props.displayBadge) {
|
||||
badge = <NotificationBadge
|
||||
notification={this.state.notificationState}
|
||||
forceCount={this.props.forceCount}
|
||||
roomId={this.props.room.roomId}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div className="mx_DecoratedRoomAvatar">
|
||||
<RoomAvatar room={this.props.room} width={this.props.avatarSize} height={this.props.avatarSize} />
|
||||
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
|
||||
{badge}
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -75,8 +75,12 @@ export default createReactClass({
|
|||
// If provided, this is used to add a aria-describedby attribute
|
||||
contentId: PropTypes.string,
|
||||
|
||||
// optional additional class for the title element
|
||||
titleClass: PropTypes.string,
|
||||
// optional additional class for the title element (basically anything that can be passed to classnames)
|
||||
titleClass: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.object,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
]),
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
|
|
@ -15,13 +15,24 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { debounce } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import PropTypes from "prop-types";
|
||||
import * as sdk from '../../../../index';
|
||||
import {MatrixClientPeg} from '../../../../MatrixClientPeg';
|
||||
import Field from '../../elements/Field';
|
||||
import AccessibleButton from '../../elements/AccessibleButton';
|
||||
|
||||
import { _t } from '../../../../languageHandler';
|
||||
import { accessSecretStorage } from '../../../../CrossSigningManager';
|
||||
|
||||
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
|
||||
// so this should be plenty and allow for people putting extra whitespace in the file because
|
||||
// maybe that's a thing people would do?
|
||||
const KEY_FILE_MAX_SIZE = 128;
|
||||
|
||||
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
|
||||
const VALIDATION_THROTTLE_MS = 200;
|
||||
|
||||
/*
|
||||
* Access Secure Secret Storage by requesting the user's passphrase.
|
||||
|
@ -36,9 +47,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._fileUpload = React.createRef();
|
||||
|
||||
this.state = {
|
||||
recoveryKey: "",
|
||||
recoveryKeyValid: false,
|
||||
recoveryKeyValid: null,
|
||||
recoveryKeyCorrect: null,
|
||||
recoveryKeyFileError: null,
|
||||
forceRecoveryKey: false,
|
||||
passPhrase: '',
|
||||
keyMatches: null,
|
||||
|
@ -55,18 +71,89 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
_onResetRecoveryClick = () => {
|
||||
// Re-enter the access flow, but resetting storage this time around.
|
||||
this.props.onFinished(false);
|
||||
accessSecretStorage(() => {}, /* forceReset = */ true);
|
||||
_validateRecoveryKeyOnChange = debounce(() => {
|
||||
this._validateRecoveryKey();
|
||||
}, VALIDATION_THROTTLE_MS);
|
||||
|
||||
async _validateRecoveryKey() {
|
||||
if (this.state.recoveryKey === '') {
|
||||
this.setState({
|
||||
recoveryKeyValid: null,
|
||||
recoveryKeyCorrect: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const decodedKey = cli.keyBackupKeyFromRecoveryKey(this.state.recoveryKey);
|
||||
const correct = await cli.checkSecretStorageKey(
|
||||
decodedKey, this.props.keyInfo,
|
||||
);
|
||||
this.setState({
|
||||
recoveryKeyValid: true,
|
||||
recoveryKeyCorrect: correct,
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({
|
||||
recoveryKeyValid: false,
|
||||
recoveryKeyCorrect: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onRecoveryKeyChange = (e) => {
|
||||
this.setState({
|
||||
recoveryKey: e.target.value,
|
||||
recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value),
|
||||
keyMatches: null,
|
||||
recoveryKeyFileError: null,
|
||||
});
|
||||
|
||||
// also clear the file upload control so that the user can upload the same file
|
||||
// the did before (otherwise the onchange wouldn't fire)
|
||||
if (this._fileUpload.current) this._fileUpload.current.value = null;
|
||||
|
||||
// We don't use Field's validation here because a) we want it in a separate place rather
|
||||
// than in a tooltip and b) we want it to display feedback based on the uploaded file
|
||||
// as well as the text box. Ideally we would refactor Field's validation logic so we could
|
||||
// re-use some of it.
|
||||
this._validateRecoveryKeyOnChange();
|
||||
}
|
||||
|
||||
_onRecoveryKeyFileChange = async e => {
|
||||
if (e.target.files.length === 0) return;
|
||||
|
||||
const f = e.target.files[0];
|
||||
|
||||
if (f.size > KEY_FILE_MAX_SIZE) {
|
||||
this.setState({
|
||||
recoveryKeyFileError: true,
|
||||
recoveryKeyCorrect: false,
|
||||
recoveryKeyValid: false,
|
||||
});
|
||||
} else {
|
||||
const contents = await f.text();
|
||||
// test it's within the base58 alphabet. We could be more strict here, eg. require the
|
||||
// right number of characters, but it's really just to make sure that what we're reading is
|
||||
// text because we'll put it in the text field.
|
||||
if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) {
|
||||
this.setState({
|
||||
recoveryKeyFileError: null,
|
||||
recoveryKey: contents.trim(),
|
||||
});
|
||||
this._validateRecoveryKey();
|
||||
} else {
|
||||
this.setState({
|
||||
recoveryKeyFileError: true,
|
||||
recoveryKeyCorrect: false,
|
||||
recoveryKeyValid: false,
|
||||
recoveryKey: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onRecoveryKeyFileUploadClick = () => {
|
||||
this._fileUpload.current.click();
|
||||
}
|
||||
|
||||
_onPassPhraseNext = async (e) => {
|
||||
|
@ -106,6 +193,20 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
getKeyValidationText() {
|
||||
if (this.state.recoveryKeyFileError) {
|
||||
return _t("Wrong file type");
|
||||
} else if (this.state.recoveryKeyCorrect) {
|
||||
return _t("Looks good!");
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
return _t("Wrong Recovery Key");
|
||||
} else if (this.state.recoveryKeyValid === null) {
|
||||
return '';
|
||||
} else {
|
||||
return _t("Invalid Recovery Key");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
|
||||
|
@ -118,10 +219,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
let content;
|
||||
let title;
|
||||
let titleClass;
|
||||
if (hasPassphrase && !this.state.forceRecoveryKey) {
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
title = _t("Enter recovery passphrase");
|
||||
title = _t("Security Phrase");
|
||||
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle'];
|
||||
|
||||
let keyStatus;
|
||||
if (this.state.keyMatches === false) {
|
||||
|
@ -137,12 +240,15 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
|
||||
content = <div>
|
||||
<p>{_t(
|
||||
"<b>Warning</b>: You should only do this on a trusted computer.", {},
|
||||
{ b: sub => <b>{sub}</b> },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Access your secure message history and your cross-signing " +
|
||||
"identity for verifying other sessions by entering your recovery passphrase.",
|
||||
"Enter your Security Phrase or <button>Use your Security Key</button> to continue.", {},
|
||||
{
|
||||
button: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onUseRecoveryKeyClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
},
|
||||
)}</p>
|
||||
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onPassPhraseNext}>
|
||||
|
@ -153,10 +259,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
value={this.state.passPhrase}
|
||||
autoFocus={true}
|
||||
autoComplete="new-password"
|
||||
placeholder={_t("Security Phrase")}
|
||||
/>
|
||||
{keyStatus}
|
||||
<DialogButtons
|
||||
primaryButton={_t('Next')}
|
||||
primaryButton={_t('Continue')}
|
||||
onPrimaryButtonClick={this._onPassPhraseNext}
|
||||
hasCancel={true}
|
||||
onCancel={this._onCancel}
|
||||
|
@ -164,87 +271,61 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
primaryDisabled={this.state.passPhrase.length === 0}
|
||||
/>
|
||||
</form>
|
||||
{_t(
|
||||
"If you've forgotten your recovery passphrase you can "+
|
||||
"<button1>use your recovery key</button1> or " +
|
||||
"<button2>set up new recovery options</button2>."
|
||||
, {}, {
|
||||
button1: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onUseRecoveryKeyClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
button2: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
})}
|
||||
</div>;
|
||||
} else {
|
||||
title = _t("Enter recovery key");
|
||||
title = _t("Security Key");
|
||||
titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle'];
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
|
||||
let keyStatus;
|
||||
if (this.state.recoveryKey.length === 0) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus" />;
|
||||
} else if (this.state.keyMatches === false) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t(
|
||||
"Unable to access secret storage. " +
|
||||
"Please verify that you entered the correct recovery key.",
|
||||
)}
|
||||
</div>;
|
||||
} else if (this.state.recoveryKeyValid) {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
|
||||
</div>;
|
||||
} else {
|
||||
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus">
|
||||
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")}
|
||||
</div>;
|
||||
}
|
||||
const feedbackClasses = classNames({
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true,
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid': this.state.recoveryKeyCorrect === true,
|
||||
'mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid': this.state.recoveryKeyCorrect === false,
|
||||
});
|
||||
const recoveryKeyFeedback = <div className={feedbackClasses}>
|
||||
{this.getKeyValidationText()}
|
||||
</div>;
|
||||
|
||||
content = <div>
|
||||
<p>{_t(
|
||||
"<b>Warning</b>: You should only do this on a trusted computer.", {},
|
||||
{ b: sub => <b>{sub}</b> },
|
||||
)}</p>
|
||||
<p>{_t(
|
||||
"Access your secure message history and your cross-signing " +
|
||||
"identity for verifying other sessions by entering your recovery key.",
|
||||
)}</p>
|
||||
<p>{_t("Use your Security Key to continue.")}</p>
|
||||
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext}>
|
||||
<input className="mx_AccessSecretStorageDialog_recoveryKeyInput"
|
||||
onChange={this._onRecoveryKeyChange}
|
||||
value={this.state.recoveryKey}
|
||||
autoFocus={true}
|
||||
/>
|
||||
{keyStatus}
|
||||
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this._onRecoveryKeyNext} spellCheck={false}>
|
||||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
|
||||
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
|
||||
<Field
|
||||
type="text"
|
||||
label={_t('Security Key')}
|
||||
value={this.state.recoveryKey}
|
||||
onChange={this._onRecoveryKeyChange}
|
||||
forceValidity={this.state.recoveryKeyCorrect}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">
|
||||
{_t("or")}
|
||||
</span>
|
||||
<div>
|
||||
<input type="file"
|
||||
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
|
||||
ref={this._fileUpload}
|
||||
onChange={this._onRecoveryKeyFileChange}
|
||||
/>
|
||||
<AccessibleButton kind="primary" onClick={this._onRecoveryKeyFileUploadClick}>
|
||||
{_t("Upload")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
{recoveryKeyFeedback}
|
||||
<DialogButtons
|
||||
primaryButton={_t('Next')}
|
||||
primaryButton={_t('Continue')}
|
||||
onPrimaryButtonClick={this._onRecoveryKeyNext}
|
||||
hasCancel={true}
|
||||
cancelButton={_t("Go Back")}
|
||||
cancelButtonClass='danger'
|
||||
onCancel={this._onCancel}
|
||||
focus={false}
|
||||
primaryDisabled={!this.state.recoveryKeyValid}
|
||||
/>
|
||||
</form>
|
||||
{_t(
|
||||
"If you've forgotten your recovery key you can "+
|
||||
"<button>set up new recovery options</button>."
|
||||
, {}, {
|
||||
button: s => <AccessibleButton className="mx_linkButton"
|
||||
element="span"
|
||||
onClick={this._onResetRecoveryClick}
|
||||
>
|
||||
{s}
|
||||
</AccessibleButton>,
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -252,6 +333,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
|
|||
<BaseDialog className='mx_AccessSecretStorageDialog'
|
||||
onFinished={this.props.onFinished}
|
||||
title={title}
|
||||
titleClass={titleClass}
|
||||
>
|
||||
<div>
|
||||
{content}
|
||||
|
|
|
@ -27,7 +27,7 @@ export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Elemen
|
|||
* onClick: (required) Event handler for button activation. Should be
|
||||
* implemented exactly like a normal onClick handler.
|
||||
*/
|
||||
interface IProps extends React.InputHTMLAttributes<Element> {
|
||||
export interface IProps extends React.InputHTMLAttributes<Element> {
|
||||
inputRef?: React.Ref<Element>;
|
||||
element?: string;
|
||||
// The kind of button, similar to how Bootstrap works.
|
||||
|
|
|
@ -16,21 +16,28 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import * as sdk from "../../../index";
|
||||
import {IProps} from "./AccessibleButton";
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export default class AccessibleTooltipButton extends React.PureComponent {
|
||||
static propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
// The tooltip to render on hover
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
interface ITooltipProps extends IProps {
|
||||
title: string;
|
||||
tooltipClassName?: string;
|
||||
}
|
||||
|
||||
state = {
|
||||
hover: false,
|
||||
};
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
export default class AccessibleTooltipButton extends React.PureComponent<ITooltipProps, IState> {
|
||||
constructor(props: ITooltipProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
onMouseOver = () => {
|
||||
this.setState({
|
||||
|
@ -45,14 +52,15 @@ export default class AccessibleTooltipButton extends React.PureComponent {
|
|||
};
|
||||
|
||||
render() {
|
||||
const Tooltip = sdk.getComponent("elements.Tooltip");
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
const {title, children, ...props} = this.props;
|
||||
const tooltipClassName = classnames(
|
||||
"mx_AccessibleTooltipButton_tooltip",
|
||||
this.props.tooltipClassName,
|
||||
);
|
||||
|
||||
const tip = this.state.hover ? <Tooltip
|
||||
className="mx_AccessibleTooltipButton_container"
|
||||
tooltipClassName="mx_AccessibleTooltipButton_tooltip"
|
||||
tooltipClassName={tooltipClassName}
|
||||
label={title}
|
||||
/> : <div />;
|
||||
return (
|
|
@ -50,7 +50,7 @@ interface IProps {
|
|||
// to the user.
|
||||
onValidate?: (input: IFieldState) => Promise<IValidationResult>;
|
||||
// If specified, overrides the value returned by onValidate.
|
||||
flagInvalid?: boolean;
|
||||
forceValidity?: boolean;
|
||||
// If specified, contents will appear as a tooltip on the element and
|
||||
// validation feedback tooltips will be suppressed.
|
||||
tooltipContent?: React.ReactNode;
|
||||
|
@ -203,7 +203,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
public render() {
|
||||
const {
|
||||
element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
|
||||
tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props;
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
const ref = input => this.input = input;
|
||||
|
@ -228,15 +228,15 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
|
||||
}
|
||||
|
||||
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
|
||||
const hasValidationFlag = forceValidity !== null && forceValidity !== undefined;
|
||||
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
|
||||
// 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_valid: onValidate && this.state.valid === true,
|
||||
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
|
||||
mx_Field_invalid: hasValidationFlag
|
||||
? flagInvalid
|
||||
? !forceValidity
|
||||
: onValidate && this.state.valid === false,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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 PropTypes from 'prop-types';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'ProgressBar',
|
||||
propTypes: {
|
||||
value: PropTypes.number,
|
||||
max: PropTypes.number,
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// Would use an HTML5 progress tag but if that doesn't animate if you
|
||||
// use the HTML attributes rather than styles
|
||||
const progressStyle = {
|
||||
width: ((this.props.value / this.props.max) * 100)+"%",
|
||||
};
|
||||
return (
|
||||
<div className="mx_ProgressBar"><div className="mx_ProgressBar_fill" style={progressStyle}></div></div>
|
||||
);
|
||||
},
|
||||
});
|
28
src/components/views/elements/ProgressBar.tsx
Normal file
28
src/components/views/elements/ProgressBar.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
Copyright 2020 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";
|
||||
|
||||
interface IProps {
|
||||
value: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
const ProgressBar: React.FC<IProps> = ({value, max}) => {
|
||||
return <progress className="mx_ProgressBar" max={max} value={value} />;
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
|
@ -18,6 +18,7 @@ import React from 'react';
|
|||
import classnames from 'classnames';
|
||||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
outlined?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -29,7 +30,7 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
};
|
||||
|
||||
public render() {
|
||||
const { children, className, disabled, ...otherProps } = this.props;
|
||||
const { children, className, disabled, outlined, ...otherProps } = this.props;
|
||||
const _className = classnames(
|
||||
'mx_RadioButton',
|
||||
className,
|
||||
|
@ -37,12 +38,13 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
|
|||
"mx_RadioButton_disabled": disabled,
|
||||
"mx_RadioButton_enabled": !disabled,
|
||||
"mx_RadioButton_checked": this.props.checked,
|
||||
"mx_RadioButton_outlined": outlined,
|
||||
});
|
||||
return <label className={_className}>
|
||||
<input type='radio' disabled={disabled} {...otherProps} />
|
||||
{/* Used to render the radio button circle */}
|
||||
<div><div></div></div>
|
||||
<span>{children}</span>
|
||||
<div><div /></div>
|
||||
<div className="mx_RadioButton_content">{children}</div>
|
||||
<div className="mx_RadioButton_spacer" />
|
||||
</label>;
|
||||
}
|
||||
|
|
|
@ -32,24 +32,25 @@ interface IProps<T extends string> {
|
|||
className?: string;
|
||||
definitions: IDefinition<T>[];
|
||||
value?: T; // if not provided no options will be selected
|
||||
outlined?: boolean;
|
||||
onChange(newValue: T);
|
||||
}
|
||||
|
||||
function StyledRadioGroup<T extends string>({name, definitions, value, className, onChange}: IProps<T>) {
|
||||
function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) {
|
||||
const _onChange = e => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return <React.Fragment>
|
||||
{definitions.map(d => <React.Fragment>
|
||||
{definitions.map(d => <React.Fragment key={d.value}>
|
||||
<StyledRadioButton
|
||||
key={d.value}
|
||||
className={classNames(className, d.className)}
|
||||
onChange={_onChange}
|
||||
checked={d.value === value}
|
||||
name={name}
|
||||
value={d.value}
|
||||
disabled={d.disabled}
|
||||
outlined={outlined}
|
||||
>
|
||||
{d.label}
|
||||
</StyledRadioButton>
|
||||
|
|
|
@ -17,35 +17,13 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
||||
import * as Unread from '../../../Unread';
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { EventEmitter } from "events";
|
||||
import { arrayDiff } from "../../../utils/arrays";
|
||||
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
|
||||
|
||||
export const NOTIFICATION_STATE_UPDATE = "update";
|
||||
|
||||
export enum NotificationColor {
|
||||
// Inverted (None -> Red) because we do integer comparisons on this
|
||||
None, // nothing special
|
||||
// TODO: Remove bold with notifications: https://github.com/vector-im/riot-web/issues/14227
|
||||
Bold, // no badge, show as unread
|
||||
Grey, // unread notified messages
|
||||
Red, // unread pings
|
||||
}
|
||||
|
||||
export interface INotificationState extends EventEmitter {
|
||||
symbol?: string;
|
||||
count: number;
|
||||
color: NotificationColor;
|
||||
}
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { XOR } from "../../../@types/common";
|
||||
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
|
||||
interface IProps {
|
||||
notification: INotificationState;
|
||||
|
@ -62,11 +40,18 @@ interface IProps {
|
|||
roomId?: string;
|
||||
}
|
||||
|
||||
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
|
||||
/**
|
||||
* If specified will return an AccessibleButton instead of a div.
|
||||
*/
|
||||
onClick?(ev: React.MouseEvent);
|
||||
}
|
||||
|
||||
interface IState {
|
||||
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
|
||||
}
|
||||
|
||||
export default class NotificationBadge extends React.PureComponent<IProps, IState> {
|
||||
export default class NotificationBadge extends React.PureComponent<XOR<IProps, IClickableProps>, IState> {
|
||||
private countWatcherRef: string;
|
||||
|
||||
constructor(props: IProps) {
|
||||
|
@ -109,20 +94,25 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
|
|||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// Don't show a badge if we don't need to
|
||||
if (this.props.notification.color <= NotificationColor.None) return null;
|
||||
const {notification, forceCount, roomId, onClick, ...props} = this.props;
|
||||
|
||||
const hasNotif = this.props.notification.color >= NotificationColor.Red;
|
||||
const hasCount = this.props.notification.color >= NotificationColor.Grey;
|
||||
const hasUnread = this.props.notification.color >= NotificationColor.Bold;
|
||||
const couldBeEmpty = (!this.state.showCounts || hasUnread) && !hasNotif;
|
||||
let isEmptyBadge = couldBeEmpty && (!this.state.showCounts || !hasCount);
|
||||
if (this.props.forceCount) {
|
||||
// Don't show a badge if we don't need to
|
||||
if (notification.color <= NotificationColor.None) return null;
|
||||
|
||||
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
|
||||
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
|
||||
// See git diff for what that boolean state looks like.
|
||||
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
|
||||
const hasNotif = notification.color >= NotificationColor.Red;
|
||||
const hasCount = notification.color >= NotificationColor.Grey;
|
||||
const hasAnySymbol = notification.symbol || notification.count > 0;
|
||||
let isEmptyBadge = !hasAnySymbol || !hasCount;
|
||||
if (forceCount) {
|
||||
isEmptyBadge = false;
|
||||
if (!hasCount) return null; // Can't render a badge
|
||||
}
|
||||
|
||||
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
|
||||
let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
|
||||
if (isEmptyBadge) symbol = "";
|
||||
|
||||
const classes = classNames({
|
||||
|
@ -134,6 +124,14 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
|
|||
'mx_NotificationBadge_3char': symbol.length > 2,
|
||||
});
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<AccessibleButton {...props} className={classes} onClick={onClick}>
|
||||
<span className="mx_NotificationBadge_count">{symbol}</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<span className="mx_NotificationBadge_count">{symbol}</span>
|
||||
|
@ -141,242 +139,3 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Clean up these state classes: https://github.com/vector-im/riot-web/issues/14153
|
||||
|
||||
export class StaticNotificationState extends EventEmitter implements INotificationState {
|
||||
constructor(public symbol: string, public count: number, public color: NotificationColor) {
|
||||
super();
|
||||
}
|
||||
|
||||
public static forCount(count: number, color: NotificationColor): StaticNotificationState {
|
||||
return new StaticNotificationState(null, count, color);
|
||||
}
|
||||
|
||||
public static forSymbol(symbol: string, color: NotificationColor): StaticNotificationState {
|
||||
return new StaticNotificationState(symbol, 0, color);
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState {
|
||||
private _symbol: string;
|
||||
private _count: number;
|
||||
private _color: NotificationColor;
|
||||
|
||||
constructor(private room: Room) {
|
||||
super();
|
||||
this.room.on("Room.receipt", this.handleReadReceipt);
|
||||
this.room.on("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.on("Room.redaction", this.handleRoomEventUpdate);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
||||
this.updateNotificationState();
|
||||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return this._symbol;
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
return this._count;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
private get roomIsInvite(): boolean {
|
||||
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.room.removeListener("Room.receipt", this.handleReadReceipt);
|
||||
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private handleReadReceipt = (event: MatrixEvent, room: Room) => {
|
||||
if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore
|
||||
if (room.roomId !== this.room.roomId) return; // not for us - ignore
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
private handleRoomEventUpdate = (event: MatrixEvent) => {
|
||||
const roomId = event.getRoomId();
|
||||
|
||||
if (roomId !== this.room.roomId) return; // ignore - not for us
|
||||
this.updateNotificationState();
|
||||
};
|
||||
|
||||
private updateNotificationState() {
|
||||
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
|
||||
if (this.roomIsInvite) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._symbol = "!";
|
||||
this._count = 1; // not used, technically
|
||||
} else {
|
||||
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight');
|
||||
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total');
|
||||
|
||||
// For a 'true count' we pick the grey notifications first because they include the
|
||||
// red notifications. If we don't have a grey count for some reason we use the red
|
||||
// count. If that count is broken for some reason, assume zero. This avoids us showing
|
||||
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
|
||||
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
|
||||
|
||||
// Note: we only set the symbol if we have an actual count. We don't want to show
|
||||
// zero on badges.
|
||||
|
||||
if (redNotifs > 0) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._count = trueCount;
|
||||
this._symbol = null; // symbol calculated by component
|
||||
} else if (greyNotifs > 0) {
|
||||
this._color = NotificationColor.Grey;
|
||||
this._count = trueCount;
|
||||
this._symbol = null; // symbol calculated by component
|
||||
} else {
|
||||
// We don't have any notified messages, but we might have unread messages. Let's
|
||||
// find out.
|
||||
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room);
|
||||
if (hasUnread) {
|
||||
this._color = NotificationColor.Bold;
|
||||
} else {
|
||||
this._color = NotificationColor.None;
|
||||
}
|
||||
|
||||
// no symbol or count for this state
|
||||
this._count = 0;
|
||||
this._symbol = null;
|
||||
}
|
||||
}
|
||||
|
||||
// finally, publish an update if needed
|
||||
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TagSpecificNotificationState extends RoomNotificationState {
|
||||
private static TAG_TO_COLOR: {
|
||||
// @ts-ignore - TS wants this to be a string key, but we know better
|
||||
[tagId: TagID]: NotificationColor,
|
||||
} = {
|
||||
[DefaultTagID.DM]: NotificationColor.Red,
|
||||
};
|
||||
|
||||
private readonly colorWhenNotIdle?: NotificationColor;
|
||||
|
||||
constructor(room: Room, tagId: TagID) {
|
||||
super(room);
|
||||
|
||||
const specificColor = TagSpecificNotificationState.TAG_TO_COLOR[tagId];
|
||||
if (specificColor) this.colorWhenNotIdle = specificColor;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
if (!this.colorWhenNotIdle) return super.color;
|
||||
|
||||
if (super.color !== NotificationColor.None) return this.colorWhenNotIdle;
|
||||
return super.color;
|
||||
}
|
||||
}
|
||||
|
||||
export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState {
|
||||
private _count: number;
|
||||
private _color: NotificationColor;
|
||||
private rooms: Room[] = [];
|
||||
private states: { [roomId: string]: RoomNotificationState } = {};
|
||||
|
||||
constructor(private byTileCount = false, private tagId: TagID) {
|
||||
super();
|
||||
}
|
||||
|
||||
public get symbol(): string {
|
||||
return null; // This notification state doesn't support symbols
|
||||
}
|
||||
|
||||
public get count(): number {
|
||||
return this._count;
|
||||
}
|
||||
|
||||
public get color(): NotificationColor {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
public setRooms(rooms: Room[]) {
|
||||
// If we're only concerned about the tile count, don't bother setting up listeners.
|
||||
if (this.byTileCount) {
|
||||
this.rooms = rooms;
|
||||
this.calculateTotalState();
|
||||
return;
|
||||
}
|
||||
|
||||
const oldRooms = this.rooms;
|
||||
const diff = arrayDiff(oldRooms, rooms);
|
||||
this.rooms = rooms;
|
||||
for (const oldRoom of diff.removed) {
|
||||
const state = this.states[oldRoom.roomId];
|
||||
if (!state) continue; // We likely just didn't have a badge (race condition)
|
||||
delete this.states[oldRoom.roomId];
|
||||
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
state.destroy();
|
||||
}
|
||||
for (const newRoom of diff.added) {
|
||||
const state = new TagSpecificNotificationState(newRoom, this.tagId);
|
||||
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||
if (this.states[newRoom.roomId]) {
|
||||
// "Should never happen" disclaimer.
|
||||
console.warn("Overwriting notification state for room:", newRoom.roomId);
|
||||
this.states[newRoom.roomId].destroy();
|
||||
}
|
||||
this.states[newRoom.roomId] = state;
|
||||
}
|
||||
|
||||
this.calculateTotalState();
|
||||
}
|
||||
|
||||
public getForRoom(room: Room) {
|
||||
const state = this.states[room.roomId];
|
||||
if (!state) throw new Error("Unknown room for notification state");
|
||||
return state;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
for (const state of Object.values(this.states)) {
|
||||
state.destroy();
|
||||
}
|
||||
this.states = {};
|
||||
}
|
||||
|
||||
private onRoomNotificationStateUpdate = () => {
|
||||
this.calculateTotalState();
|
||||
};
|
||||
|
||||
private calculateTotalState() {
|
||||
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
|
||||
if (this.byTileCount) {
|
||||
this._color = NotificationColor.Red;
|
||||
this._count = this.rooms.length;
|
||||
} else {
|
||||
this._count = 0;
|
||||
this._color = NotificationColor.None;
|
||||
for (const state of Object.values(this.states)) {
|
||||
this._count += state.count;
|
||||
this._color = Math.max(this.color, state.color);
|
||||
}
|
||||
}
|
||||
|
||||
// finally, publish an update if needed
|
||||
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,13 +17,16 @@ limitations under the License.
|
|||
import React from "react";
|
||||
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import Analytics from "../../../Analytics";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore2";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
|
@ -93,15 +96,25 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
|
|||
// TODO: Scrolling: https://github.com/vector-im/riot-web/issues/14040
|
||||
// TODO: Tooltips: https://github.com/vector-im/riot-web/issues/14040
|
||||
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
|
||||
const roomTags = RoomListStore.instance.getTagsForRoom(r);
|
||||
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
|
||||
return (
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomBreadcrumbs2_crumb"
|
||||
key={r.roomId}
|
||||
onClick={() => this.viewRoom(r, i)}
|
||||
aria-label={_t("Room %(name)s", {name: r.name})}
|
||||
title={r.name}
|
||||
tooltipClassName={"mx_RoomBreadcrumbs2_Tooltip"}
|
||||
>
|
||||
<RoomAvatar room={r} width={32} height={32}/>
|
||||
</AccessibleButton>
|
||||
<DecoratedRoomAvatar
|
||||
room={r}
|
||||
avatarSize={32}
|
||||
tag={roomTag}
|
||||
displayBadge={true}
|
||||
forceCount={true}
|
||||
/>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -25,10 +25,16 @@ import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
|||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { Dispatcher } from "flux";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import RoomSublist2 from "./RoomSublist2";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import GroupAvatar from "../avatars/GroupAvatar";
|
||||
import TemporaryTile from "./TemporaryTile";
|
||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
|
||||
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
|
@ -160,16 +166,57 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store: RoomListStore2) => {
|
||||
const newLists = store.orderedLists;
|
||||
console.log("new lists", newLists);
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
|
||||
this.updateLists(); // trigger the first update
|
||||
}
|
||||
|
||||
const layoutMap = new Map<TagID, ListLayout>();
|
||||
for (const tagId of Object.keys(newLists)) {
|
||||
layoutMap.set(tagId, new ListLayout(tagId));
|
||||
}
|
||||
public componentWillUnmount() {
|
||||
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
|
||||
}
|
||||
|
||||
this.setState({sublists: newLists, layouts: layoutMap});
|
||||
private updateLists = () => {
|
||||
const newLists = RoomListStore.instance.orderedLists;
|
||||
console.log("new lists", newLists);
|
||||
|
||||
const layoutMap = new Map<TagID, ListLayout>();
|
||||
for (const tagId of Object.keys(newLists)) {
|
||||
layoutMap.set(tagId, new ListLayout(tagId));
|
||||
}
|
||||
|
||||
this.setState({sublists: newLists, layouts: layoutMap});
|
||||
};
|
||||
|
||||
private renderCommunityInvites(): React.ReactElement[] {
|
||||
// TODO: Put community invites in a more sensible place (not in the room list)
|
||||
return MatrixClientPeg.get().getGroups().filter(g => {
|
||||
if (g.myMembership !== 'invite') return false;
|
||||
return !this.searchFilter || this.searchFilter.matches(g.name);
|
||||
}).map(g => {
|
||||
const avatar = (
|
||||
<GroupAvatar
|
||||
groupId={g.groupId}
|
||||
groupName={g.name}
|
||||
groupAvatarUrl={g.avatarUrl}
|
||||
width={32} height={32} resizeMethod='crop'
|
||||
/>
|
||||
);
|
||||
const openGroup = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: 'view_group',
|
||||
group_id: g.groupId,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<TemporaryTile
|
||||
isMinimized={this.props.isMinimized}
|
||||
isSelected={false}
|
||||
displayName={g.name}
|
||||
avatar={avatar}
|
||||
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
|
||||
onClick={openGroup}
|
||||
key={`temporaryGroupTile_${g.groupId}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -195,6 +242,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
||||
|
||||
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
||||
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
|
||||
components.push(
|
||||
<RoomSublist2
|
||||
key={`sublist-${orderedTagId}`}
|
||||
|
@ -208,6 +256,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
isInvite={aesthetics.isInvite}
|
||||
layout={this.state.layouts.get(orderedTagId)}
|
||||
isMinimized={this.props.isMinimized}
|
||||
extraBadTilesThatShouldntExist={extraTiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -26,13 +26,18 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
|
|||
import RoomTile2 from "./RoomTile2";
|
||||
import { ResizableBox, ResizeCallbackData } from "react-resizable";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
|
||||
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
import StyledRadioButton from "../elements/StyledRadioButton";
|
||||
import RoomListStore from "../../../stores/room-list/RoomListStore2";
|
||||
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
|
||||
import { TagID } from "../../../stores/room-list/models";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
|
||||
import Tooltip from "../elements/Tooltip";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { Key } from "../../../Keyboard";
|
||||
|
||||
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
|
@ -62,33 +67,44 @@ interface IProps {
|
|||
isMinimized: boolean;
|
||||
tagId: TagID;
|
||||
|
||||
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
|
||||
// You should feel bad if you use this.
|
||||
extraBadTilesThatShouldntExist?: React.ReactElement[];
|
||||
|
||||
// TODO: Account for https://github.com/vector-im/riot-web/issues/14179
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
|
||||
|
||||
interface IState {
|
||||
notificationState: ListNotificationState;
|
||||
menuDisplayed: boolean;
|
||||
contextMenuPosition: PartialDOMRect;
|
||||
isResizing: boolean;
|
||||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef();
|
||||
private menuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
private headerButton = createRef<HTMLDivElement>();
|
||||
private sublistRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
|
||||
menuDisplayed: false,
|
||||
contextMenuPosition: null,
|
||||
isResizing: false,
|
||||
};
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
// TODO: Account for group invites: https://github.com/vector-im/riot-web/issues/14179
|
||||
return (this.props.rooms || []).length;
|
||||
return (this.props.rooms || []).length + (this.props.extraBadTilesThatShouldntExist || []).length;
|
||||
}
|
||||
|
||||
private get numVisibleTiles(): number {
|
||||
if (!this.props.layout) return 0;
|
||||
const nVisible = Math.floor(this.props.layout.visibleTiles);
|
||||
return Math.min(nVisible, this.numTiles);
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
|
@ -107,7 +123,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => {
|
||||
const direction = e.movementY < 0 ? -1 : +1;
|
||||
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction;
|
||||
this.props.layout.visibleTiles += tileDiff;
|
||||
this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles);
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
|
@ -132,11 +148,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({menuDisplayed: true});
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({contextMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
contextMenuPosition: {
|
||||
left: ev.clientX,
|
||||
top: ev.clientY,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseMenu = () => {
|
||||
this.setState({menuDisplayed: false});
|
||||
this.setState({contextMenuPosition: null});
|
||||
};
|
||||
|
||||
private onUnreadFirstChanged = async () => {
|
||||
|
@ -154,6 +183,30 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
};
|
||||
|
||||
private onBadgeClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
let room;
|
||||
if (this.props.tagId === DefaultTagID.Invite) {
|
||||
// switch to first room as that'll be the top of the list for the user
|
||||
room = this.props.rooms && this.props.rooms[0];
|
||||
} else {
|
||||
// find the first room with a count of the same colour as the badge count
|
||||
room = this.props.rooms.find((r: Room) => {
|
||||
const notifState = this.state.notificationState.getForRoom(r);
|
||||
return notifState.count > 0 && notifState.color === this.state.notificationState.color;
|
||||
});
|
||||
}
|
||||
|
||||
if (room) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: room.roomId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onHeaderClick = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
let target = ev.target as HTMLDivElement;
|
||||
if (!target.classList.contains('mx_RoomSublist2_headerText')) {
|
||||
|
@ -168,18 +221,70 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
sublist.scrollIntoView({behavior: 'smooth'});
|
||||
} else {
|
||||
// on screen - toggle collapse
|
||||
this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
|
||||
this.forceUpdate(); // because the layout doesn't trigger an update
|
||||
this.toggleCollapsed();
|
||||
}
|
||||
};
|
||||
|
||||
private renderTiles(): React.ReactElement[] {
|
||||
if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering
|
||||
private toggleCollapsed = () => {
|
||||
this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
|
||||
this.forceUpdate(); // because the layout doesn't trigger an update
|
||||
};
|
||||
|
||||
private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
|
||||
const isCollapsed = this.props.layout && this.props.layout.isCollapsed;
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
if (!isCollapsed) {
|
||||
// On ARROW_LEFT collapse the room sublist if it isn't already
|
||||
this.toggleCollapsed();
|
||||
}
|
||||
break;
|
||||
case Key.ARROW_RIGHT: {
|
||||
ev.stopPropagation();
|
||||
if (isCollapsed) {
|
||||
// On ARROW_RIGHT expand the room sublist if it isn't already
|
||||
this.toggleCollapsed();
|
||||
} else if (this.sublistRef.current) {
|
||||
// otherwise focus the first room
|
||||
const element = this.sublistRef.current.querySelector(".mx_RoomTile2") as HTMLDivElement;
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
// On ARROW_LEFT go to the sublist header
|
||||
case Key.ARROW_LEFT:
|
||||
ev.stopPropagation();
|
||||
this.headerButton.current.focus();
|
||||
break;
|
||||
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
|
||||
case Key.ARROW_RIGHT:
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
private renderVisibleTiles(): React.ReactElement[] {
|
||||
if (this.props.layout && this.props.layout.isCollapsed) {
|
||||
// don't waste time on rendering
|
||||
return [];
|
||||
}
|
||||
|
||||
const tiles: React.ReactElement[] = [];
|
||||
|
||||
if (this.props.extraBadTilesThatShouldntExist) {
|
||||
tiles.push(...this.props.extraBadTilesThatShouldntExist);
|
||||
}
|
||||
|
||||
if (this.props.rooms) {
|
||||
for (const room of this.props.rooms) {
|
||||
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
|
||||
for (const room of visibleRooms) {
|
||||
tiles.push(
|
||||
<RoomTile2
|
||||
room={room}
|
||||
|
@ -192,20 +297,32 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
// We only have to do this because of the extra tiles. We do it conditionally
|
||||
// to avoid spending cycles on slicing. It's generally fine to do this though
|
||||
// as users are unlikely to have more than a handful of tiles when the extra
|
||||
// tiles are used.
|
||||
if (tiles.length > this.numVisibleTiles) {
|
||||
return tiles.slice(0, this.numVisibleTiles);
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private renderMenu(): React.ReactElement {
|
||||
// TODO: Get a proper invite context menu, or take invites out of the room list.
|
||||
if (this.props.tagId === DefaultTagID.Invite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.menuDisplayed) {
|
||||
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
|
||||
if (this.state.contextMenuPosition) {
|
||||
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
|
||||
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height}
|
||||
left={this.state.contextMenuPosition.left}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
>
|
||||
<div className="mx_RoomSublist2_contextMenu">
|
||||
|
@ -256,9 +373,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
<ContextMenuButton
|
||||
className="mx_RoomSublist2_menuButton"
|
||||
onClick={this.onOpenMenuClick}
|
||||
inputRef={this.menuButtonRef}
|
||||
label={_t("List options")}
|
||||
isExpanded={this.state.menuDisplayed}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
|
@ -267,21 +383,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
private renderHeader(): React.ReactElement {
|
||||
return (
|
||||
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||
<RovingTabIndexWrapper>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
// TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
const badge = <NotificationBadge forceCount={true} notification={this.state.notificationState}/>;
|
||||
const badge = (
|
||||
<NotificationBadge
|
||||
forceCount={true}
|
||||
notification={this.state.notificationState}
|
||||
onClick={this.onBadgeClick}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
let addRoomButton = null;
|
||||
if (!!this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleButton
|
||||
<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSublist2_auxButton"
|
||||
aria-label={this.props.addRoomLabel || _t("Add room")}
|
||||
title={this.props.addRoomLabel}
|
||||
tooltipClassName={"mx_RoomSublist2_addRoomTooltip"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -309,16 +433,18 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
// doesn't become sticky.
|
||||
// The same applies to the notification badge.
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className='mx_RoomSublist2_stickable'>
|
||||
<div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus}>
|
||||
<div className="mx_RoomSublist2_stickable">
|
||||
<div className="mx_RoomSublist2_stickyHeaderBlur">
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className={"mx_RoomSublist2_headerText"}
|
||||
className="mx_RoomSublist2_headerText"
|
||||
role="treeitem"
|
||||
aria-level={1}
|
||||
onClick={this.onHeaderClick}
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<span className={collapseClasses} />
|
||||
<span>{this.props.label}</span>
|
||||
|
@ -340,22 +466,19 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
public render(): React.ReactElement {
|
||||
// TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185
|
||||
|
||||
const tiles = this.renderTiles();
|
||||
const visibleTiles = this.renderVisibleTiles();
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomSublist2': true,
|
||||
'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed,
|
||||
'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition,
|
||||
'mx_RoomSublist2_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
let content = null;
|
||||
if (tiles.length > 0) {
|
||||
if (visibleTiles.length > 0) {
|
||||
const layout = this.props.layout; // to shorten calls
|
||||
|
||||
const nVisible = Math.floor(layout.visibleTiles);
|
||||
const visibleTiles = tiles.slice(0, nVisible);
|
||||
|
||||
const maxTilesFactored = layout.tilesWithResizerBoxFactor(tiles.length);
|
||||
const maxTilesFactored = layout.tilesWithResizerBoxFactor(this.numTiles);
|
||||
const showMoreBtnClasses = classNames({
|
||||
'mx_RoomSublist2_showNButton': true,
|
||||
'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored,
|
||||
|
@ -365,9 +488,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
// floats above the resize handle, if we have one present. If the user has all
|
||||
// tiles visible, it becomes 'show less'.
|
||||
let showNButton = null;
|
||||
if (tiles.length > nVisible) {
|
||||
if (this.numTiles > visibleTiles.length) {
|
||||
// we have a cutoff condition - add the button to show all
|
||||
const numMissing = tiles.length - visibleTiles.length;
|
||||
const numMissing = this.numTiles - visibleTiles.length;
|
||||
let showMoreText = (
|
||||
<span className='mx_RoomSublist2_showNButtonText'>
|
||||
{_t("Show %(count)s more", {count: numMissing})}
|
||||
|
@ -382,7 +505,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
{showMoreText}
|
||||
</div>
|
||||
);
|
||||
} else if (tiles.length <= nVisible && tiles.length > this.props.layout.defaultVisibleTiles) {
|
||||
} else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) {
|
||||
// we have all tiles visible - add a button to show less
|
||||
let showLessText = (
|
||||
<span className='mx_RoomSublist2_showNButtonText'>
|
||||
|
@ -402,7 +525,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
// Figure out if we need a handle
|
||||
let handles = ['s'];
|
||||
if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) {
|
||||
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
|
||||
handles = []; // no handles, we're at a minimum
|
||||
}
|
||||
|
||||
|
@ -421,9 +544,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
if (showNButton) padding += SHOW_N_BUTTON_HEIGHT;
|
||||
padding += RESIZE_HANDLE_HEIGHT; // always append the handle height
|
||||
|
||||
const relativeTiles = layout.tilesWithPadding(tiles.length, padding);
|
||||
const relativeTiles = layout.tilesWithPadding(this.numTiles, padding);
|
||||
const minTilesPx = layout.calculateTilesToPixelsMin(relativeTiles, layout.minVisibleTiles, padding);
|
||||
const maxTilesPx = layout.tilesToPixelsWithPadding(tiles.length, padding);
|
||||
const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, padding);
|
||||
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
|
||||
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
|
||||
|
||||
|
@ -446,12 +569,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: onKeyDown support: https://github.com/vector-im/riot-web/issues/14180
|
||||
return (
|
||||
<div
|
||||
ref={this.sublistRef}
|
||||
className={classes}
|
||||
role="group"
|
||||
aria-label={this.props.label}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{this.renderHeader()}
|
||||
{content}
|
||||
|
|
|
@ -17,25 +17,26 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import React from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
|
||||
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import NotificationBadge, {
|
||||
INotificationState,
|
||||
NotificationColor,
|
||||
TagSpecificNotificationState
|
||||
} from "./NotificationBadge";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
|
||||
import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import RoomTileIcon from "./RoomTileIcon";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { setRoomNotifsState } from "../../../RoomNotifs";
|
||||
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
|
||||
import { INotificationState } from "../../../stores/notifications/INotificationState";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
|
||||
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
|
@ -57,17 +58,51 @@ interface IProps {
|
|||
// TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177
|
||||
}
|
||||
|
||||
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
notificationState: INotificationState;
|
||||
selected: boolean;
|
||||
generalMenuDisplayed: boolean;
|
||||
notificationsMenuPosition: PartialDOMRect;
|
||||
generalMenuPosition: PartialDOMRect;
|
||||
}
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
private roomTileRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||
const contextMenuBelow = (elementRect: PartialDOMRect) => {
|
||||
// align the context menu's icons with the icon which opened the context menu
|
||||
const left = elementRect.left + window.pageXOffset - 9;
|
||||
const top = elementRect.bottom + window.pageYOffset + 17;
|
||||
const chevronFace = "none";
|
||||
return {left, top, chevronFace};
|
||||
};
|
||||
|
||||
interface INotifOptionProps {
|
||||
active: boolean;
|
||||
iconClassName: string;
|
||||
label: string;
|
||||
onClick(ev: ButtonEvent);
|
||||
}
|
||||
|
||||
const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassName, label}) => {
|
||||
const classes = classNames({
|
||||
mx_RoomTile2_contextMenu_activeRow: active,
|
||||
});
|
||||
|
||||
let activeIcon;
|
||||
if (active) {
|
||||
activeIcon = <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconCheck" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemRadio className={classes} onClick={onClick} active={active} label={label}>
|
||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
{ activeIcon }
|
||||
</MenuItemRadio>
|
||||
);
|
||||
};
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
|
||||
|
||||
constructor(props: IProps) {
|
||||
|
@ -77,12 +112,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
hover: false,
|
||||
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
|
||||
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
|
||||
generalMenuDisplayed: false,
|
||||
notificationsMenuPosition: null,
|
||||
generalMenuPosition: null,
|
||||
};
|
||||
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
|
||||
private get showContextMenu(): boolean {
|
||||
return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite;
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.props.room) {
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
|
@ -98,6 +138,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onTileClick = (ev: React.KeyboardEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
// TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
|
||||
|
@ -111,16 +153,40 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
this.setState({selected: isActive});
|
||||
};
|
||||
|
||||
private onNotificationsMenuOpenClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({notificationsMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onCloseNotificationsMenu = () => {
|
||||
this.setState({notificationsMenuPosition: null});
|
||||
};
|
||||
|
||||
private onGeneralMenuOpenClick = (ev: InputEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({generalMenuDisplayed: true});
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
this.setState({generalMenuPosition: target.getBoundingClientRect()});
|
||||
};
|
||||
|
||||
private onCloseGeneralMenu = (ev: InputEvent) => {
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
// If we don't have a context menu to show, ignore the action.
|
||||
if (!this.showContextMenu) return;
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({generalMenuDisplayed: false});
|
||||
this.setState({
|
||||
generalMenuPosition: {
|
||||
left: ev.clientX,
|
||||
bottom: ev.clientY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private onCloseGeneralMenu = () => {
|
||||
this.setState({generalMenuPosition: null});
|
||||
};
|
||||
|
||||
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
|
||||
|
@ -139,7 +205,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
action: 'leave_room',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuDisplayed: false}); // hide the menu
|
||||
this.setState({generalMenuPosition: null}); // hide the menu
|
||||
};
|
||||
|
||||
private onOpenRoomSettings = (ev: ButtonEvent) => {
|
||||
|
@ -150,58 +216,126 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
action: 'open_room_settings',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
this.setState({generalMenuDisplayed: false}); // hide the menu
|
||||
this.setState({generalMenuPosition: null}); // hide the menu
|
||||
};
|
||||
|
||||
private renderGeneralMenu(): React.ReactElement {
|
||||
if (this.props.isMinimized) return null; // no menu when minimized
|
||||
private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
try {
|
||||
// TODO add local echo - https://github.com/vector-im/riot-web/issues/14280
|
||||
await setRoomNotifsState(this.props.room.roomId, newState);
|
||||
} catch (error) {
|
||||
// TODO: some form of error notification to the user to inform them that their state change failed.
|
||||
// https://github.com/vector-im/riot-web/issues/14281
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
this.setState({notificationsMenuPosition: null}); // Close the context menu
|
||||
}
|
||||
|
||||
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
|
||||
private onClickAlertMe = ev => this.saveNotifState(ev, ALL_MESSAGES_LOUD);
|
||||
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
|
||||
private onClickMute = ev => this.saveNotifState(ev, MUTE);
|
||||
|
||||
private renderNotificationsMenu(isActive: boolean): React.ReactElement {
|
||||
if (MatrixClientPeg.get().isGuest() || !this.showContextMenu) {
|
||||
// the menu makes no sense in these cases so do not show one
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = getRoomNotifsState(this.props.room.roomId);
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.generalMenuDisplayed) {
|
||||
// The context menu appears within the list, so use the room tile as a reference point
|
||||
const elementRect = this.roomTileRef.current.getBoundingClientRect();
|
||||
if (this.state.notificationsMenuPosition) {
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
left={elementRect.left}
|
||||
top={elementRect.top + elementRect.height + 8}
|
||||
onFinished={this.onCloseGeneralMenu}
|
||||
>
|
||||
<div
|
||||
className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"
|
||||
style={{width: elementRect.width}}
|
||||
>
|
||||
<ContextMenu {...contextMenuBelow(this.state.notificationsMenuPosition)} onFinished={this.onCloseNotificationsMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
|
||||
<span>{_t("Favourite")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown" />
|
||||
<span>{_t("Low Priority")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
<li>
|
||||
<AccessibleButton onClick={this.onOpenRoomSettings}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
|
||||
<span>{_t("Settings")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
<NotifOption
|
||||
label={_t("Use default")}
|
||||
active={state === ALL_MESSAGES}
|
||||
iconClassName="mx_RoomTile2_iconBell"
|
||||
onClick={this.onClickAllNotifs}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("All messages")}
|
||||
active={state === ALL_MESSAGES_LOUD}
|
||||
iconClassName="mx_RoomTile2_iconBellDot"
|
||||
onClick={this.onClickAlertMe}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("Mentions & Keywords")}
|
||||
active={state === MENTIONS_ONLY}
|
||||
iconClassName="mx_RoomTile2_iconBellMentions"
|
||||
onClick={this.onClickMentions}
|
||||
/>
|
||||
<NotifOption
|
||||
label={_t("None")}
|
||||
active={state === MUTE}
|
||||
iconClassName="mx_RoomTile2_iconBellCrossed"
|
||||
onClick={this.onClickMute}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RoomTile2_notificationsButton", {
|
||||
// Show bell icon for the default case too.
|
||||
mx_RoomTile2_iconBell: state === ALL_MESSAGES,
|
||||
mx_RoomTile2_iconBellDot: state === ALL_MESSAGES_LOUD,
|
||||
mx_RoomTile2_iconBellMentions: state === MENTIONS_ONLY,
|
||||
mx_RoomTile2_iconBellCrossed: state === MUTE,
|
||||
|
||||
// Only show the icon by default if the room is overridden to muted.
|
||||
// TODO: [FTUE Notifications] Probably need to detect global mute state
|
||||
mx_RoomTile2_notificationsButton_show: state === MUTE,
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ContextMenuButton
|
||||
className={classes}
|
||||
onClick={this.onNotificationsMenuOpenClick}
|
||||
label={_t("Notification options")}
|
||||
isExpanded={!!this.state.notificationsMenuPosition}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderGeneralMenu(): React.ReactElement {
|
||||
if (!this.showContextMenu) return null; // no menu to show
|
||||
|
||||
// TODO: We could do with a proper invite context menu, unlike what showContextMenu suggests
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.generalMenuPosition) {
|
||||
contextMenu = (
|
||||
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
|
||||
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
|
||||
<div className="mx_IconizedContextMenu_optionList">
|
||||
<ul>
|
||||
<li className="mx_RoomTile2_contextMenu_redRow">
|
||||
<AccessibleButton onClick={this.onLeaveRoomClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
|
||||
<span>{_t("Leave Room")}</span>
|
||||
</AccessibleButton>
|
||||
</li>
|
||||
</ul>
|
||||
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton onClick={this.onOpenRoomSettings}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
|
||||
<AccessibleButton onClick={this.onLeaveRoomClick}>
|
||||
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
|
||||
<span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
@ -213,9 +347,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
<ContextMenuButton
|
||||
className="mx_RoomTile2_menuButton"
|
||||
onClick={this.onGeneralMenuOpenClick}
|
||||
inputRef={this.generalMenuButtonRef}
|
||||
label={_t("Room options")}
|
||||
isExpanded={this.state.generalMenuDisplayed}
|
||||
isExpanded={!!this.state.generalMenuPosition}
|
||||
/>
|
||||
{contextMenu}
|
||||
</React.Fragment>
|
||||
|
@ -229,17 +362,29 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
const classes = classNames({
|
||||
'mx_RoomTile2': true,
|
||||
'mx_RoomTile2_selected': this.state.selected,
|
||||
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed,
|
||||
'mx_RoomTile2_hasMenuOpen': !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
|
||||
'mx_RoomTile2_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const badge = (
|
||||
<NotificationBadge
|
||||
notification={this.state.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
);
|
||||
const roomAvatar = <DecoratedRoomAvatar
|
||||
room={this.props.room}
|
||||
avatarSize={32}
|
||||
tag={this.props.tag}
|
||||
displayBadge={this.props.isMinimized}
|
||||
/>;
|
||||
|
||||
let badge: React.ReactNode;
|
||||
if (!this.props.isMinimized) {
|
||||
badge = (
|
||||
<div className="mx_RoomTile2_badgeContainer">
|
||||
<NotificationBadge
|
||||
notification={this.state.notificationState}
|
||||
forceCount={false}
|
||||
roomId={this.props.room.roomId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||
let name = this.props.room.name;
|
||||
|
@ -277,10 +422,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
);
|
||||
if (this.props.isMinimized) nameContainer = null;
|
||||
|
||||
const avatarSize = 32;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
|
||||
<RovingTabIndexWrapper>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
|
@ -291,15 +435,12 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
onMouseLeave={this.onTileMouseLeave}
|
||||
onClick={this.onTileClick}
|
||||
role="treeitem"
|
||||
onContextMenu={this.onContextMenu}
|
||||
>
|
||||
<div className="mx_RoomTile2_avatarContainer">
|
||||
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} />
|
||||
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
|
||||
</div>
|
||||
{roomAvatar}
|
||||
{nameContainer}
|
||||
<div className="mx_RoomTile2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
{badge}
|
||||
{this.renderNotificationsMenu(isActive)}
|
||||
{this.renderGeneralMenu()}
|
||||
</AccessibleButton>
|
||||
}
|
||||
|
|
116
src/components/views/rooms/TemporaryTile.tsx
Normal file
116
src/components/views/rooms/TemporaryTile.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
Copyright 2020 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 { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import { INotificationState } from "../../../stores/notifications/INotificationState";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
isSelected: boolean;
|
||||
displayName: string;
|
||||
avatar: React.ReactElement;
|
||||
notificationState: INotificationState;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
export default class TemporaryTile extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onTileMouseEnter = () => {
|
||||
this.setState({hover: true});
|
||||
};
|
||||
|
||||
private onTileMouseLeave = () => {
|
||||
this.setState({hover: false});
|
||||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// XXX: We copy classes because it's easier
|
||||
const classes = classNames({
|
||||
'mx_RoomTile2': true,
|
||||
'mx_TemporaryTile': true,
|
||||
'mx_RoomTile2_selected': this.props.isSelected,
|
||||
'mx_RoomTile2_minimized': this.props.isMinimized,
|
||||
});
|
||||
|
||||
const badge = (
|
||||
<NotificationBadge
|
||||
notification={this.props.notificationState}
|
||||
forceCount={false}
|
||||
/>
|
||||
);
|
||||
|
||||
let name = this.props.displayName;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
const nameClasses = classNames({
|
||||
"mx_RoomTile2_name": true,
|
||||
"mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold,
|
||||
});
|
||||
|
||||
let nameContainer = (
|
||||
<div className="mx_RoomTile2_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (this.props.isMinimized) nameContainer = null;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
inputRef={ref}
|
||||
className={classes}
|
||||
onMouseEnter={this.onTileMouseEnter}
|
||||
onMouseLeave={this.onTileMouseLeave}
|
||||
onClick={this.props.onClick}
|
||||
role="treeitem"
|
||||
>
|
||||
<div className="mx_RoomTile2_avatarContainer">
|
||||
{this.props.avatar}
|
||||
</div>
|
||||
{nameContainer}
|
||||
<div className="mx_RoomTile2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -413,7 +413,7 @@ export default class SetIdServer extends React.Component {
|
|||
tooltipContent={this._getTooltip()}
|
||||
tooltipClassName="mx_SetIdServer_tooltip"
|
||||
disabled={this.state.busy}
|
||||
flagInvalid={!!this.state.error}
|
||||
forceValidity={this.state.error ? false : null}
|
||||
/>
|
||||
<AccessibleButton type="submit" kind="primary_sm"
|
||||
onClick={this._checkIdServer}
|
||||
|
|
|
@ -28,6 +28,8 @@ export default class NotificationsSettingsTab extends React.Component {
|
|||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
_soundUpload = createRef();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
|
@ -44,8 +46,6 @@ export default class NotificationsSettingsTab extends React.Component {
|
|||
return;
|
||||
}
|
||||
this.setState({currentSound: soundData.name || soundData.url});
|
||||
|
||||
this._soundUpload = createRef();
|
||||
}
|
||||
|
||||
async _triggerUploader(e) {
|
||||
|
|
|
@ -34,6 +34,7 @@ import SettingsFlag from '../../../elements/SettingsFlag';
|
|||
import Field from '../../../elements/Field';
|
||||
import EventTilePreview from '../../../elements/EventTilePreview';
|
||||
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
@ -288,10 +289,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
}))}
|
||||
onChange={this.onThemeChange}
|
||||
value={this.state.useSystemTheme ? undefined : this.state.theme}
|
||||
outlined
|
||||
/>
|
||||
</div>
|
||||
{customThemeForm}
|
||||
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} useCheckbox={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -343,8 +344,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Message layout")}</span>
|
||||
|
||||
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons" >
|
||||
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButton">
|
||||
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
|
||||
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.useIRCLayout,
|
||||
})}>
|
||||
<EventTilePreview
|
||||
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
|
||||
message={this.MESSAGE_PREVIEW_TEXT}
|
||||
|
@ -360,7 +363,9 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
</StyledRadioButton>
|
||||
</div>
|
||||
<div className="mx_AppearanceUserSettingsTab_spacer" />
|
||||
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButton">
|
||||
<div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: !this.state.useIRCLayout,
|
||||
})}>
|
||||
<EventTilePreview
|
||||
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
|
||||
message={this.MESSAGE_PREVIEW_TEXT}
|
||||
|
|
|
@ -23,6 +23,7 @@ import SettingsStore from "../../../../../settings/SettingsStore";
|
|||
import Field from "../../../elements/Field";
|
||||
import * as sdk from "../../../../..";
|
||||
import PlatformPeg from "../../../../../PlatformPeg";
|
||||
import {RoomListStoreTempProxy} from "../../../../../stores/room-list/RoomListStoreTempProxy";
|
||||
|
||||
export default class PreferencesUserSettingsTab extends React.Component {
|
||||
static ROOM_LIST_SETTINGS = [
|
||||
|
@ -31,6 +32,19 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
|||
'breadcrumbs',
|
||||
];
|
||||
|
||||
// TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231
|
||||
static ROOM_LIST_2_SETTINGS = [
|
||||
'breadcrumbs',
|
||||
];
|
||||
|
||||
// TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231
|
||||
static eligibleRoomListSettings = () => {
|
||||
if (RoomListStoreTempProxy.isUsingNewStore()) {
|
||||
return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS;
|
||||
}
|
||||
return PreferencesUserSettingsTab.ROOM_LIST_SETTINGS;
|
||||
};
|
||||
|
||||
static COMPOSER_SETTINGS = [
|
||||
'MessageComposerInput.autoReplaceEmoji',
|
||||
'MessageComposerInput.suggestEmoji',
|
||||
|
@ -175,7 +189,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
|
|||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
|
||||
{this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
|
||||
{this._renderGroup(PreferencesUserSettingsTab.eligibleRoomListSettings())}
|
||||
</div>
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
|
|
53
src/components/views/toasts/GenericExpiringToast.tsx
Normal file
53
src/components/views/toasts/GenericExpiringToast.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
Copyright 2020 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 ToastStore from "../../../stores/ToastStore";
|
||||
import GenericToast, { IProps as IGenericToastProps } from "./GenericToast";
|
||||
import {useExpiringCounter} from "../../../hooks/useTimeout";
|
||||
|
||||
interface IProps extends IGenericToastProps {
|
||||
toastKey: string;
|
||||
numSeconds: number;
|
||||
dismissLabel: string;
|
||||
onDismiss?();
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
|
||||
const GenericExpiringToast: React.FC<IProps> = ({description, acceptLabel, dismissLabel, onAccept, onDismiss, toastKey, numSeconds}) => {
|
||||
const onReject = () => {
|
||||
if (onDismiss) onDismiss();
|
||||
ToastStore.sharedInstance().dismissToast(toastKey);
|
||||
};
|
||||
const counter = useExpiringCounter(onReject, SECOND, numSeconds);
|
||||
|
||||
let rejectLabel = dismissLabel;
|
||||
if (counter > 0) {
|
||||
rejectLabel += ` (${counter})`;
|
||||
}
|
||||
|
||||
return <GenericToast
|
||||
description={description}
|
||||
acceptLabel={acceptLabel}
|
||||
onAccept={onAccept}
|
||||
rejectLabel={rejectLabel}
|
||||
onReject={onReject}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default GenericExpiringToast;
|
|
@ -19,7 +19,7 @@ import React, {ReactChild} from "react";
|
|||
import FormButton from "../elements/FormButton";
|
||||
import {XOR} from "../../../@types/common";
|
||||
|
||||
interface IProps {
|
||||
export interface IProps {
|
||||
description: ReactChild;
|
||||
acceptLabel: string;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue