Merge branch 'develop' into travis/room-list/preview-copy

pull/21833/head
Travis Ralston 2020-06-26 07:29:49 -06:00
commit 67cc84d00d
46 changed files with 1035 additions and 695 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
@ -596,14 +600,14 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
}
&:last-child {
padding-bottom: 20px;
padding-bottom: 16px;
}
}
.mx_IconizedContextMenu_optionList {
// the notFirst class is for cases where the optionList might be under a header of sorts.
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
margin-top: 20px;
margin-top: 12px;
// This is a bit of a hack when we could just use a simple border-top property,
// however we have a (kinda) good reason for doing it this way: we need opacity.
@ -634,7 +638,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
li {
margin: 0;
padding: 20px 0 0;
padding: 12px 0 0;
.mx_AccessibleButton {
text-decoration: none;

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

@ -38,6 +38,12 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
// TagPanel handles its own CSS
}
&:not(.mx_LeftPanel2_hasTagPanel) {
.mx_LeftPanel2_roomListContainer {
width: 100%;
}
}
// Note: The 'room list' in this context is actually everything that isn't the tag
// panel, such as the menu options, breadcrumbs, filtering, etc
.mx_LeftPanel2_roomListContainer {
@ -48,13 +54,13 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
flex-direction: column;
.mx_LeftPanel2_userHeader {
padding: 14px 12px 20px; // 14px top, 12px sides, 20px bottom
padding: 12px 12px 20px; // 12px top, 12px sides, 20px bottom
// Create another flexbox column for the rows to stack within
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.
@ -62,31 +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;
.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;
@ -152,21 +133,16 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
min-width: unset;
// We have to forcefully set the width to override the resizer's style attribute.
width: calc(68px + $tagPanelWidth) !important;
&.mx_LeftPanel2_hasTagPanel {
width: calc(68px + $tagPanelWidth) !important;
}
&:not(.mx_LeftPanel2_hasTagPanel) {
width: 68px !important;
}
.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,22 +35,71 @@ 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_header {
.mx_UserMenu_contextMenu_redRow {
.mx_AccessibleButton {
color: $warning-color !important; // !important to override styles from context menu
}
.mx_IconizedContextMenu_icon::before {
background-color: $warning-color;
}
}
.mx_UserMenu_contextMenu_header {
// Create a flexbox to organize the header a bit easier
display: flex;
align-items: center;
&:nth-child(n + 1) {
// The first header will have appropriate padding, subsequent ones need a margin.
margin-top: 10px;
}
.mx_UserMenuButton_contextMenu_name {
.mx_UserMenu_contextMenu_name {
// Create another flexbox of columns to handle large user IDs
display: flex;
flex-direction: column;
@ -67,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;
@ -105,6 +154,7 @@ limitations under the License.
content: '';
width: 16px;
height: 16px;
display: block;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
@ -112,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;
}

View File

@ -77,8 +77,8 @@ limitations under the License.
}
&:checked:disabled + label > .mx_Checkbox_background {
background-color: $muted-fg-color;
border-color: rgba($muted-fg-color, 0.5);
background-color: $accent-color;
border-color: $accent-color;
}
}
}

View File

@ -85,23 +85,30 @@ limitations under the License.
// ***************************
.mx_RoomSublist2_badgeContainer {
opacity: 0.8;
width: 16px;
margin-right: 5px; // aligns with the room tile's badge
// Create another flexbox row because it's super easy to position the badge this way.
display: flex;
align-items: center;
justify-content: center;
// Apply the width and margin to the badge so the container doesn't occupy dead space
.mx_NotificationBadge {
width: 16px;
margin-left: 8px; // same as menu+aux buttons
}
}
&:not(.mx_RoomSublist2_headerContainer_withAux) {
.mx_NotificationBadge {
margin-right: 4px; // just to push it over a bit, aligning it with the other elements
}
}
// Both of these buttons are hidden by default until the list is hovered
.mx_RoomSublist2_auxButton,
.mx_RoomSublist2_menuButton {
width: 0;
margin: 0;
visibility: hidden;
margin-left: 8px; // should be the same as the notification badge
position: relative;
width: 24px;
height: 24px;
border-radius: 32px;
&::before {
@ -118,6 +125,13 @@ limitations under the License.
}
}
// Hide the menu button by default
.mx_RoomSublist2_menuButton {
visibility: hidden;
width: 0;
margin: 0;
}
.mx_RoomSublist2_auxButton::before {
mask-image: url('$(res)/img/feather-customised/plus.svg');
}
@ -130,9 +144,9 @@ limitations under the License.
flex: 1;
max-width: calc(100% - 16px); // 16px is the badge width
text-transform: uppercase;
opacity: 0.5;
line-height: $font-16px;
font-size: $font-12px;
font-weight: 600;
// Ellipsize any text overflow
text-overflow: ellipsis;
@ -142,11 +156,9 @@ limitations under the License.
.mx_RoomSublist2_collapseBtn {
display: inline-block;
position: relative;
// Default hidden
visibility: hidden;
width: 0;
height: 0;
width: 12px;
height: 12px;
margin-right: 8px;
&::before {
content: '';
@ -158,7 +170,7 @@ limitations under the License.
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $primary-fg-color;
background-color: $primary-fg-color;
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
}
@ -226,6 +238,16 @@ limitations under the License.
.mx_RoomSublist2_showLessButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-up.svg');
}
&.mx_RoomSublist2_isCutting::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08);
}
}
// Class name comes from the ResizableBox component
@ -233,69 +255,34 @@ limitations under the License.
// so that selector is below and one level higher.
.react-resizable-handle {
cursor: ns-resize;
border-radius: 2px;
border-radius: 3px;
// Update RESIZE_HANDLE_HEIGHT if this changes
height: 4px;
// This is positioned directly below the 'show more' button.
position: absolute;
bottom: 0;
left: 0;
right: 0;
// This is to visually align the bar in the list. Should be 12px from
// either side of the list. We define this after the positioning to
// trick the browser.
margin-left: 4px;
margin-right: 4px;
// Together, these make the bar 64px wide
left: calc(50% - 32px);
right: calc(50% - 32px);
}
&:hover, &.mx_RoomSublist2_hasMenuOpen {
.react-resizable-handle {
opacity: 0.8;
background-color: $primary-fg-color;
}
}
}
// The aforementioned selector for the hover state.
&:hover, &.mx_RoomSublist2_hasMenuOpen {
.react-resizable-handle {
opacity: 0.2;
// Update the render() function for RoomSublist2 if this changes
border: 2px solid $primary-fg-color;
}
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer {
// If the header doesn't have an aux button we still need to hide the badge for
// the menu button.
.mx_RoomSublist2_badgeContainer {
// Completely hide the badge
width: 0;
margin: 0;
visibility: hidden;
}
&:not(.mx_RoomSublist2_headerContainer_withAux) {
// The menu button will be the rightmost button, so make it correctly aligned.
.mx_RoomSublist2_menuButton {
margin-right: 1px; // line it up with the badges on the room tiles
}
}
// Both of these buttons have circled backgrounds and are visible at this point,
// so make them so.
.mx_RoomSublist2_auxButton,
.mx_RoomSublist2_menuButton {
width: 24px;
height: 24px;
margin-left: 16px;
visibility: visible;
background-color: $roomlist2-button-bg-color;
}
}
.mx_RoomSublist2_headerContainer {
.mx_RoomSublist2_headerText {
.mx_RoomSublist2_collapseBtn {
visibility: visible;
width: 12px;
height: 12px;
margin-right: 4px;
}
}
&.mx_RoomSublist2_hasMenuOpen,
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover {
.mx_RoomSublist2_menuButton {
visibility: visible;
width: 24px;
margin-left: 8px;
}
}
@ -344,7 +331,12 @@ limitations under the License.
}
}
&:hover, &.mx_RoomSublist2_hasMenuOpen {
.mx_RoomSublist2_menuButton {
height: 16px;
}
&.mx_RoomSublist2_hasMenuOpen,
& > .mx_RoomSublist2_headerContainer:hover {
.mx_RoomSublist2_menuButton {
visibility: visible;
position: absolute;
@ -365,7 +357,7 @@ limitations under the License.
}
}
.mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) {
&.mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) {
.mx_RoomSublist2_menuButton {
bottom: 8px; // align to the middle of name, 40px less than the `bottom` above.
}
@ -374,27 +366,6 @@ limitations under the License.
}
}
// We have a hover style on the room list with no specific list hovered, so account for that
.mx_RoomList2:hover .mx_RoomSublist2:not(.mx_RoomSublist2_minimized),
.mx_RoomSublist2_hasMenuOpen:not(.mx_RoomSublist2_minimized) {
.mx_RoomSublist2_headerContainer_withAux {
.mx_RoomSublist2_badgeContainer {
// Completely hide the badge
width: 0;
margin: 0;
visibility: hidden;
}
.mx_RoomSublist2_auxButton {
// Show the aux button, but not the list button
width: 24px;
height: 24px;
margin-right: 1px; // line it up with the badges on the room tiles
visibility: visible;
}
}
}
.mx_RoomSublist2_contextMenu {
padding: 20px 16px;
width: 250px;
@ -404,6 +375,7 @@ limitations under the License.
margin-bottom: 16px;
margin-right: 16px; // additional 16px
border: 1px solid $roomsublist2-divider-color;
opacity: 0.1;
}
.mx_RoomSublist2_contextMenu_title {

View File

@ -209,9 +209,15 @@ limitations under the License.
}
.mx_AppearanceUserSettingsTab_Advanced {
color: $primary-fg-color;
> * {
margin-bottom: 16px;
}
.mx_AppearanceUserSettingsTab_AdvancedToggle {
color: $accent-color;
margin-bottom: 16px;
cursor: pointer;
}
.mx_AppearanceUserSettingsTab_systemFont {

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

@ -113,7 +113,7 @@ $theme-button-bg-color: #e3e8f0;
$roomlist2-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons
$roomlist2-bg-color: $header-panel-bg-color;
$roomsublist2-divider-color: #e9eaeb;
$roomsublist2-divider-color: $primary-fg-color;
$roomtile2-preview-color: #9e9e9e;
$roomtile2-default-badge-bg-color: #61708b;

View File

@ -180,7 +180,7 @@ $theme-button-bg-color: #e3e8f0;
$roomlist2-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons
$roomlist2-bg-color: $header-panel-bg-color;
$roomsublist2-divider-color: #e9eaeb;
$roomsublist2-divider-color: $primary-fg-color;
$roomtile2-preview-color: #9e9e9e;
$roomtile2-default-badge-bg-color: #61708b;

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

@ -122,7 +122,7 @@ const Notifier = {
}
},
getSoundForRoom: async function(roomId) {
getSoundForRoom: function(roomId) {
// We do no caching here because the SDK caches setting
// and the browser will cache the sound.
const content = SettingsStore.getValue("notificationSound", roomId);
@ -151,7 +151,7 @@ const Notifier = {
},
_playAudioNotification: async function(ev, room) {
const sound = await this.getSoundForRoom(room.roomId);
const sound = this.getSoundForRoom(room.roomId);
console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`);
try {

View File

@ -56,10 +56,11 @@ export function countRoomsWithNotif(rooms) {
}
export function aggregateNotificationCount(rooms) {
return rooms.reduce((result, room, index) => {
return rooms.reduce((result, room) => {
const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
// use helper method to include highlights in the previous version of the room
const notificationCount = getUnreadNotificationCount(room);
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);

View File

@ -22,18 +22,14 @@ 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";
/*******************************************************************
* CAUTION *
@ -51,10 +47,12 @@ interface IProps {
interface IState {
searchFilter: string; // TODO: Move search into room list?
showBreadcrumbs: boolean;
showTagPanel: boolean;
}
export default class LeftPanel2 extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string;
// TODO: Properly support TagPanel
// TODO: Properly support searching/filtering
@ -69,39 +67,25 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
this.state = {
searchFilter: "",
showBreadcrumbs: BreadcrumbsStore.instance.visible,
showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'),
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
});
// 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});
};
@ -161,7 +145,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
};
private onResize = () => {
console.log("Resize width");
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
@ -171,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) {
@ -182,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>
);
@ -232,7 +189,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const tagPanel = (
const tagPanel = !this.state.showTagPanel ? null : (
<div className="mx_LeftPanel2_tagPanelContainer">
<TagPanel/>
</div>
@ -253,6 +210,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const containerClasses = classNames({
"mx_LeftPanel2": true,
"mx_LeftPanel2_hasTagPanel": !!tagPanel,
"mx_LeftPanel2_minimized": this.props.isMinimized,
});

View File

@ -123,7 +123,7 @@ interface IState {
*
* Components mounted below us can access the matrix client via the react context.
*/
class LoggedInView extends React.PureComponent<IProps, IState> {
class LoggedInView extends React.Component<IProps, IState> {
static displayName = 'LoggedInView';
static propTypes = {

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',
@ -1828,7 +1843,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
updateStatusIndicator(state: string, prevState: string) {
const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getRooms()).count;
// only count visible rooms to not torment the user with notification counts in rooms they can't see
// it will include highlights from the previous version of the room internally
const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getVisibleRooms()).count;
if (PlatformPeg.get()) {
PlatformPeg.get().setErrorStatus(state === 'ERROR');
@ -1913,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
@ -1923,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) {
@ -2002,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>
<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

@ -30,6 +30,7 @@ interface IProps {
isExplicit?: boolean;
// XXX: once design replaces all toggles make this the default
useCheckbox?: boolean;
disabled?: boolean;
onChange?(checked: boolean): void;
}
@ -78,14 +79,23 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
else label = _t(label);
if (this.props.useCheckbox) {
return <StyledCheckbox checked={this.state.value} onChange={this.checkBoxOnChange} disabled={!canChange} >
return <StyledCheckbox
checked={this.state.value}
onChange={this.checkBoxOnChange}
disabled={this.props.disabled || !canChange}
>
{label}
</StyledCheckbox>;
} else {
return (
<div className="mx_SettingsFlag">
<span className="mx_SettingsFlag_label">{label}</span>
<ToggleSwitch checked={this.state.value} onChange={this.onChange} disabled={!canChange} aria-label={label} />
<ToggleSwitch
checked={this.state.value}
onChange={this.onChange}
disabled={this.props.disabled || !canChange}
aria-label={label}
/>
</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

@ -0,0 +1,61 @@
/*
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 StyledRadioButton from "./StyledRadioButton";
interface IDefinition<T extends string> {
value: T;
className?: string;
disabled?: boolean;
label: React.ReactChild;
description?: React.ReactChild;
}
interface IProps<T extends string> {
name: string;
className?: string;
definitions: IDefinition<T>[];
value?: T; // if not provided no options will be selected
onChange(newValue: T);
}
function StyledRadioGroup<T extends string>({name, definitions, value, className, onChange}: IProps<T>) {
const _onChange = e => {
onChange(e.target.value);
};
return <React.Fragment>
{definitions.map(d => <React.Fragment>
<StyledRadioButton
key={d.value}
className={classNames(className, d.className)}
onChange={_onChange}
checked={d.value === value}
name={name}
value={d.value}
disabled={d.disabled}
>
{d.label}
</StyledRadioButton>
{d.description}
</React.Fragment>)}
</React.Fragment>;
}
export default StyledRadioGroup;

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

@ -141,6 +141,20 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
}
}
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;

View File

@ -70,6 +70,7 @@ interface IProps {
interface IState {
notificationState: ListNotificationState;
menuDisplayed: boolean;
isResizing: boolean;
}
export default class RoomSublist2 extends React.Component<IProps, IState> {
@ -82,6 +83,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
this.state = {
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
menuDisplayed: false,
isResizing: false,
};
this.state.notificationState.setRooms(this.props.rooms);
}
@ -111,13 +113,21 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onResizeStart = () => {
this.setState({isResizing: true});
};
private onResizeStop = () => {
this.setState({isResizing: false});
};
private onShowAllClick = () => {
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onShowLessClick = () => {
this.props.layout.visibleTiles = this.props.layout.minVisibleTiles;
this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
@ -320,8 +330,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<span>{this.props.label}</span>
</AccessibleButton>
{this.renderMenu()}
{this.props.isMinimized ? null : addRoomButton}
{this.props.isMinimized ? null : badgeContainer}
{this.props.isMinimized ? null : addRoomButton}
</div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
@ -356,6 +366,12 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const nVisible = Math.floor(layout.visibleTiles);
const visibleTiles = tiles.slice(0, nVisible);
const maxTilesFactored = layout.tilesWithResizerBoxFactor(tiles.length);
const showMoreBtnClasses = classNames({
'mx_RoomSublist2_showNButton': true,
'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored,
});
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
@ -370,7 +386,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
);
if (this.props.isMinimized) showMoreText = null;
showNButton = (
<div onClick={this.onShowAllClick} className='mx_RoomSublist2_showNButton'>
<div onClick={this.onShowAllClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */}
</span>
@ -386,7 +402,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
);
if (this.props.isMinimized) showLessText = null;
showNButton = (
<div onClick={this.onShowLessClick} className='mx_RoomSublist2_showNButton'>
<div onClick={this.onShowLessClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */}
</span>
@ -432,6 +448,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
resizeHandles={handles}
onResize={this.onResize}
className="mx_RoomSublist2_resizeBox"
onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop}
>
{visibleTiles}
{showNButton}

View File

@ -39,12 +39,11 @@ export default class NotificationsSettingsTab extends React.Component {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
Notifier.getSoundForRoom(this.props.roomId).then((soundData) => {
if (!soundData) {
return;
}
this.setState({currentSound: soundData.name || soundData.url});
});
const soundData = Notifier.getSoundForRoom(this.props.roomId);
if (!soundData) {
return;
}
this.setState({currentSound: soundData.name || soundData.url});
this._soundUpload = createRef();
}

View File

@ -33,6 +33,7 @@ import StyledCheckbox from '../../../elements/StyledCheckbox';
import SettingsFlag from '../../../elements/SettingsFlag';
import Field from '../../../elements/Field';
import EventTilePreview from '../../../elements/EventTilePreview';
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
interface IProps {
}
@ -116,8 +117,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
};
}
private onThemeChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newTheme = e.target.value;
private onThemeChange = (newTheme: string): void => {
if (this.state.theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set,
@ -277,19 +277,18 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
<div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_themeSection">
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
{systemThemeSection}
<div className="mx_ThemeSelectors" onChange={this.onThemeChange}>
{orderedThemes.map(theme => {
return <StyledRadioButton
key={theme.id}
value={theme.id}
name="theme"
disabled={this.state.useSystemTheme}
checked={!this.state.useSystemTheme && theme.id === this.state.theme}
className={"mx_ThemeSelector_" + theme.id}
>
{theme.name}
</StyledRadioButton>;
})}
<div className="mx_ThemeSelectors">
<StyledRadioGroup
name="theme"
definitions={orderedThemes.map(t => ({
value: t.id,
label: t.name,
disabled: this.state.useSystemTheme,
className: "mx_ThemeSelector_" + t.id,
}))}
onChange={this.onThemeChange}
value={this.state.useSystemTheme ? undefined : this.state.theme}
/>
</div>
{customThemeForm}
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} useCheckbox={true} />
@ -391,7 +390,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
let advanced: React.ReactNode;
if (this.state.showAdvanced) {
advanced = <div>
advanced = <>
<SettingsFlag
name="useCompactLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
disabled={this.state.useIRCLayout}
/>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}
@ -413,7 +418,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
disabled={!this.state.useSystemFont}
value={this.state.systemFont}
/>
</div>;
</>;
}
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Advanced">
{toggle}

View File

@ -0,0 +1,50 @@
/*
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 {useCallback, useState} from "react";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {useEventEmitter} from "./useEventEmitter";
const tryGetContent = (ev?: MatrixEvent) => ev ? ev.getContent() : undefined;
// Hook to simplify listening to Matrix account data
export const useAccountData = <T extends {}>(cli: MatrixClient, eventType: string) => {
const [value, setValue] = useState<T>(() => tryGetContent(cli.getAccountData(eventType)));
const handler = useCallback((event) => {
if (event.getType() !== eventType) return;
setValue(event.getContent());
}, [cli, eventType]);
useEventEmitter(cli, "accountData", handler);
return value || {} as T;
};
// Hook to simplify listening to Matrix room account data
export const useRoomAccountData = <T extends {}>(room: Room, eventType: string) => {
const [value, setValue] = useState<T>(() => tryGetContent(room.getAccountData(eventType)));
const handler = useCallback((event) => {
if (event.getType() !== eventType) return;
setValue(event.getContent());
}, [room, eventType]);
useEventEmitter(room, "Room.accountData", handler);
return value || {} as T;
};

View File

@ -479,6 +479,7 @@
"%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s",
"You changed the room topic": "You changed the room topic",
"%(senderName)s changed the room topic": "%(senderName)s changed the room topic",
"New spinner design": "New spinner design",
"Font scaling": "Font scaling",
"Message Pinning": "Message Pinning",
"Custom user status messages": "Custom user status messages",
@ -493,7 +494,7 @@
"Font size": "Font size",
"Use custom size": "Use custom size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout",
"Use a more compact Modern layout": "Use a more compact Modern layout",
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
"Show join/leave messages (invites/kicks/bans unaffected)": "Show join/leave messages (invites/kicks/bans unaffected)",
"Show avatar changes": "Show avatar changes",

View File

@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@ -15,9 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import {PushRuleVectorState, State} from "./PushRuleVectorState";
import {IExtendedPushRule, IPushRuleSet, IRuleSets} from "./types";
import {PushRuleVectorState} from "./PushRuleVectorState";
export interface IContentRules {
vectorState: State;
rules: IExtendedPushRule[];
externalRules: IExtendedPushRule[];
}
export const SCOPE = "global";
export const KIND = "content";
export class ContentRules {
/**
@ -31,7 +39,7 @@ export class ContentRules {
* externalRules: a list of other keyword rules, with states other than
* vectorState
*/
static parseContentRules(rulesets) {
static parseContentRules(rulesets: IRuleSets): IContentRules {
// first categorise the keyword rules in terms of their actions
const contentRules = this._categoriseContentRules(rulesets);
@ -51,59 +59,72 @@ export class ContentRules {
if (contentRules.loud.length) {
return {
vectorState: PushRuleVectorState.LOUD,
vectorState: State.Loud,
rules: contentRules.loud,
externalRules: [].concat(contentRules.loud_but_disabled, contentRules.on, contentRules.on_but_disabled, contentRules.other),
externalRules: [
...contentRules.loud_but_disabled,
...contentRules.on,
...contentRules.on_but_disabled,
...contentRules.other,
],
};
} else if (contentRules.loud_but_disabled.length) {
return {
vectorState: PushRuleVectorState.OFF,
vectorState: State.Off,
rules: contentRules.loud_but_disabled,
externalRules: [].concat(contentRules.on, contentRules.on_but_disabled, contentRules.other),
externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other],
};
} else if (contentRules.on.length) {
return {
vectorState: PushRuleVectorState.ON,
vectorState: State.On,
rules: contentRules.on,
externalRules: [].concat(contentRules.on_but_disabled, contentRules.other),
externalRules: [...contentRules.on_but_disabled, ...contentRules.other],
};
} else if (contentRules.on_but_disabled.length) {
return {
vectorState: PushRuleVectorState.OFF,
vectorState: State.Off,
rules: contentRules.on_but_disabled,
externalRules: contentRules.other,
};
} else {
return {
vectorState: PushRuleVectorState.ON,
vectorState: State.On,
rules: [],
externalRules: contentRules.other,
};
}
}
static _categoriseContentRules(rulesets) {
const contentRules = {on: [], on_but_disabled: [], loud: [], loud_but_disabled: [], other: []};
static _categoriseContentRules(rulesets: IRuleSets) {
const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IExtendedPushRule[]> = {
on: [],
on_but_disabled: [],
loud: [],
loud_but_disabled: [],
other: [],
};
for (const kind in rulesets.global) {
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
const r = rulesets.global[kind][i];
// check it's not a default rule
if (r.rule_id[0] === '.' || kind !== 'content') {
if (r.rule_id[0] === '.' || kind !== "content") {
continue;
}
r.kind = kind; // is this needed? not sure
// this is needed as we are flattening an object of arrays into a single array
r.kind = kind;
switch (PushRuleVectorState.contentRuleVectorStateKind(r)) {
case PushRuleVectorState.ON:
case State.On:
if (r.enabled) {
contentRules.on.push(r);
} else {
contentRules.on_but_disabled.push(r);
}
break;
case PushRuleVectorState.LOUD:
case State.Loud:
if (r.enabled) {
contentRules.loud.push(r);
} else {

View File

@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@ -15,7 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import {Action, Actions} from "./types";
interface IEncodedActions {
notify: boolean;
sound?: string;
highlight?: boolean;
}
export class NotificationUtils {
// Encodes a dictionary of {
@ -24,12 +30,12 @@ export class NotificationUtils {
// "highlight: true/false,
// }
// to a list of push actions.
static encodeActions(action) {
static encodeActions(action: IEncodedActions) {
const notify = action.notify;
const sound = action.sound;
const highlight = action.highlight;
if (notify) {
const actions = ["notify"];
const actions: Action[] = [Actions.Notify];
if (sound) {
actions.push({"set_tweak": "sound", "value": sound});
}
@ -40,7 +46,7 @@ export class NotificationUtils {
}
return actions;
} else {
return ["dont_notify"];
return [Actions.DontNotify];
}
}
@ -50,18 +56,18 @@ export class NotificationUtils {
// "highlight: true/false,
// }
// If the actions couldn't be decoded then returns null.
static decodeActions(actions) {
static decodeActions(actions: Action[]): IEncodedActions {
let notify = false;
let sound = null;
let highlight = false;
for (let i = 0; i < actions.length; ++i) {
const action = actions[i];
if (action === "notify") {
if (action === Actions.Notify) {
notify = true;
} else if (action === "dont_notify") {
} else if (action === Actions.DontNotify) {
notify = false;
} else if (typeof action === 'object') {
} else if (typeof action === "object") {
if (action.set_tweak === "sound") {
sound = action.value;
} else if (action.set_tweak === "highlight") {
@ -81,7 +87,7 @@ export class NotificationUtils {
highlight = true;
}
const result = {notify: notify, highlight: highlight};
const result: IEncodedActions = { notify, highlight };
if (sound !== null) {
result.sound = sound;
}

View File

@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@ -15,43 +15,42 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import {StandardActions} from "./StandardActions";
import {NotificationUtils} from "./NotificationUtils";
import {IPushRule} from "./types";
export enum State {
/** The push rule is disabled */
Off = "off",
/** The user will receive push notification for this rule */
On = "on",
/** The user will receive push notification for this rule with sound and
highlight if this is legitimate */
Loud = "loud",
}
export class PushRuleVectorState {
// Backwards compatibility (things should probably be using .states instead)
static OFF = "off";
static ON = "on";
static LOUD = "loud";
// Backwards compatibility (things should probably be using the enum above instead)
static OFF = State.Off;
static ON = State.On;
static LOUD = State.Loud;
/**
* Enum for state of a push rule as defined by the Vector UI.
* @readonly
* @enum {string}
*/
static states = {
/** The push rule is disabled */
OFF: PushRuleVectorState.OFF,
/** The user will receive push notification for this rule */
ON: PushRuleVectorState.ON,
/** The user will receive push notification for this rule with sound and
highlight if this is legitimate */
LOUD: PushRuleVectorState.LOUD,
};
static states = State;
/**
* Convert a PushRuleVectorState to a list of actions
*
* @return [object] list of push-rule actions
*/
static actionsFor(pushRuleVectorState) {
if (pushRuleVectorState === PushRuleVectorState.ON) {
static actionsFor(pushRuleVectorState: State) {
if (pushRuleVectorState === State.On) {
return StandardActions.ACTION_NOTIFY;
} else if (pushRuleVectorState === PushRuleVectorState.LOUD) {
} else if (pushRuleVectorState === State.Loud) {
return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND;
}
}
@ -63,7 +62,7 @@ export class PushRuleVectorState {
* category or in PushRuleVectorState.LOUD, regardless of its enabled
* state. Returns null if it does not match these categories.
*/
static contentRuleVectorStateKind(rule) {
static contentRuleVectorStateKind(rule: IPushRule): State {
const decoded = NotificationUtils.decodeActions(rule.actions);
if (!decoded) {
@ -81,10 +80,10 @@ export class PushRuleVectorState {
let stateKind = null;
switch (tweaks) {
case 0:
stateKind = PushRuleVectorState.ON;
stateKind = State.On;
break;
case 2:
stateKind = PushRuleVectorState.LOUD;
stateKind = State.Loud;
break;
}
return stateKind;

View File

@ -15,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import {NotificationUtils} from "./NotificationUtils";
const encodeActions = NotificationUtils.encodeActions;

111
src/notifications/types.ts Normal file
View File

@ -0,0 +1,111 @@
/*
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.
*/
export enum NotificationSetting {
AllMessages = "all_messages", // .m.rule.message = notify
DirectMessagesMentionsKeywords = "dm_mentions_keywords", // .m.rule.message = mark_unread. This is the new default.
MentionsKeywordsOnly = "mentions_keywords", // .m.rule.message = mark_unread; .m.rule.room_one_to_one = mark_unread
Never = "never", // .m.rule.master = enabled (dont_notify)
}
export interface ISoundTweak {
set_tweak: "sound";
value: string;
}
export interface IHighlightTweak {
set_tweak: "highlight";
value?: boolean;
}
export type Tweak = ISoundTweak | IHighlightTweak;
export enum Actions {
Notify = "notify",
DontNotify = "dont_notify", // no-op
Coalesce = "coalesce", // unused
MarkUnread = "mark_unread", // new
}
export type Action = Actions | Tweak;
// Push rule kinds in descending priority order
export enum Kind {
Override = "override",
ContentSpecific = "content",
RoomSpecific = "room",
SenderSpecific = "sender",
Underride = "underride",
}
export interface IEventMatchCondition {
kind: "event_match";
key: string;
pattern: string;
}
export interface IContainsDisplayNameCondition {
kind: "contains_display_name";
}
export interface IRoomMemberCountCondition {
kind: "room_member_count";
is: string;
}
export interface ISenderNotificationPermissionCondition {
kind: "sender_notification_permission";
key: string;
}
export type Condition =
IEventMatchCondition |
IContainsDisplayNameCondition |
IRoomMemberCountCondition |
ISenderNotificationPermissionCondition;
export enum RuleIds {
MasterRule = ".m.rule.master", // The master rule (all notifications disabling)
MessageRule = ".m.rule.message",
EncryptedMessageRule = ".m.rule.encrypted",
RoomOneToOneRule = ".m.rule.room_one_to_one",
EncryptedRoomOneToOneRule = ".m.rule.room_one_to_one",
}
export interface IPushRule {
enabled: boolean;
rule_id: RuleIds | string;
actions: Action[];
default: boolean;
conditions?: Condition[]; // only applicable to `underride` and `override` rules
pattern?: string; // only applicable to `content` rules
}
// push rule extended with kind, used by ContentRules and js-sdk's pushprocessor
export interface IExtendedPushRule extends IPushRule {
kind: Kind;
}
export interface IPushRuleSet {
override: IPushRule[];
content: IPushRule[];
room: IPushRule[];
sender: IPushRule[];
underride: IPushRule[];
}
export interface IRuleSets {
global: IPushRuleSet;
}

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"),
@ -192,12 +198,12 @@ export const SETTINGS = {
},
// TODO: Wire up appropriately to UI (FTUE notifications)
"Notifications.alwaysShowBadgeCounts": {
supportedLevels: ['account'],
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
default: false,
},
"useCompactLayout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Use compact timeline layout'),
displayName: _td('Use a more compact Modern layout'),
default: false,
},
"showRedactions": {

View File

@ -18,6 +18,10 @@ import { TagID } from "./models";
const TILE_HEIGHT_PX = 44;
// the .65 comes from the CSS where the show more button is
// mathematically 65% of a tile when floating.
const RESIZER_BOX_FACTOR = 0.65;
interface ISerializedListLayout {
numTiles: number;
showPreviews: boolean;
@ -67,6 +71,7 @@ export class ListLayout {
}
public get visibleTiles(): number {
if (this._n === 0) return this.defaultVisibleTiles;
return Math.max(this._n, this.minVisibleTiles);
}
@ -76,9 +81,13 @@ export class ListLayout {
}
public get minVisibleTiles(): number {
// the .65 comes from the CSS where the show more button is
// mathematically 65% of a tile when floating.
return 4.65;
return 1 + RESIZER_BOX_FACTOR;
}
public get defaultVisibleTiles(): number {
// TODO: Remove dogfood flag
const val = Number(localStorage.getItem("mx_dogfood_rl_defTiles") || 4);
return val + RESIZER_BOX_FACTOR;
}
public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number {
@ -92,6 +101,10 @@ export class ListLayout {
return this.tilesToPixels(Math.min(maxTiles, n)) + padding;
}
public tilesWithResizerBoxFactor(n: number): number {
return n + RESIZER_BOX_FACTOR;
}
public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
}

View File

@ -24,14 +24,12 @@ import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore";
const onAccept = () => {
console.log("DEBUG onAccept AnalyticsToast");
dis.dispatch({
action: 'accept_cookies',
});
};
const onReject = () => {
console.log("DEBUG onReject AnalyticsToast");
dis.dispatch({
action: "reject_cookies",
});