Merge branch 'develop' into travis/room-list/tag-panel

pull/21833/head
Travis Ralston 2020-06-26 07:26:57 -06:00
commit 3abc9d8d56
24 changed files with 520 additions and 490 deletions

View File

@ -428,6 +428,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
border-radius: 8px;
padding: 0px;
box-shadow: none;
/* Don't show scroll-bars on spinner dialogs */
overflow-x: hidden;
overflow-y: hidden;
}
// TODO: Review mx_GeneralButton usage to see if it can use a different class

View File

@ -30,7 +30,7 @@
@import "./structures/_ToastContainer.scss";
@import "./structures/_TopLeftMenuButton.scss";
@import "./structures/_UploadBar.scss";
@import "./structures/_UserMenuButton.scss";
@import "./structures/_UserMenu.scss";
@import "./structures/_ViewSource.scss";
@import "./structures/auth/_CompleteSecurity.scss";
@import "./structures/auth/_Login.scss";

View File

@ -60,7 +60,7 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
display: flex;
flex-direction: column;
// There's 2 rows when breadcrumbs are present: the top bit and the breadcrumbs
// This is basically just breadcrumbs. The row above that is handled by the UserMenu
.mx_LeftPanel2_headerRow {
// Create yet another flexbox, this time within the row, to ensure items stay
// aligned correctly. This is also a row-based flexbox.
@ -68,32 +68,6 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
align-items: center;
}
.mx_LeftPanel2_userAvatarContainer {
position: relative; // to make default avatars work
margin-right: 8px;
height: 32px; // to remove the unknown 4px gap the browser puts below it
.mx_LeftPanel2_userAvatar {
border-radius: 32px; // should match avatar size
}
}
.mx_LeftPanel2_userName {
font-weight: 600;
font-size: $font-15px;
line-height: $font-20px;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_LeftPanel2_headerButtons {
// No special styles: the rest of the layout happens to make it work.
}
.mx_LeftPanel2_breadcrumbsContainer {
width: 100%;
overflow: hidden;
@ -169,16 +143,6 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
.mx_LeftPanel2_roomListContainer {
width: 68px;
.mx_LeftPanel2_userHeader {
.mx_LeftPanel2_headerRow {
justify-content: center;
}
.mx_LeftPanel2_userAvatarContainer {
margin-right: 0;
}
}
.mx_LeftPanel2_filterContainer {
// Organize the flexbox into a centered column layout
flex-direction: column;

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_UserMenuButton {
> span {
.mx_UserMenu {
.mx_UserMenu_headerButtons {
width: 16px;
height: 16px;
position: relative;
@ -35,12 +35,56 @@ limitations under the License.
mask-image: url('$(res)/img/feather-customised/more-horizontal.svg');
}
}
.mx_UserMenu_row {
// Create a row-based flexbox to ensure items stay aligned correctly.
display: flex;
align-items: center;
.mx_UserMenu_userAvatarContainer {
position: relative; // to make default avatars work
margin-right: 8px;
height: 32px; // to remove the unknown 4px gap the browser puts below it
.mx_UserMenu_userAvatar {
border-radius: 32px; // should match avatar size
}
}
.mx_UserMenu_userName {
font-weight: 600;
font-size: $font-15px;
line-height: $font-20px;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_UserMenu_headerButtons {
// No special styles: the rest of the layout happens to make it work.
}
}
&.mx_UserMenu_minimized {
.mx_UserMenu_userHeader {
.mx_UserMenu_row {
justify-content: center;
}
.mx_UserMenu_userAvatarContainer {
margin-right: 0;
}
}
}
}
.mx_UserMenuButton_contextMenu {
.mx_UserMenu_contextMenu {
width: 247px;
.mx_UserMenuButton_contextMenu_redRow {
.mx_UserMenu_contextMenu_redRow {
.mx_AccessibleButton {
color: $warning-color !important; // !important to override styles from context menu
}
@ -50,12 +94,12 @@ limitations under the License.
}
}
.mx_UserMenuButton_contextMenu_header {
.mx_UserMenu_contextMenu_header {
// Create a flexbox to organize the header a bit easier
display: flex;
align-items: center;
.mx_UserMenuButton_contextMenu_name {
.mx_UserMenu_contextMenu_name {
// Create another flexbox of columns to handle large user IDs
display: flex;
flex-direction: column;
@ -72,19 +116,19 @@ limitations under the License.
white-space: nowrap;
}
.mx_UserMenuButton_contextMenu_displayName {
.mx_UserMenu_contextMenu_displayName {
font-weight: bold;
font-size: $font-15px;
line-height: $font-20px;
}
.mx_UserMenuButton_contextMenu_userId {
.mx_UserMenu_contextMenu_userId {
font-size: $font-15px;
line-height: $font-24px;
}
}
.mx_UserMenuButton_contextMenu_themeButton {
.mx_UserMenu_contextMenu_themeButton {
min-width: 32px;
max-width: 32px;
width: 32px;
@ -118,31 +162,31 @@ limitations under the License.
}
}
.mx_UserMenuButton_iconHome::before {
.mx_UserMenu_iconHome::before {
mask-image: url('$(res)/img/feather-customised/home.svg');
}
.mx_UserMenuButton_iconBell::before {
.mx_UserMenu_iconBell::before {
mask-image: url('$(res)/img/feather-customised/notifications.svg');
}
.mx_UserMenuButton_iconLock::before {
.mx_UserMenu_iconLock::before {
mask-image: url('$(res)/img/feather-customised/lock.svg');
}
.mx_UserMenuButton_iconSettings::before {
.mx_UserMenu_iconSettings::before {
mask-image: url('$(res)/img/feather-customised/settings.svg');
}
.mx_UserMenuButton_iconArchive::before {
.mx_UserMenu_iconArchive::before {
mask-image: url('$(res)/img/feather-customised/archive.svg');
}
.mx_UserMenuButton_iconMessage::before {
.mx_UserMenu_iconMessage::before {
mask-image: url('$(res)/img/feather-customised/message-circle.svg');
}
.mx_UserMenuButton_iconSignOut::before {
.mx_UserMenu_iconSignOut::before {
mask-image: url('$(res)/img/feather-customised/sign-out.svg');
}
}

View File

@ -18,7 +18,7 @@ limitations under the License.
display: inline;
}
.mx_InlineSpinner img {
.mx_InlineSpinner_spin img {
margin: 0px 6px;
vertical-align: -3px;
}

View File

@ -23,6 +23,16 @@ limitations under the License.
flex: 1;
}
.mx_Spinner_spin img {
animation: spin 1s linear infinite;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.mx_MatrixChat_middlePanel .mx_Spinner {
height: auto;
}

3
res/img/spinner.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.60236 3.67346C3.10764 5.59313 1.5 8.60882 1.5 12C1.5 17.799 6.20101 22.5 12 22.5C17.799 22.5 22.5 17.799 22.5 12C22.5 8.6452 20.9267 5.65787 18.4776 3.73562L17.7648 4.44842C20.0354 6.18437 21.5 8.92114 21.5 12C21.5 17.2467 17.2467 21.5 12 21.5C6.75329 21.5 2.5 17.2467 2.5 12C2.5 8.88471 3.9995 6.11966 6.31612 4.38722L5.60236 3.67346Z" fill="#03b381"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@ -25,8 +25,8 @@ import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload";
import {Action} from "./dispatcher/actions";
import {hideToast as hideUpdateToast} from "./toasts/UpdateToast";
export const HOMESERVER_URL_KEY = "mx_hs_url";
export const ID_SERVER_URL_KEY = "mx_is_url";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
export enum UpdateCheckStatus {
Checking = "CHECKING",
@ -221,7 +221,7 @@ export default abstract class BasePlatform {
setLanguage(preferredLangs: string[]) {}
getSSOCallbackUrl(fragmentAfterLogin: string): URL {
protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || "";
return url;
@ -235,9 +235,9 @@ export default abstract class BasePlatform {
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) {
// persist hs url and is url for when the user is returned to the app with the login token
localStorage.setItem(HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
if (mxClient.getIdentityServerUrl()) {
localStorage.setItem(ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO

View File

@ -41,7 +41,10 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers";
import {Mjolnir} from "./mjolnir/Mjolnir";
import DeviceListener from "./DeviceListener";
import {Jitsi} from "./widgets/Jitsi";
import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "./BasePlatform";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url";
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -164,8 +167,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
return Promise.resolve(false);
}
const homeserver = localStorage.getItem(HOMESERVER_URL_KEY);
const identityServer = localStorage.getItem(ID_SERVER_URL_KEY);
const homeserver = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY);
if (!homeserver) {
console.warn("Cannot log in with token: can't determine HS URL to use");
return Promise.resolve(false);

View File

@ -22,18 +22,13 @@ import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList2 from "../views/rooms/RoomList2";
import { Action } from "../../dispatcher/actions";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseAvatar from '../views/avatars/BaseAvatar';
import UserMenuButton from "./UserMenuButton";
import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch";
import AccessibleButton from "../views/elements/AccessibleButton";
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { throttle } from 'lodash';
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import SettingsStore from "../../settings/SettingsStore";
/*******************************************************************
@ -83,33 +78,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
// We watch the middle panel because we don't actually get resized, the middle panel does.
// We listen to the noisy channel to avoid choppy reaction times.
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize);
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
}
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
}
// TSLint wants this to be a member, but we don't want that.
// tslint:disable-next-line
private onRoomStateUpdate = throttle((ev: MatrixEvent) => {
const myUserId = MatrixClientPeg.get().getUserId();
if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) {
// noinspection JSIgnoredPromiseFromCall
this.onProfileUpdate();
}
}, 200, {trailing: true, leading: true});
private onProfileUpdate = async () => {
// the store triggered an update, so force a layout update. We don't
// have any state to store here for that to magically happen.
this.forceUpdate();
};
private onSearch = (term: string): void => {
this.setState({searchFilter: term});
};
@ -178,7 +154,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
// TODO: Presence
// TODO: Breadcrumbs toggle
// TODO: Menu button
const avatarSize = 32; // should match border-radius of the avatar
let breadcrumbs;
if (this.state.showBreadcrumbs) {
@ -189,34 +164,9 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
);
}
let name = <span className="mx_LeftPanel2_userName">{OwnProfileStore.instance.displayName}</span>;
let buttons = (
<span className="mx_LeftPanel2_headerButtons">
<UserMenuButton />
</span>
);
if (this.props.isMinimized) {
name = null;
buttons = null;
}
return (
<div className="mx_LeftPanel2_userHeader">
<div className="mx_LeftPanel2_headerRow">
<span className="mx_LeftPanel2_userAvatarContainer">
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId()}
url={OwnProfileStore.instance.getHttpAvatarUrl(avatarSize)}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_LeftPanel2_userAvatar"
/>
</span>
{name}
{buttons}
</div>
<UserMenu isMinimized={this.props.isMinimized} />
{breadcrumbs}
</div>
);

View File

@ -18,6 +18,8 @@ limitations under the License.
*/
import React, { createRef } from 'react';
// @ts-ignore - XXX: no idea why this import fails
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";
@ -1612,6 +1614,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
} else if (screen === 'directory') {
dis.fire(Action.ViewRoomDirectory);
} else if (screen === "start_sso" || screen === "start_cas") {
// TODO if logged in, skip SSO
let cli = MatrixClientPeg.get();
if (!cli) {
const {hsUrl, isUrl} = this.props.serverConfig;
cli = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
}
const type = screen === "start_sso" ? "sso" : "cas";
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
} else if (screen === 'groups') {
dis.dispatch({
action: 'view_my_groups',
@ -1915,9 +1930,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn();
};
render() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
getFragmentAfterLogin() {
let fragmentAfterLogin = "";
if (this.props.initialScreenAfterLogin &&
// XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop
@ -1925,7 +1938,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
) {
fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`;
}
return fragmentAfterLogin;
}
render() {
const fragmentAfterLogin = this.getFragmentAfterLogin();
let view;
if (this.state.view === Views.LOADING) {
@ -2004,7 +2021,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
} else if (this.state.view === Views.WELCOME) {
const Welcome = sdk.getComponent('auth.Welcome');
view = <Welcome {...this.getServerProperties()} fragmentAfterLogin={fragmentAfterLogin} />;
view = <Welcome />;
} else if (this.state.view === Views.REGISTER) {
const Registration = sdk.getComponent('structures.auth.Registration');
view = (

View File

@ -0,0 +1,332 @@
/*
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 * as React from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { createRef } from "react";
import { _t } from "../../languageHandler";
import {ContextMenu, ContextMenuButton} 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";
import Modal from "../../Modal";
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 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";
interface IProps {
isMinimized: boolean;
}
interface IState {
menuDisplayed: boolean;
isDarkTheme: boolean;
}
export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
menuDisplayed: false,
isDarkTheme: this.isUserOnDarkTheme(),
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
}
private get hasHomePage(): boolean {
return !!getHomePageUrl(SdkConfig.get());
}
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
}
public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
}
private isUserOnDarkTheme(): boolean {
const theme = SettingsStore.getValue("theme");
if (theme.startsWith("custom-")) {
return getCustomTheme(theme.substring("custom-".length)).is_dark;
}
return theme === "dark";
}
private onProfileUpdate = async () => {
// the store triggered an update, so force a layout update. We don't
// have any state to store here for that to magically happen.
this.forceUpdate();
};
private onThemeChanged = () => {
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
};
private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested
// For accessibility
if (this.buttonRef.current) this.buttonRef.current.click();
};
private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({menuDisplayed: true});
};
private onCloseMenu = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({menuDisplayed: false});
};
private onSwitchThemeClick = () => {
// Disable system theme matching if the user hits this button
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
const newTheme = this.state.isDarkTheme ? "light" : "dark";
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
};
private onSettingsOpen = (ev: ButtonEvent, tabId: string) => {
ev.preventDefault();
ev.stopPropagation();
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
defaultDispatcher.dispatch(payload);
this.setState({menuDisplayed: false}); // also close the menu
};
private onShowArchived = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
// TODO: Archived room view (deferred)
console.log("TODO: Show archived rooms");
};
private onProvideFeedback = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
this.setState({menuDisplayed: false}); // also close the menu
};
private onSignOutClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
this.setState({menuDisplayed: false}); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({action: 'view_home_page'});
};
private renderContextMenu = (): React.ReactNode => {
if (!this.state.menuDisplayed) return null;
let hostingLink;
const signupLink = getHostingLink("user-context-menu");
if (signupLink) {
hostingLink = (
<div className="mx_UserMenu_contextMenu_header">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</div>
);
}
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>
);
}
const elementRect = this.buttonRef.current.getBoundingClientRect();
return (
<ContextMenu
chevronFace="none"
left={elementRect.width + elementRect.left}
top={elementRect.top + elementRect.height}
onFinished={this.onCloseMenu}
>
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenu_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
</span>
</div>
<div
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/feather-customised/sun.svg")}
alt={_t("Switch theme")}
width={16}
/>
</div>
</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>
</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>
</div>
</ContextMenu>
);
};
public render() {
const avatarSize = 32; // should match border-radius of the avatar
let name = <span className="mx_UserMenu_userName">{OwnProfileStore.instance.displayName}</span>;
let buttons = (
<span className="mx_UserMenu_headerButtons">
{/* masked image in CSS */}
</span>
);
if (this.props.isMinimized) {
name = null;
buttons = null;
}
const classes = classNames({
'mx_UserMenu': true,
'mx_UserMenu_minimized': this.props.isMinimized,
});
return (
<React.Fragment>
<ContextMenuButton
className={classes}
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("Account settings")}
isExpanded={this.state.menuDisplayed}
>
<div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer">
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId()}
url={OwnProfileStore.instance.getHttpAvatarUrl(avatarSize)}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_UserMenu_userAvatar"
/>
</span>
{name}
{buttons}
</div>
{this.renderContextMenu()}
</ContextMenuButton>
</React.Fragment>
);
}
}

View File

@ -1,294 +0,0 @@
/*
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 * as React from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { createRef } from "react";
import { _t } from "../../languageHandler";
import {ContextMenu, ContextMenuButton} 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";
import Modal from "../../Modal";
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 SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
interface IProps {
}
interface IState {
menuDisplayed: boolean;
isDarkTheme: boolean;
}
export default class UserMenuButton extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
menuDisplayed: false,
isDarkTheme: this.isUserOnDarkTheme(),
};
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
}
private get hasHomePage(): boolean {
return !!getHomePageUrl(SdkConfig.get());
}
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
}
public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
}
private isUserOnDarkTheme(): boolean {
const theme = SettingsStore.getValue("theme");
if (theme.startsWith("custom-")) {
return getCustomTheme(theme.substring("custom-".length)).is_dark;
}
return theme === "dark";
}
private onProfileUpdate = async () => {
// the store triggered an update, so force a layout update. We don't
// have any state to store here for that to magically happen.
this.forceUpdate();
};
private onThemeChanged = () => {
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
};
private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested
// For accessibility
if (this.buttonRef.current) this.buttonRef.current.click();
};
private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({menuDisplayed: true});
};
private onCloseMenu = () => {
this.setState({menuDisplayed: false});
};
private onSwitchThemeClick = () => {
// Disable system theme matching if the user hits this button
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
const newTheme = this.state.isDarkTheme ? "light" : "dark";
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
};
private onSettingsOpen = (ev: ButtonEvent, tabId: string) => {
ev.preventDefault();
ev.stopPropagation();
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
defaultDispatcher.dispatch(payload);
this.setState({menuDisplayed: false}); // also close the menu
};
private onShowArchived = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
// TODO: Archived room view (deferred)
console.log("TODO: Show archived rooms");
};
private onProvideFeedback = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
this.setState({menuDisplayed: false}); // also close the menu
};
private onSignOutClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
this.setState({menuDisplayed: false}); // also close the menu
};
private onHomeClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({action: 'view_home_page'});
};
public render() {
let contextMenu;
if (this.state.menuDisplayed) {
let hostingLink;
const signupLink = getHostingLink("user-context-menu");
if (signupLink) {
hostingLink = (
<div className="mx_UserMenuButton_contextMenu_header">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</div>
);
}
let homeButton = null;
if (this.hasHomePage) {
homeButton = (
<li>
<AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconHome" />
<span>{_t("Home")}</span>
</AccessibleButton>
</li>
);
}
const elementRect = this.buttonRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu
chevronFace="none"
left={elementRect.left}
top={elementRect.top + elementRect.height}
onFinished={this.onCloseMenu}
>
<div className="mx_IconizedContextMenu mx_UserMenuButton_contextMenu">
<div className="mx_UserMenuButton_contextMenu_header">
<div className="mx_UserMenuButton_contextMenu_name">
<span className="mx_UserMenuButton_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenuButton_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
</span>
</div>
<div
className="mx_UserMenuButton_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/feather-customised/sun.svg")}
alt={_t("Switch theme")}
width={16}
/>
</div>
</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_UserMenuButton_iconBell" />
<span>{_t("Notification settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconLock" />
<span>{_t("Security & privacy")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconSettings" />
<span>{_t("All settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconArchive" />
<span>{_t("Archived rooms")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconMessage" />
<span>{_t("Feedback")}</span>
</AccessibleButton>
</li>
</ul>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li className="mx_UserMenuButton_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenuButton_iconSignOut" />
<span>{_t("Sign out")}</span>
</AccessibleButton>
</li>
</ul>
</div>
</div>
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuButton
className="mx_UserMenuButton"
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("Account settings")}
isExpanded={this.state.menuDisplayed}
>
<span>{/* masked image in CSS */}</span>
</ContextMenuButton>
{contextMenu}
</React.Fragment>
);
}
}

View File

@ -25,7 +25,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import SSOButton from "../../views/elements/SSOButton";
import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "../../../BasePlatform";
import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform";
const LOGIN_VIEW = {
LOADING: 1,
@ -158,8 +158,8 @@ export default class SoftLogout extends React.Component {
async trySsoLogin() {
this.setState({busy: true});
const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY);
const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl();
const loginType = "m.login.token";
const loginParams = {
token: this.props.realQueryParams['loginToken'],

View File

@ -18,9 +18,7 @@ import React from 'react';
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import AuthPage from "./AuthPage";
import * as Matrix from "matrix-js-sdk";
import {_td} from "../../../languageHandler";
import PlatformPeg from "../../../PlatformPeg";
// translatable strings for Welcome pages
_td("Sign in with SSO");
@ -39,15 +37,6 @@ export default class Welcome extends React.PureComponent {
pageUrl = 'welcome.html';
}
const {hsUrl, isUrl} = this.props.serverConfig;
const tmpClient = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
const plaf = PlatformPeg.get();
const callbackUrl = plaf.getSSOCallbackUrl(tmpClient.getHomeserverUrl(), tmpClient.getIdentityServerUrl(),
this.props.fragmentAfterLogin);
return (
<AuthPage>
<div className="mx_Welcome">
@ -55,8 +44,8 @@ export default class Welcome extends React.PureComponent {
className="mx_WelcomePage"
url={pageUrl}
replaceMap={{
"$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"),
"$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"),
"$riot:ssoUrl": "#/start_sso",
"$riot:casUrl": "#/start_cas",
}}
/>
<LanguageSelector />

View File

@ -29,7 +29,7 @@ import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner';
import Spinner from './Spinner';
import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
@ -740,7 +740,7 @@ export default class AppTile extends React.Component {
if (this.props.show) {
const loadingElement = (
<div className="mx_AppLoading_spinner_fadeIn">
<MessageSpinner msg='Loading...' />
<Spinner message={_t("Loading...")} />
</div>
);
if (!this.state.hasPermissionToLoad) {

View File

@ -16,6 +16,8 @@ limitations under the License.
import React from "react";
import createReactClass from 'create-react-class';
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
export default createReactClass({
displayName: 'InlineSpinner',
@ -25,9 +27,25 @@ export default createReactClass({
const h = this.props.h || 16;
const imgClass = this.props.imgClassName || "";
let divClass;
let imageSource;
if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
divClass = "mx_InlineSpinner mx_Spinner_spin";
imageSource = require("../../../../res/img/spinner.svg");
} else {
divClass = "mx_InlineSpinner";
imageSource = require("../../../../res/img/spinner.gif");
}
return (
<div className="mx_InlineSpinner">
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} />
<div className={divClass}>
<img
src={imageSource}
width={w}
height={h}
className={imgClass}
aria-label={_t("Loading...")}
/>
</div>
);
},

View File

@ -1,35 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
export default createReactClass({
displayName: 'MessageSpinner',
render: function() {
const w = this.props.w || 32;
const h = this.props.h || 32;
const imgClass = this.props.imgClassName || "";
const msg = this.props.msg || "Loading...";
return (
<div className="mx_Spinner">
<div className="mx_Spinner_Msg">{ msg }</div>&nbsp;
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} />
</div>
);
},
});

View File

@ -16,19 +16,39 @@ limitations under the License.
*/
import React from "react";
import createReactClass from 'create-react-class';
import PropTypes from "prop-types";
import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
export default createReactClass({
displayName: 'Spinner',
const Spinner = ({w = 32, h = 32, imgClassName, message}) => {
let divClass;
let imageSource;
if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
divClass = "mx_Spinner mx_Spinner_spin";
imageSource = require("../../../../res/img/spinner.svg");
} else {
divClass = "mx_Spinner";
imageSource = require("../../../../res/img/spinner.gif");
}
render: function() {
const w = this.props.w || 32;
const h = this.props.h || 32;
const imgClass = this.props.imgClassName || "";
return (
<div className="mx_Spinner">
<img src={require("../../../../res/img/spinner.gif")} width={w} height={h} className={imgClass} />
</div>
);
},
});
return (
<div className={divClass}>
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message}</div>&nbsp;</React.Fragment> }
<img
src={imageSource}
width={w}
height={h}
className={imgClassName}
aria-label={_t("Loading...")}
/>
</div>
);
};
Spinner.propTypes = {
w: PropTypes.number,
h: PropTypes.number,
imgClassName: PropTypes.string,
message: PropTypes.node,
};
export default Spinner;

View File

@ -22,6 +22,7 @@ import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import InlineSpinner from '../elements/InlineSpinner';
export default class MAudioBody extends React.Component {
constructor(props) {
@ -94,7 +95,7 @@ export default class MAudioBody extends React.Component {
// Not sure how tall the audio player is so not sure how tall it should actually be.
return (
<span className="mx_MAudioBody">
<img src={require("../../../../res/img/spinner.gif")} alt={content.body} width="16" height="16" />
<InlineSpinner />
</span>
);
}

View File

@ -26,6 +26,7 @@ import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import InlineSpinner from '../elements/InlineSpinner';
export default class MImageBody extends React.Component {
static propTypes = {
@ -365,12 +366,7 @@ export default class MImageBody extends React.Component {
// e2e image hasn't been decrypted yet
if (content.file !== undefined && this.state.decryptedUrl === null) {
placeholder = <img
src={require("../../../../res/img/spinner.gif")}
alt={content.body}
width="32"
height="32"
/>;
placeholder = <InlineSpinner w={32} h={32} />;
} else if (!this.state.imgLoaded) {
// Deliberately, getSpinner is left unimplemented here, MStickerBody overides
placeholder = this.getPlaceholder();

View File

@ -23,6 +23,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner';
export default createReactClass({
displayName: 'MVideoBody',
@ -147,7 +148,7 @@ export default createReactClass({
return (
<span className="mx_MVideoBody">
<div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner">
<img src={require("../../../../res/img/spinner.gif")} alt={content.body} width="16" height="16" />
<InlineSpinner />
</div>
</span>
);

View File

@ -427,6 +427,7 @@
"Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.",
"Please contact your homeserver administrator.": "Please contact your homeserver administrator.",
"Failed to join room": "Failed to join room",
"New spinner design": "New spinner design",
"Font scaling": "Font scaling",
"Message Pinning": "Message Pinning",
"Custom user status messages": "Custom user status messages",

View File

@ -97,6 +97,12 @@ export const SETTINGS = {
// // not use this for new settings.
// invertedSettingName: "my-negative-setting",
// },
"feature_new_spinner": {
isFeature: true,
displayName: _td("New spinner design"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_font_scaling": {
isFeature: true,
displayName: _td("Font scaling"),