Merge branches 'develop' and 't3chguy/room-list/3' of github.com:matrix-org/matrix-react-sdk into t3chguy/room-list/3

 Conflicts:
	src/components/structures/ContextMenu.tsx
	src/components/structures/UserMenu.tsx
	src/components/views/rooms/RoomSublist2.tsx
	src/components/views/rooms/RoomTile2.tsx
This commit is contained in:
Michael Telatynski 2020-07-07 15:01:27 +01:00
commit afac330143
42 changed files with 1084 additions and 248 deletions

View file

@ -1,3 +1,159 @@
Changes in [2.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.9.0) (2020-07-03)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.9.0-rc.1...v2.9.0)
* Upgrade to JS SDK 7.1.0
* Remove duplicate compact settings, handle device level updates
[\#4889](https://github.com/matrix-org/matrix-react-sdk/pull/4889)
Changes in [2.9.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.9.0-rc.1) (2020-07-01)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.1...v2.9.0-rc.1)
* Upgrade to JS SDK 7.1.0-rc.1
* Update from Weblate
[\#4869](https://github.com/matrix-org/matrix-react-sdk/pull/4869)
* Fix a number of proliferation issues in the new room list
[\#4828](https://github.com/matrix-org/matrix-react-sdk/pull/4828)
* Fix jumping to read marker for events without tiles
[\#4860](https://github.com/matrix-org/matrix-react-sdk/pull/4860)
* De-duplicate rooms from the room autocomplete provider
[\#4859](https://github.com/matrix-org/matrix-react-sdk/pull/4859)
* Add file upload button to recovery key input
[\#4847](https://github.com/matrix-org/matrix-react-sdk/pull/4847)
* Implement new design on security setup & login
[\#4831](https://github.com/matrix-org/matrix-react-sdk/pull/4831)
* Fix /join slash command via servers including room id as a via
[\#4856](https://github.com/matrix-org/matrix-react-sdk/pull/4856)
* Add Generic Expiring Toast and timing hooks
[\#4855](https://github.com/matrix-org/matrix-react-sdk/pull/4855)
* Fix Room Custom Sounds regression and make ProgressBar relevant again
[\#4846](https://github.com/matrix-org/matrix-react-sdk/pull/4846)
* Including start_sso and start_cas in redirect loop prevention
[\#4854](https://github.com/matrix-org/matrix-react-sdk/pull/4854)
* Clean up TODO comments for new room list
[\#4850](https://github.com/matrix-org/matrix-react-sdk/pull/4850)
* Show timestamp of redaction on hover
[\#4622](https://github.com/matrix-org/matrix-react-sdk/pull/4622)
* Remove the DM button from new room tiles
[\#4849](https://github.com/matrix-org/matrix-react-sdk/pull/4849)
* Hide room list show less button if it would do nothing
[\#4848](https://github.com/matrix-org/matrix-react-sdk/pull/4848)
* Improve message preview copy in new room list
[\#4823](https://github.com/matrix-org/matrix-react-sdk/pull/4823)
* Allow the tag panel to be disabled in the new room list
[\#4844](https://github.com/matrix-org/matrix-react-sdk/pull/4844)
* Make the whole user row clickable in the new room list
[\#4843](https://github.com/matrix-org/matrix-react-sdk/pull/4843)
* Add a new spinner design behind a labs flag
[\#4842](https://github.com/matrix-org/matrix-react-sdk/pull/4842)
* ts-ignore because something is made of fail
[\#4845](https://github.com/matrix-org/matrix-react-sdk/pull/4845)
* Fix Welcome.html CAS and SSO URLs not working
[\#4838](https://github.com/matrix-org/matrix-react-sdk/pull/4838)
* More small tweaks in preparation for Notifications rework
[\#4835](https://github.com/matrix-org/matrix-react-sdk/pull/4835)
* Iterate on the new room list resize handle
[\#4840](https://github.com/matrix-org/matrix-react-sdk/pull/4840)
* Update sublists for new hover states
[\#4837](https://github.com/matrix-org/matrix-react-sdk/pull/4837)
* Tweak parts of the new room list design
[\#4839](https://github.com/matrix-org/matrix-react-sdk/pull/4839)
* Implement new resize handle for dogfooding
[\#4836](https://github.com/matrix-org/matrix-react-sdk/pull/4836)
* Hide app badge count for hidden upgraded rooms (non-highlight)
[\#4834](https://github.com/matrix-org/matrix-react-sdk/pull/4834)
* Move compact modern layout checkbox to 'advanced'
[\#4822](https://github.com/matrix-org/matrix-react-sdk/pull/4822)
* Allow the user to resize the new sublists to 1 tile
[\#4825](https://github.com/matrix-org/matrix-react-sdk/pull/4825)
* Make LoggedInView a real component because it uses shouldComponentUpdate
[\#4832](https://github.com/matrix-org/matrix-react-sdk/pull/4832)
* Small tweaks in preparation for Notifications rework
[\#4829](https://github.com/matrix-org/matrix-react-sdk/pull/4829)
* Remove extraneous debug from the new left panel
[\#4826](https://github.com/matrix-org/matrix-react-sdk/pull/4826)
* Fix icons in the new user menu not showing up
[\#4824](https://github.com/matrix-org/matrix-react-sdk/pull/4824)
* Fix sticky room disappearing/jumping in search results
[\#4817](https://github.com/matrix-org/matrix-react-sdk/pull/4817)
* Show cross-signing / secret storage reset button in more cases
[\#4821](https://github.com/matrix-org/matrix-react-sdk/pull/4821)
* Use theme-capable icons in the user menu
[\#4819](https://github.com/matrix-org/matrix-react-sdk/pull/4819)
* Font support in custom themes
[\#4814](https://github.com/matrix-org/matrix-react-sdk/pull/4814)
* Decrease margin between new sublists
[\#4816](https://github.com/matrix-org/matrix-react-sdk/pull/4816)
* Update profile information in User Menu and truncate where needed
[\#4818](https://github.com/matrix-org/matrix-react-sdk/pull/4818)
* Fix MessageActionBar in irc layout
[\#4802](https://github.com/matrix-org/matrix-react-sdk/pull/4802)
* Mark messages with a black shield if the megolm session isn't trusted
[\#4797](https://github.com/matrix-org/matrix-react-sdk/pull/4797)
* Custom font selection
[\#4761](https://github.com/matrix-org/matrix-react-sdk/pull/4761)
* Use the correct timeline reference for message previews
[\#4812](https://github.com/matrix-org/matrix-react-sdk/pull/4812)
* Fix read receipt handling in the new room list
[\#4811](https://github.com/matrix-org/matrix-react-sdk/pull/4811)
* Improve unread/badge states in new room list (mk II)
[\#4805](https://github.com/matrix-org/matrix-react-sdk/pull/4805)
* Only fire setting changes for changed settings
[\#4803](https://github.com/matrix-org/matrix-react-sdk/pull/4803)
* Trigger room-specific watchers whenever a higher level change happens
[\#4804](https://github.com/matrix-org/matrix-react-sdk/pull/4804)
* Have the theme switcher set the device-level theme to match settings
[\#4810](https://github.com/matrix-org/matrix-react-sdk/pull/4810)
* Fix layout of minimized view for new room list
[\#4808](https://github.com/matrix-org/matrix-react-sdk/pull/4808)
* Fix sticky headers over/under extending themselves in the new room list
[\#4809](https://github.com/matrix-org/matrix-react-sdk/pull/4809)
* Update read receipt remainder for internal font size change
[\#4806](https://github.com/matrix-org/matrix-react-sdk/pull/4806)
* Fix some appearance tab crash and implement style nits
[\#4801](https://github.com/matrix-org/matrix-react-sdk/pull/4801)
* Add message preview for font slider
[\#4770](https://github.com/matrix-org/matrix-react-sdk/pull/4770)
* Add layout options to the appearance tab
[\#4773](https://github.com/matrix-org/matrix-react-sdk/pull/4773)
* Update from Weblate
[\#4800](https://github.com/matrix-org/matrix-react-sdk/pull/4800)
* Support accounts with cross signing but no SSSS
[\#4717](https://github.com/matrix-org/matrix-react-sdk/pull/4717)
* Look for existing verification requests after login
[\#4762](https://github.com/matrix-org/matrix-react-sdk/pull/4762)
* Add a checkpoint to index newly encrypted rooms.
[\#4611](https://github.com/matrix-org/matrix-react-sdk/pull/4611)
* Add support to paginate search results when using Seshat.
[\#4705](https://github.com/matrix-org/matrix-react-sdk/pull/4705)
* User versions in the event index.
[\#4788](https://github.com/matrix-org/matrix-react-sdk/pull/4788)
* Fix crash when filtering new room list too fast
[\#4796](https://github.com/matrix-org/matrix-react-sdk/pull/4796)
* hide search results from unknown rooms
[\#4795](https://github.com/matrix-org/matrix-react-sdk/pull/4795)
* Mark the new room list as ready for general testing
[\#4794](https://github.com/matrix-org/matrix-react-sdk/pull/4794)
* Extend QueryMatcher's sorting heuristic
[\#4784](https://github.com/matrix-org/matrix-react-sdk/pull/4784)
* Lint ts semicolons (aka. The great semicolon migration)
[\#4791](https://github.com/matrix-org/matrix-react-sdk/pull/4791)
* Revert "Use recovery keys over passphrases"
[\#4790](https://github.com/matrix-org/matrix-react-sdk/pull/4790)
* Clear `top` when not sticking headers to the top
[\#4783](https://github.com/matrix-org/matrix-react-sdk/pull/4783)
* Don't show a 'show less' button when it's impossible to collapse
[\#4785](https://github.com/matrix-org/matrix-react-sdk/pull/4785)
* Fix show less/more button occluding the list automatically
[\#4786](https://github.com/matrix-org/matrix-react-sdk/pull/4786)
* Improve room switching in the new room list
[\#4787](https://github.com/matrix-org/matrix-react-sdk/pull/4787)
* Remove labs option to cache 'passphrase'
[\#4789](https://github.com/matrix-org/matrix-react-sdk/pull/4789)
* Remove escape backslashes in non-Markdown messages
[\#4694](https://github.com/matrix-org/matrix-react-sdk/pull/4694)
Changes in [2.8.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.1) (2020-06-29) Changes in [2.8.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.1) (2020-06-29)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.0...v2.8.1) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.0...v2.8.1)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "2.8.1", "version": "2.9.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -120,6 +120,7 @@
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@peculiar/webcrypto": "^1.0.22", "@peculiar/webcrypto": "^1.0.22",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/counterpart": "^0.18.1",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/lodash": "^4.14.152", "@types/lodash": "^4.14.152",
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",

View file

@ -86,11 +86,15 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
.mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton { .mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
// Cheaty way to return the occupied space to the filter input // Cheaty way to return the occupied space to the filter input
flex-basis: 0;
margin: 0; margin: 0;
width: 0; width: 0;
// Don't forget to hide the masked ::before icon // Don't forget to hide the masked ::before icon,
visibility: hidden; // using display:none or visibility:hidden would break accessibility
&::before {
content: none;
}
} }
.mx_LeftPanel2_exploreButton { .mx_LeftPanel2_exploreButton {

View file

@ -28,9 +28,6 @@ limitations under the License.
align-items: baseline; align-items: baseline;
flex-grow: 1; flex-grow: 1;
border: 1px solid $input-darker-bg-color;
border-radius: 8px;
> .mx_RadioButton_content { > .mx_RadioButton_content {
flex-grow: 1; flex-grow: 1;
@ -110,6 +107,11 @@ limitations under the License.
} }
} }
.mx_RadioButton_outlined {
border: 1px solid $input-darker-bg-color;
border-radius: 8px;
}
.mx_RadioButton_checked { .mx_RadioButton_checked {
border-color: $accent-color; border-color: $accent-color;
} }

View file

@ -92,7 +92,7 @@ limitations under the License.
// Apply the width and margin to the badge so the container doesn't occupy dead space // Apply the width and margin to the badge so the container doesn't occupy dead space
.mx_NotificationBadge { .mx_NotificationBadge {
width: 16px; // Do not set a width so the badges get properly sized
margin-left: 8px; // same as menu+aux buttons margin-left: 8px; // same as menu+aux buttons
} }
} }
@ -278,6 +278,7 @@ limitations under the License.
} }
&.mx_RoomSublist2_hasMenuOpen, &.mx_RoomSublist2_hasMenuOpen,
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within,
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover {
.mx_RoomSublist2_menuButton { .mx_RoomSublist2_menuButton {
visibility: visible; visibility: visible;

View file

@ -24,7 +24,10 @@ limitations under the License.
// The tile is also a flexbox row itself // The tile is also a flexbox row itself
display: flex; display: flex;
&.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen { &.mx_RoomTile2_selected,
&:hover,
&:focus-within,
&.mx_RoomTile2_hasMenuOpen {
background-color: $roomtile2-selected-bg-color; background-color: $roomtile2-selected-bg-color;
border-radius: 32px; border-radius: 32px;
} }
@ -74,7 +77,7 @@ limitations under the License.
} }
} }
.mx_RoomTile2_menuButton { .mx_RoomTile2_notificationsButton {
margin-left: 4px; // spacing between buttons margin-left: 4px; // spacing between buttons
} }
@ -82,6 +85,7 @@ limitations under the License.
height: 16px; height: 16px;
// don't set width so that it takes no space when there is no badge to show // don't set width so that it takes no space when there is no badge to show
margin: auto 0; // vertically align margin: auto 0; // vertically align
position: relative; // fixes badge alignment in some scenarios
// Create a flexbox to make aligning dot badges easier // Create a flexbox to make aligning dot badges easier
display: flex; display: flex;
@ -104,7 +108,8 @@ limitations under the License.
width: 20px; width: 20px;
min-width: 20px; // yay flex min-width: 20px; // yay flex
height: 20px; height: 20px;
margin: auto 0; margin-top: auto;
margin-bottom: auto;
position: relative; position: relative;
display: none; display: none;
@ -132,7 +137,9 @@ limitations under the License.
} }
&:not(.mx_RoomTile2_minimized) { &:not(.mx_RoomTile2_minimized) {
&:hover, &.mx_RoomTile2_hasMenuOpen { &:hover,
&:focus-within,
&.mx_RoomTile2_hasMenuOpen {
// Hide the badge container on hover because it'll be a menu button // Hide the badge container on hover because it'll be a menu button
.mx_RoomTile2_badgeContainer { .mx_RoomTile2_badgeContainer {
width: 0; width: 0;

View file

@ -193,6 +193,10 @@ limitations under the License.
.mx_EventTile_content { .mx_EventTile_content {
margin-right: 0; margin-right: 0;
} }
&.mx_AppearanceUserSettingsTab_Layout_RadioButton_selected {
border-color: $accent-color;
}
} }
.mx_RadioButton { .mx_RadioButton {

View file

@ -20,6 +20,7 @@ import { IMatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import DeviceListener from "../DeviceListener"; import DeviceListener from "../DeviceListener";
import { RoomListStore2 } from "../stores/room-list/RoomListStore2"; import { RoomListStore2 } from "../stores/room-list/RoomListStore2";
import { PlatformPeg } from "../PlatformPeg";
declare global { declare global {
interface Window { interface Window {
@ -33,6 +34,7 @@ declare global {
mx_ToastStore: ToastStore; mx_ToastStore: ToastStore;
mx_DeviceListener: DeviceListener; mx_DeviceListener: DeviceListener;
mx_RoomListStore2: RoomListStore2; mx_RoomListStore2: RoomListStore2;
mxPlatformPeg: PlatformPeg;
} }
// workaround for https://github.com/microsoft/TypeScript/issues/30933 // workaround for https://github.com/microsoft/TypeScript/issues/30933
@ -45,6 +47,10 @@ declare global {
hasStorageAccess?: () => Promise<boolean>; hasStorageAccess?: () => Promise<boolean>;
} }
interface Navigator {
userLanguage?: string;
}
interface StorageEstimate { interface StorageEstimate {
usageDetails?: {[key: string]: number}; usageDetails?: {[key: string]: number};
} }

View file

@ -53,6 +53,10 @@ export default abstract class BasePlatform {
this.startUpdateCheck = this.startUpdateCheck.bind(this); this.startUpdateCheck = this.startUpdateCheck.bind(this);
} }
abstract async getConfig(): Promise<{}>;
abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload) => { protected onAction = (payload: ActionPayload) => {
switch (payload.action) { switch (payload.action) {
case 'on_client_not_viable': case 'on_client_not_viable':

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import BasePlatform from "./BasePlatform";
/* /*
* Holds the current Platform object used by the code to do anything * Holds the current Platform object used by the code to do anything
* specific to the platform we're running on (eg. web, electron) * specific to the platform we're running on (eg. web, electron)
@ -21,10 +24,8 @@ limitations under the License.
* This allows the app layer to set a Platform without necessarily * This allows the app layer to set a Platform without necessarily
* having to have a MatrixChat object * having to have a MatrixChat object
*/ */
class PlatformPeg { export class PlatformPeg {
constructor() { platform: BasePlatform = null;
this.platform = null;
}
/** /**
* Returns the current Platform object for the application. * Returns the current Platform object for the application.
@ -39,12 +40,12 @@ class PlatformPeg {
* application. * application.
* This should be an instance of a class extending BasePlatform. * This should be an instance of a class extending BasePlatform.
*/ */
set(plaf) { set(plaf: BasePlatform) {
this.platform = plaf; this.platform = plaf;
} }
} }
if (!global.mxPlatformPeg) { if (!window.mxPlatformPeg) {
global.mxPlatformPeg = new PlatformPeg(); window.mxPlatformPeg = new PlatformPeg();
} }
export default global.mxPlatformPeg; export default window.mxPlatformPeg;

24
src/RoomNotifsTypes.ts Normal file
View file

@ -0,0 +1,24 @@
/*
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 {
ALL_MESSAGES,
ALL_MESSAGES_LOUD,
MENTIONS_ONLY,
MUTE,
} from "./RoomNotifs";
export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE;

View file

@ -444,6 +444,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
value={CREATE_STORAGE_OPTION_KEY} value={CREATE_STORAGE_OPTION_KEY}
name="keyPassphrase" name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY} checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY}
outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span> <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_secureBackup"></span>
@ -456,6 +457,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
value={CREATE_STORAGE_OPTION_PASSPHRASE} value={CREATE_STORAGE_OPTION_PASSPHRASE}
name="keyPassphrase" name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE} checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE}
outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
<span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span> <span className="mx_CreateSecretStorageDialog_optionIcon mx_CreateSecretStorageDialog_optionIcon_securePhrase"></span>

View file

@ -23,6 +23,8 @@ import classNames from "classnames";
import {Key} from "../../Keyboard"; import {Key} from "../../Keyboard";
import AccessibleButton, { IProps as IAccessibleButtonProps, ButtonEvent } from "../views/elements/AccessibleButton"; import AccessibleButton, { IProps as IAccessibleButtonProps, ButtonEvent } from "../views/elements/AccessibleButton";
import {Writeable} from "../../@types/common"; import {Writeable} from "../../@types/common";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import StyledRadioButton from "../views/elements/StyledRadioButton";
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
@ -455,6 +457,54 @@ export const MenuItemCheckbox: React.FC<IMenuItemCheckboxProps> = ({children, la
); );
}; };
interface IStyledMenuItemCheckboxProps extends IAccessibleButtonProps {
label?: string;
active: boolean;
disabled?: boolean;
className?: string;
onChange();
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IStyledMenuItemCheckboxProps> = ({children, label, onChange, onClose, checked, disabled=false, ...props}) => {
const onKeyDown = (e) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
aria-checked={checked}
checked={checked}
aria-disabled={disabled}
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledCheckbox>
);
};
interface IMenuItemRadioProps extends IAccessibleButtonProps { interface IMenuItemRadioProps extends IAccessibleButtonProps {
label?: string; label?: string;
active: boolean; active: boolean;
@ -472,6 +522,55 @@ export const MenuItemRadio: React.FC<IMenuItemRadioProps> = ({children, label, a
); );
}; };
interface IStyledMenuItemRadioProps extends IAccessibleButtonProps {
label?: string;
active: boolean;
disabled?: boolean;
className?: string;
onChange();
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IStyledMenuItemRadioProps> = ({children, label, onChange, onClose, checked=false, disabled=false, ...props}) => {
const onKeyDown = (e) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
aria-checked={checked}
checked={checked}
aria-disabled={disabled}
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledRadioButton>
);
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset // Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => { export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
const left = elementRect.right + window.pageXOffset + 3; const left = elementRect.right + window.pageXOffset + 3;

View file

@ -30,7 +30,9 @@ import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { RoomListStore2, LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2";
import {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -54,9 +56,19 @@ interface IState {
showTagPanel: boolean; showTagPanel: boolean;
} }
// List of CSS classes which should be included in keyboard navigation within the room list
const cssClasses = [
"mx_RoomSearch_input",
"mx_RoomSearch_icon", // minimized <RoomSearch />
"mx_RoomSublist2_headerText",
"mx_RoomTile2",
"mx_RoomSublist2_showNButton",
];
export default class LeftPanel2 extends React.Component<IProps, IState> { export default class LeftPanel2 extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string; private tagPanelWatcherRef: string;
private focusedElement = null;
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
@ -113,6 +125,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const headerStickyWidth = rlRect.width - headerRightMargin; const headerStickyWidth = rlRect.width - headerRightMargin;
let gotBottom = false; let gotBottom = false;
let lastTopHeader;
for (const sublist of sublists) { for (const sublist of sublists) {
const slRect = sublist.getBoundingClientRect(); const slRect = sublist.getBoundingClientRect();
@ -122,19 +135,25 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `${headerStickyWidth}px`; header.style.width = `${headerStickyWidth}px`;
header.style.top = `unset`; header.style.removeProperty("top");
gotBottom = true; gotBottom = true;
} else if (slRect.top < top) { } else if ((slRect.top - (headerHeight / 3)) < top) {
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
header.style.width = `${headerStickyWidth}px`; header.style.width = `${headerStickyWidth}px`;
header.style.top = `${rlRect.top}px`; header.style.top = `${rlRect.top}px`;
if (lastTopHeader) {
lastTopHeader.style.display = "none";
}
// first unset it, if set in last iteration
header.style.removeProperty("display");
lastTopHeader = header;
} else { } else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `unset`; header.style.removeProperty("width");
header.style.top = `unset`; header.style.removeProperty("top");
} }
} }
} }
@ -150,13 +169,76 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
this.handleStickyHeaders(this.listContainerRef.current); this.handleStickyHeaders(this.listContainerRef.current);
}; };
private onFocus = (ev: React.FocusEvent) => {
this.focusedElement = ev.target;
};
private onBlur = () => {
this.focusedElement = null;
};
private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.ARROW_UP:
case Key.ARROW_DOWN:
ev.stopPropagation();
ev.preventDefault();
this.onMoveFocus(ev.key === Key.ARROW_UP);
break;
}
};
private onMoveFocus = (up: boolean) => {
let element = this.focusedElement;
let descending = false; // are we currently descending or ascending through the DOM tree?
let classes: DOMTokenList;
do {
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
} else if (sibling) {
element = sibling;
} else {
descending = false;
element = element.parentElement;
}
} else {
if (sibling) {
element = sibling;
descending = true;
} else {
element = element.parentElement;
}
}
if (element) {
classes = element.classList;
}
} while (element && !cssClasses.some(c => classes.contains(c)));
if (element) {
element.focus();
this.focusedElement = element;
}
};
private renderHeader(): React.ReactNode { private renderHeader(): React.ReactNode {
let breadcrumbs; let breadcrumbs;
if (this.state.showBreadcrumbs) { if (this.state.showBreadcrumbs && !this.props.isMinimized) {
breadcrumbs = ( breadcrumbs = (
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"> <IndicatorScrollbar
{this.props.isMinimized ? null : <RoomBreadcrumbs2 />} className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"
</div> verticalScrollsHorizontally={true}
>
<RoomBreadcrumbs2 />
</IndicatorScrollbar>
); );
} }
@ -170,13 +252,22 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
private renderSearchExplore(): React.ReactNode { private renderSearchExplore(): React.ReactNode {
return ( return (
<div className="mx_LeftPanel2_filterContainer"> <div
<RoomSearch onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} /> className="mx_LeftPanel2_filterContainer"
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
>
<RoomSearch
onQueryUpdate={this.onSearch}
isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown}
/>
<AccessibleButton <AccessibleButton
tabIndex={-1} // TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180
className='mx_LeftPanel2_exploreButton' className="mx_LeftPanel2_exploreButton"
onClick={this.onExplore} onClick={this.onExplore}
alt={_t("Explore rooms")} title={_t("Explore rooms")}
/> />
</div> </div>
); );
@ -189,15 +280,15 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
</div> </div>
); );
// TODO: Determine what these onWhatever handlers do: https://github.com/vector-im/riot-web/issues/14180
const roomList = <RoomList2 const roomList = <RoomList2
onKeyDown={() => {/*TODO*/}} onKeyDown={this.onKeyDown}
resizeNotifier={null} resizeNotifier={null}
collapsed={false} collapsed={false}
searchFilter={this.state.searchFilter} searchFilter={this.state.searchFilter}
onFocus={() => {/*TODO*/}} onFocus={this.onFocus}
onBlur={() => {/*TODO*/}} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.onResize}
/>; />;
// TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177 // TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177
@ -223,7 +314,12 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
className={roomListClasses} className={roomListClasses}
onScroll={this.onScroll} onScroll={this.onScroll}
ref={this.listContainerRef} ref={this.listContainerRef}
>{roomList}</div> // Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{roomList}
</div>
</aside> </aside>
</div> </div>
); );

View file

@ -146,6 +146,7 @@ class LoggedInView extends React.Component<IProps, IState> {
protected readonly _resizeContainer: React.RefObject<ResizeHandle>; protected readonly _resizeContainer: React.RefObject<ResizeHandle>;
protected readonly _sessionStore: sessionStore; protected readonly _sessionStore: sessionStore;
protected readonly _sessionStoreToken: { remove: () => void }; protected readonly _sessionStoreToken: { remove: () => void };
protected readonly _compactLayoutWatcherRef: string;
protected resizer: Resizer; protected resizer: Resizer;
constructor(props, context) { constructor(props, context) {
@ -177,6 +178,10 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.on("sync", this.onSync); this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents); this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
this._compactLayoutWatcherRef = SettingsStore.watchSetting(
"useCompactLayout", null, this.onCompactLayoutChanged,
);
fixupColorFonts(); fixupColorFonts();
this._roomView = React.createRef(); this._roomView = React.createRef();
@ -194,6 +199,7 @@ class LoggedInView extends React.Component<IProps, IState> {
this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
SettingsStore.unwatchSetting(this._compactLayoutWatcherRef);
if (this._sessionStoreToken) { if (this._sessionStoreToken) {
this._sessionStoreToken.remove(); this._sessionStoreToken.remove();
} }
@ -263,16 +269,17 @@ class LoggedInView extends React.Component<IProps, IState> {
} }
onAccountData = (event) => { onAccountData = (event) => {
if (event.getType() === "im.vector.web.settings") {
this.setState({
useCompactLayout: event.getContent().useCompactLayout,
});
}
if (event.getType() === "m.ignored_user_list") { if (event.getType() === "m.ignored_user_list") {
dis.dispatch({action: "ignore_state_changed"}); dis.dispatch({action: "ignore_state_changed"});
} }
}; };
onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => {
this.setState({
useCompactLayout: valueAtLevel,
});
};
onSync = (syncState, oldSyncState, data) => { onSync = (syncState, oldSyncState, data) => {
const oldErrCode = ( const oldErrCode = (
this.state.syncErrorData && this.state.syncErrorData &&

View file

@ -38,6 +38,7 @@ import { Action } from "../../dispatcher/actions";
interface IProps { interface IProps {
onQueryUpdate: (newQuery: string) => void; onQueryUpdate: (newQuery: string) => void;
isMinimized: boolean; isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent);
} }
interface IState { interface IState {
@ -111,6 +112,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
if (ev.key === Key.ESCAPE) { if (ev.key === Key.ESCAPE) {
this.clearInput(); this.clearInput();
defaultDispatcher.fire(Action.FocusComposer); defaultDispatcher.fire(Action.FocusComposer);
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
this.props.onVerticalArrow(ev);
} }
}; };
@ -146,7 +149,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
let clearButton = ( let clearButton = (
<AccessibleButton <AccessibleButton
tabIndex={-1} tabIndex={-1}
className='mx_RoomSearch_clearButton' title={_t("Clear filter")}
className="mx_RoomSearch_clearButton"
onClick={this.clearInput} onClick={this.clearInput}
/> />
); );
@ -154,8 +158,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
if (this.props.isMinimized) { if (this.props.isMinimized) {
icon = ( icon = (
<AccessibleButton <AccessibleButton
tabIndex={-1} title={_t("Search rooms")}
className='mx_RoomSearch_icon' className="mx_RoomSearch_icon"
onClick={this.openSearch} onClick={this.openSearch}
/> />
); );

View file

@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as React from "react"; import React, { createRef } from "react";
import {createRef} from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import {ChevronFace, ContextMenu, ContextMenuButton} from "./ContextMenu"; import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
@ -30,7 +29,7 @@ import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme"; import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink"; import {getHostingLink} from "../../utils/HostingLink";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import {ButtonEvent} from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import {getHomePageUrl} from "../../utils/pages"; import {getHomePageUrl} from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../stores/OwnProfileStore";
@ -50,6 +49,19 @@ interface IState {
isDarkTheme: boolean; isDarkTheme: boolean;
} }
interface IMenuButtonProps {
iconClassName: string;
label: string;
onClick(ev: ButtonEvent);
}
const MenuButton: React.FC<IMenuButtonProps> = ({iconClassName, label, onClick}) => {
return <MenuItem label={label} onClick={onClick}>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{label}</span>
</MenuItem>;
};
export default class UserMenu extends React.Component<IProps, IState> { export default class UserMenu extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private themeWatcherRef: string; private themeWatcherRef: string;
@ -102,8 +114,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
private onAction = (ev: ActionPayload) => { private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested if (ev.action !== Action.ToggleUserMenu) return; // not interested
// For accessibility if (this.state.contextMenuPosition) {
this.setState({contextMenuPosition: null});
} else {
if (this.buttonRef.current) this.buttonRef.current.click(); if (this.buttonRef.current) this.buttonRef.current.click();
}
}; };
private onOpenMenuClick = (ev: React.MouseEvent) => { private onOpenMenuClick = (ev: React.MouseEvent) => {
@ -130,7 +145,10 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.setState({contextMenuPosition: null}); this.setState({contextMenuPosition: null});
}; };
private onSwitchThemeClick = () => { private onSwitchThemeClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
// Disable system theme matching if the user hits this button // Disable system theme matching if the user hits this button
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
@ -206,10 +224,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
let homeButton = null; let homeButton = null;
if (this.hasHomePage) { if (this.hasHomePage) {
homeButton = ( homeButton = (
<AccessibleButton onClick={this.onHomeClick}> <MenuButton
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" /> iconClassName="mx_UserMenu_iconHome"
<span>{_t("Home")}</span> label={_t("Home")}
</AccessibleButton> onClick={this.onHomeClick}
/>
); );
} }
@ -246,32 +265,38 @@ export default class UserMenu extends React.Component<IProps, IState> {
{hostingLink} {hostingLink}
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst"> <div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
{homeButton} {homeButton}
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> <MenuButton
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" /> iconClassName="mx_UserMenu_iconBell"
<span className="mx_IconizedContextMenu_label">{_t("Notification settings")}</span> label={_t("Notification settings")}
</AccessibleButton> onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}> />
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" /> <MenuButton
<span className="mx_IconizedContextMenu_label">{_t("Security & privacy")}</span> iconClassName="mx_UserMenu_iconLock"
</AccessibleButton> label={_t("Security & privacy")}
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}> onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" /> />
<span className="mx_IconizedContextMenu_label">{_t("All settings")}</span> <MenuButton
</AccessibleButton> iconClassName="mx_UserMenu_iconSettings"
<AccessibleButton onClick={this.onShowArchived}> label={_t("All settings")}
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" /> onClick={(e) => this.onSettingsOpen(e, null)}
<span className="mx_IconizedContextMenu_label">{_t("Archived rooms")}</span> />
</AccessibleButton> <MenuButton
<AccessibleButton onClick={this.onProvideFeedback}> iconClassName="mx_UserMenu_iconArchive"
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" /> label={_t("Archived rooms")}
<span className="mx_IconizedContextMenu_label">{_t("Feedback")}</span> onClick={this.onShowArchived}
</AccessibleButton> />
<MenuButton
iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")}
onClick={this.onProvideFeedback}
/>
</div> </div>
<div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow"> <div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}> <MenuButton
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" /> iconClassName="mx_UserMenu_iconSignOut"
<span className="mx_IconizedContextMenu_label">{_t("Sign out")}</span> label={_t("Sign out")}
</AccessibleButton> onClick={this.onSignOutClick}
/>
</div> </div>
</div> </div>
</ContextMenu> </ContextMenu>
@ -303,7 +328,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
className={classes} className={classes}
onClick={this.onOpenMenuClick} onClick={this.onOpenMenuClick}
inputRef={this.buttonRef} inputRef={this.buttonRef}
label={_t("Account settings")} label={_t("User menu")}
isExpanded={!!this.state.contextMenuPosition} isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
> >

View file

@ -132,7 +132,7 @@ const BaseAvatar = (props) => {
); );
} else { } else {
return ( return (
<span className="mx_BaseAvatar" ref={inputRef} {...otherProps}> <span className="mx_BaseAvatar" ref={inputRef} {...otherProps} role="presentation">
{ textNode } { textNode }
{ imgNode } { imgNode }
</span> </span>

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler.js'; import {_t} from '../../../languageHandler';
import Field from "./Field"; import Field from "./Field";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";

View file

@ -18,6 +18,7 @@ import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> { interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
outlined?: boolean;
} }
interface IState { interface IState {
@ -29,7 +30,7 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
}; };
public render() { public render() {
const { children, className, disabled, ...otherProps } = this.props; const { children, className, disabled, outlined, ...otherProps } = this.props;
const _className = classnames( const _className = classnames(
'mx_RadioButton', 'mx_RadioButton',
className, className,
@ -37,11 +38,12 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
"mx_RadioButton_disabled": disabled, "mx_RadioButton_disabled": disabled,
"mx_RadioButton_enabled": !disabled, "mx_RadioButton_enabled": !disabled,
"mx_RadioButton_checked": this.props.checked, "mx_RadioButton_checked": this.props.checked,
"mx_RadioButton_outlined": outlined,
}); });
return <label className={_className}> return <label className={_className}>
<input type='radio' disabled={disabled} {...otherProps} /> <input type='radio' disabled={disabled} {...otherProps} />
{/* Used to render the radio button circle */} {/* Used to render the radio button circle */}
<div><div></div></div> <div><div /></div>
<div className="mx_RadioButton_content">{children}</div> <div className="mx_RadioButton_content">{children}</div>
<div className="mx_RadioButton_spacer" /> <div className="mx_RadioButton_spacer" />
</label>; </label>;

View file

@ -32,10 +32,11 @@ interface IProps<T extends string> {
className?: string; className?: string;
definitions: IDefinition<T>[]; definitions: IDefinition<T>[];
value?: T; // if not provided no options will be selected value?: T; // if not provided no options will be selected
outlined?: boolean;
onChange(newValue: T); onChange(newValue: T);
} }
function StyledRadioGroup<T extends string>({name, definitions, value, className, onChange}: IProps<T>) { function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) {
const _onChange = e => { const _onChange = e => {
onChange(e.target.value); onChange(e.target.value);
}; };
@ -49,6 +50,7 @@ function StyledRadioGroup<T extends string>({name, definitions, value, className
name={name} name={name}
value={d.value} value={d.value}
disabled={d.disabled} disabled={d.disabled}
outlined={outlined}
> >
{d.label} {d.label}
</StyledRadioButton> </StyledRadioButton>

View file

@ -51,6 +51,7 @@ interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void; onKeyDown: (ev: React.KeyboardEvent) => void;
onFocus: (ev: React.FocusEvent) => void; onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
collapsed: boolean; collapsed: boolean;
searchFilter: string; searchFilter: string;
@ -63,8 +64,6 @@ interface IState {
} }
const TAG_ORDER: TagID[] = [ const TAG_ORDER: TagID[] = [
// -- Community Invites Placeholder --
DefaultTagID.Invite, DefaultTagID.Invite,
DefaultTagID.Favourite, DefaultTagID.Favourite,
DefaultTagID.DM, DefaultTagID.DM,
@ -76,7 +75,6 @@ const TAG_ORDER: TagID[] = [
DefaultTagID.ServerNotice, DefaultTagID.ServerNotice,
DefaultTagID.Archived, DefaultTagID.Archived,
]; ];
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority; const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
const ALWAYS_VISIBLE_TAGS: TagID[] = [ const ALWAYS_VISIBLE_TAGS: TagID[] = [
DefaultTagID.DM, DefaultTagID.DM,
@ -183,14 +181,16 @@ export default class RoomList2 extends React.Component<IProps, IState> {
layoutMap.set(tagId, new ListLayout(tagId)); layoutMap.set(tagId, new ListLayout(tagId));
} }
this.setState({sublists: newLists, layouts: layoutMap}); this.setState({sublists: newLists, layouts: layoutMap}, () => {
this.props.onResize();
});
}; };
private renderCommunityInvites(): React.ReactElement[] { private renderCommunityInvites(): React.ReactElement[] {
// TODO: Put community invites in a more sensible place (not in the room list) // TODO: Put community invites in a more sensible place (not in the room list)
return MatrixClientPeg.get().getGroups().filter(g => { return MatrixClientPeg.get().getGroups().filter(g => {
if (g.myMembership !== 'invite') return false; if (g.myMembership !== 'invite') return false;
return !this.searchFilter || this.searchFilter.matches(g.name); return !this.searchFilter || this.searchFilter.matches(g.name || "");
}).map(g => { }).map(g => {
const avatar = ( const avatar = (
<GroupAvatar <GroupAvatar
@ -224,17 +224,15 @@ export default class RoomList2 extends React.Component<IProps, IState> {
const components: React.ReactElement[] = []; const components: React.ReactElement[] = [];
for (const orderedTagId of TAG_ORDER) { for (const orderedTagId of TAG_ORDER) {
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
// Populate community invites if we have the chance
// TODO: Community invites: https://github.com/vector-im/riot-web/issues/14179
}
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) { if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
// Populate custom tags if needed // Populate custom tags if needed
// TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091 // TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091
} }
const orderedRooms = this.state.sublists[orderedTagId] || []; const orderedRooms = this.state.sublists[orderedTagId] || [];
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) { const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed continue; // skip tag - not needed
} }
@ -242,7 +240,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
components.push( components.push(
<RoomSublist2 <RoomSublist2
key={`sublist-${orderedTagId}`} key={`sublist-${orderedTagId}`}
@ -256,6 +253,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
isInvite={aesthetics.isInvite} isInvite={aesthetics.isInvite}
layout={this.state.layouts.get(orderedTagId)} layout={this.state.layouts.get(orderedTagId)}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
extraBadTilesThatShouldntExist={extraTiles} extraBadTilesThatShouldntExist={extraTiles}
/> />
); );
@ -276,9 +274,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
className="mx_RoomList2" className="mx_RoomList2"
role="tree" role="tree"
aria-label={_t("Rooms")} aria-label={_t("Rooms")}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>{sublists}</div> >{sublists}</div>
)} )}
</RovingTabIndexProvider> </RovingTabIndexProvider>

View file

@ -26,17 +26,22 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomTile2 from "./RoomTile2"; import RoomTile2 from "./RoomTile2";
import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ResizableBox, ResizeCallbackData } from "react-resizable";
import { ListLayout } from "../../../stores/room-list/ListLayout"; import { ListLayout } from "../../../stores/room-list/ListLayout";
import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import {
import StyledCheckbox from "../elements/StyledCheckbox"; ChevronFace,
import StyledRadioButton from "../elements/StyledRadioButton"; ContextMenu,
ContextMenuButton,
StyledMenuItemCheckbox,
StyledMenuItemRadio,
} from "../../structures/ContextMenu";
import RoomListStore from "../../../stores/room-list/RoomListStore2"; import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import Tooltip from "../elements/Tooltip";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard";
import StyledCheckbox from "../elements/StyledCheckbox";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -62,9 +67,10 @@ interface IProps {
onAddRoom?: () => void; onAddRoom?: () => void;
addRoomLabel: string; addRoomLabel: string;
isInvite: boolean; isInvite: boolean;
layout: ListLayout; layout?: ListLayout;
isMinimized: boolean; isMinimized: boolean;
tagId: TagID; tagId: TagID;
onResize: () => void;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here. // TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this. // You should feel bad if you use this.
@ -82,6 +88,9 @@ interface IState {
} }
export default class RoomSublist2 extends React.Component<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -132,11 +141,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private onShowAllClick = () => { private onShowAllClick = () => {
// TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
private onShowLessClick = () => { private onShowLessClick = () => {
// TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180
this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
@ -199,6 +210,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: room.roomId, room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
}); });
} }
}; };
@ -217,8 +229,53 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
sublist.scrollIntoView({behavior: 'smooth'}); sublist.scrollIntoView({behavior: 'smooth'});
} else { } else {
// on screen - toggle collapse // on screen - toggle collapse
this.toggleCollapsed();
}
};
private toggleCollapsed = () => {
this.props.layout.isCollapsed = !this.props.layout.isCollapsed; this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
this.forceUpdate(); // because the layout doesn't trigger an update this.forceUpdate(); // because the layout doesn't trigger an update
setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
};
private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
const isCollapsed = this.props.layout && this.props.layout.isCollapsed;
switch (ev.key) {
case Key.ARROW_LEFT:
ev.stopPropagation();
if (!isCollapsed) {
// On ARROW_LEFT collapse the room sublist if it isn't already
this.toggleCollapsed();
}
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
if (isCollapsed) {
// On ARROW_RIGHT expand the room sublist if it isn't already
this.toggleCollapsed();
} else if (this.sublistRef.current) {
// otherwise focus the first room
const element = this.sublistRef.current.querySelector(".mx_RoomTile2") as HTMLDivElement;
if (element) {
element.focus();
}
}
break;
}
}
};
private onKeyDown = (ev: React.KeyboardEvent) => {
switch (ev.key) {
// On ARROW_LEFT go to the sublist header
case Key.ARROW_LEFT:
ev.stopPropagation();
this.headerButton.current.focus();
break;
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
case Key.ARROW_RIGHT:
ev.stopPropagation();
} }
}; };
@ -230,10 +287,6 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tiles: React.ReactElement[] = []; const tiles: React.ReactElement[] = [];
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
if (this.props.rooms) { if (this.props.rooms) {
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles); const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
for (const room of visibleRooms) { for (const room of visibleRooms) {
@ -249,6 +302,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
} }
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
// We only have to do this because of the extra tiles. We do it conditionally // We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though // to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra // as users are unlikely to have more than a handful of tiles when the extra
@ -261,15 +318,42 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
private renderMenu(): React.ReactElement { private renderMenu(): React.ReactElement {
// TODO: Get a proper invite context menu, or take invites out of the room list.
if (this.props.tagId === DefaultTagID.Invite) {
return null;
}
let contextMenu = null; let contextMenu = null;
if (this.state.contextMenuPosition) { if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections = null;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Always show first")}
</StyledMenuItemCheckbox>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.props.layout.showPreviews}
>
{_t("Message preview")}
</StyledMenuItemCheckbox>
</div>
</React.Fragment>
);
}
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronFace={ChevronFace.None} chevronFace={ChevronFace.None}
@ -280,41 +364,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<div className="mx_RoomSublist2_contextMenu"> <div className="mx_RoomSublist2_contextMenu">
<div> <div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div> <div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div>
<StyledRadioButton <StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)} onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical} checked={!isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`} name={`mx_${this.props.tagId}_sortBy`}
> >
{_t("Activity")} {_t("Activity")}
</StyledRadioButton> </StyledMenuItemRadio>
<StyledRadioButton <StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)} onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical} checked={isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`} name={`mx_${this.props.tagId}_sortBy`}
> >
{_t("A-Z")} {_t("A-Z")}
</StyledRadioButton> </StyledMenuItemRadio>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
<StyledCheckbox
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Always show first")}
</StyledCheckbox>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
<StyledCheckbox
onChange={this.onMessagePreviewChanged}
checked={this.props.layout.showPreviews}
>
{_t("Message preview")}
</StyledCheckbox>
</div> </div>
{otherSections}
</div> </div>
</ContextMenu> </ContextMenu>
); );
@ -335,17 +402,22 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private renderHeader(): React.ReactElement { private renderHeader(): React.ReactElement {
return ( return (
<RovingTabIndexWrapper> <RovingTabIndexWrapper inputRef={this.headerButton}>
{({onFocus, isActive, ref}) => { {({onFocus, isActive, ref}) => {
// TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
let ariaLabel = _t("Jump to first unread room.");
if (this.props.tagId === DefaultTagID.Invite) {
ariaLabel = _t("Jump to first invite.");
}
const badge = ( const badge = (
<NotificationBadge <NotificationBadge
forceCount={true} forceCount={true}
notification={this.state.notificationState} notification={this.state.notificationState}
onClick={this.onBadgeClick} onClick={this.onBadgeClick}
tabIndex={tabIndex} tabIndex={tabIndex}
aria-label={ariaLabel}
/> />
); );
@ -386,14 +458,15 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// doesn't become sticky. // doesn't become sticky.
// The same applies to the notification badge. // The same applies to the notification badge.
return ( return (
<div className={classes}> <div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus} aria-label={this.props.label}>
<div className='mx_RoomSublist2_stickable'> <div className="mx_RoomSublist2_stickable">
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}
inputRef={ref} inputRef={ref}
tabIndex={tabIndex} tabIndex={tabIndex}
className={"mx_RoomSublist2_headerText"} className="mx_RoomSublist2_headerText"
role="treeitem" role="treeitem"
aria-expanded={!this.props.layout || !this.props.layout.isCollapsed}
aria-level={1} aria-level={1}
onClick={this.onHeaderClick} onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
@ -449,12 +522,12 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) showMoreText = null; if (this.props.isMinimized) showMoreText = null;
showNButton = ( showNButton = (
<div onClick={this.onShowAllClick} className={showMoreBtnClasses}> <AccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses} tabIndex={-1}>
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
{showMoreText} {showMoreText}
</div> </AccessibleButton>
); );
} else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less // we have all tiles visible - add a button to show less
@ -464,13 +537,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
</span> </span>
); );
if (this.props.isMinimized) showLessText = null; if (this.props.isMinimized) showLessText = null;
// TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180
showNButton = ( showNButton = (
<div onClick={this.onShowLessClick} className={showMoreBtnClasses}> <AccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses} tabIndex={-1}>
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
{showLessText} {showLessText}
</div> </AccessibleButton>
); );
} }
@ -520,12 +594,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
} }
// TODO: onKeyDown support: https://github.com/vector-im/riot-web/issues/14180
return ( return (
<div <div
ref={this.sublistRef}
className={classes} className={classes}
role="group" role="group"
aria-label={this.props.label} aria-label={this.props.label}
onKeyDown={this.onKeyDown}
> >
{this.renderHeader()} {this.renderHeader()}
{content} {content}

View file

@ -26,17 +26,31 @@ import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import {ChevronFace, ContextMenu, ContextMenuButton, MenuItemRadio} from "../../structures/ContextMenu"; import {
ChevronFace,
ContextMenu,
ContextMenuButton,
MenuItemRadio,
MenuItemCheckbox,
MenuItem,
} from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs"; import {
getRoomNotifsState,
setRoomNotifsState,
ALL_MESSAGES,
ALL_MESSAGES_LOUD,
MENTIONS_ONLY,
MUTE,
} from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { setRoomNotifsState } from "../../../RoomNotifs";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
import { INotificationState } from "../../../stores/notifications/INotificationState"; import { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Volume } from "../../../RoomNotifsTypes";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -68,6 +82,8 @@ interface IState {
generalMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect;
} }
const messagePreviewId = (roomId: string) => `mx_RoomTile2_messagePreview_${roomId}`;
const contextMenuBelow = (elementRect: PartialDOMRect) => { const contextMenuBelow = (elementRect: PartialDOMRect) => {
// align the context menu's icons with the icon which opened the context menu // align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset - 9; const left = elementRect.left + window.pageXOffset - 9;
@ -123,6 +139,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite; return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite;
} }
private get showMessagePreview(): boolean {
return !this.props.isMinimized && this.props.showMessagePreview;
}
public componentWillUnmount() { public componentWillUnmount() {
if (this.props.room) { if (this.props.room) {
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
@ -195,6 +215,11 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
// TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211 // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211
// TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210 // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({generalMenuPosition: null}); // hide the menu
}
}; };
private onLeaveRoomClick = (ev: ButtonEvent) => { private onLeaveRoomClick = (ev: ButtonEvent) => {
@ -219,11 +244,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({generalMenuPosition: null}); // hide the menu this.setState({generalMenuPosition: null}); // hide the menu
}; };
private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) { private async saveNotifState(ev: ButtonEvent, newState: Volume) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
if (MatrixClientPeg.get().isGuest()) return; if (MatrixClientPeg.get().isGuest()) return;
// get key before we go async and React discards the nativeEvent
const key = (ev as React.KeyboardEvent).key;
try { try {
// TODO add local echo - https://github.com/vector-im/riot-web/issues/14280 // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280
await setRoomNotifsState(this.props.room.roomId, newState); await setRoomNotifsState(this.props.room.roomId, newState);
@ -233,7 +260,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
console.error(error); console.error(error);
} }
this.setState({notificationsMenuPosition: null}); // Close the context menu if (key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({notificationsMenuPosition: null}); // hide the menu
}
} }
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES); private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
@ -241,7 +271,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY); private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
private onClickMute = ev => this.saveNotifState(ev, MUTE); private onClickMute = ev => this.saveNotifState(ev, MUTE);
private renderNotificationsMenu(): React.ReactElement { private renderNotificationsMenu(isActive: boolean): React.ReactElement {
if (MatrixClientPeg.get().isGuest() || !this.showContextMenu) { if (MatrixClientPeg.get().isGuest() || !this.showContextMenu) {
// the menu makes no sense in these cases so do not show one // the menu makes no sense in these cases so do not show one
return null; return null;
@ -287,7 +317,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const classes = classNames("mx_RoomTile2_notificationsButton", { const classes = classNames("mx_RoomTile2_notificationsButton", {
// Show bell icon for the default case too. // Show bell icon for the default case too.
mx_RoomTile2_iconBell: state === state === ALL_MESSAGES, mx_RoomTile2_iconBell: state === ALL_MESSAGES,
mx_RoomTile2_iconBellDot: state === ALL_MESSAGES_LOUD, mx_RoomTile2_iconBellDot: state === ALL_MESSAGES_LOUD,
mx_RoomTile2_iconBellMentions: state === MENTIONS_ONLY, mx_RoomTile2_iconBellMentions: state === MENTIONS_ONLY,
mx_RoomTile2_iconBellCrossed: state === MUTE, mx_RoomTile2_iconBellCrossed: state === MUTE,
@ -304,6 +334,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onClick={this.onNotificationsMenuOpenClick} onClick={this.onNotificationsMenuOpenClick}
label={_t("Notification options")} label={_t("Notification options")}
isExpanded={!!this.state.notificationsMenuPosition} isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1}
/> />
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>
@ -321,20 +352,24 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}> <ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"> <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList">
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}> <MenuItemCheckbox
onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}
active={false} // TODO: https://github.com/vector-im/riot-web/issues/14283
label={_t("Favourite")}
>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
<span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span> <span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span>
</AccessibleButton> </MenuItemCheckbox>
<AccessibleButton onClick={this.onOpenRoomSettings}> <MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span className="mx_IconizedContextMenu_label">{_t("Settings")}</span> <span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
</AccessibleButton> </MenuItem>
</div> </div>
<div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow"> <div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}> <MenuItem onClick={this.onLeaveRoomClick} label={_t("Leave Room")}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span> <span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
</AccessibleButton> </MenuItem>
</div> </div>
</div> </div>
</ContextMenu> </ContextMenu>
@ -374,8 +409,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
let badge: React.ReactNode; let badge: React.ReactNode;
if (!this.props.isMinimized) { if (!this.props.isMinimized) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = ( badge = (
<div className="mx_RoomTile2_badgeContainer"> <div className="mx_RoomTile2_badgeContainer" aria-hidden="true">
<NotificationBadge <NotificationBadge
notification={this.state.notificationState} notification={this.state.notificationState}
forceCount={false} forceCount={false}
@ -391,24 +427,25 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let messagePreview = null; let messagePreview = null;
if (this.props.showMessagePreview && !this.props.isMinimized) { if (this.showMessagePreview) {
// The preview store heavily caches this info, so should be safe to hammer. // The preview store heavily caches this info, so should be safe to hammer.
const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
// Only show the preview if there is one to show. // Only show the preview if there is one to show.
if (text) { if (text) {
messagePreview = ( messagePreview = (
<div className="mx_RoomTile2_messagePreview"> <div className="mx_RoomTile2_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
{text} {text}
</div> </div>
); );
} }
} }
const notificationColor = this.state.notificationState.color;
const nameClasses = classNames({ const nameClasses = classNames({
"mx_RoomTile2_name": true, "mx_RoomTile2_name": true,
"mx_RoomTile2_nameWithPreview": !!messagePreview, "mx_RoomTile2_nameWithPreview": !!messagePreview,
"mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold, "mx_RoomTile2_nameHasUnreadEvents": notificationColor >= NotificationColor.Bold,
}); });
let nameContainer = ( let nameContainer = (
@ -421,6 +458,27 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) nameContainer = null; if (this.props.isMinimized) nameContainer = null;
let ariaLabel = name;
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) {
// append nothing
} else if (notificationColor >= NotificationColor.Red) {
ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
count: this.state.notificationState.count,
});
} else if (notificationColor >= NotificationColor.Grey) {
ariaLabel += " " + _t("%(count)s unread messages.", {
count: this.state.notificationState.count,
});
} else if (notificationColor >= NotificationColor.Bold) {
ariaLabel += " " + _t("Unread messages.");
}
let ariaDescribedBy: string;
if (this.showMessagePreview) {
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
}
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper> <RovingTabIndexWrapper>
@ -433,14 +491,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onMouseEnter={this.onTileMouseEnter} onMouseEnter={this.onTileMouseEnter}
onMouseLeave={this.onTileMouseLeave} onMouseLeave={this.onTileMouseLeave}
onClick={this.onTileClick} onClick={this.onTileClick}
role="treeitem"
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
role="treeitem"
aria-label={ariaLabel}
aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy}
> >
{roomAvatar} {roomAvatar}
{nameContainer} {nameContainer}
{badge} {badge}
{this.renderNotificationsMenu()}
{this.renderGeneralMenu()} {this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
</AccessibleButton> </AccessibleButton>
} }
</RovingTabIndexWrapper> </RovingTabIndexWrapper>

View file

@ -34,6 +34,7 @@ import SettingsFlag from '../../../elements/SettingsFlag';
import Field from '../../../elements/Field'; import Field from '../../../elements/Field';
import EventTilePreview from '../../../elements/EventTilePreview'; import EventTilePreview from '../../../elements/EventTilePreview';
import StyledRadioGroup from "../../../elements/StyledRadioGroup"; import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import classNames from 'classnames';
interface IProps { interface IProps {
} }
@ -288,10 +289,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
}))} }))}
onChange={this.onThemeChange} onChange={this.onThemeChange}
value={this.state.useSystemTheme ? undefined : this.state.theme} value={this.state.useSystemTheme ? undefined : this.state.theme}
outlined
/> />
</div> </div>
{customThemeForm} {customThemeForm}
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} useCheckbox={true} />
</div> </div>
); );
} }
@ -344,7 +345,9 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
<span className="mx_SettingsTab_subheading">{_t("Message layout")}</span> <span className="mx_SettingsTab_subheading">{_t("Message layout")}</span>
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons"> <div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButton"> <div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.useIRCLayout,
})}>
<EventTilePreview <EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview" className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT} message={this.MESSAGE_PREVIEW_TEXT}
@ -360,7 +363,9 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</StyledRadioButton> </StyledRadioButton>
</div> </div>
<div className="mx_AppearanceUserSettingsTab_spacer" /> <div className="mx_AppearanceUserSettingsTab_spacer" />
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButton"> <div className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: !this.state.useIRCLayout,
})}>
<EventTilePreview <EventTilePreview
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview" className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
message={this.MESSAGE_PREVIEW_TEXT} message={this.MESSAGE_PREVIEW_TEXT}

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {ReactChild} from "react"; import React, {ReactNode} from "react";
import FormButton from "../elements/FormButton"; import FormButton from "../elements/FormButton";
import {XOR} from "../../../@types/common"; import {XOR} from "../../../@types/common";
export interface IProps { export interface IProps {
description: ReactChild; description: ReactNode;
acceptLabel: string; acceptLabel: string;
onAccept(); onAccept();

View file

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import { createContext } from "react"; import { createContext } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
const MatrixClientContext = createContext(undefined); const MatrixClientContext = createContext<MatrixClient>(undefined);
MatrixClientContext.displayName = "MatrixClientContext"; MatrixClientContext.displayName = "MatrixClientContext";
export default MatrixClientContext; export default MatrixClientContext;

View file

@ -15,6 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MatrixClient} from "matrix-js-sdk/src/client";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './index'; import * as sdk from './index';
@ -26,6 +29,56 @@ import {getAddressType} from "./UserAddress";
const E2EE_WK_KEY = "im.vector.riot.e2ee"; const E2EE_WK_KEY = "im.vector.riot.e2ee";
// TODO move these interfaces over to js-sdk once it has been typescripted enough to accept them
enum Visibility {
Public = "public",
Private = "private",
}
enum Preset {
PrivateChat = "private_chat",
TrustedPrivateChat = "trusted_private_chat",
PublicChat = "public_chat",
}
interface Invite3PID {
id_server: string;
id_access_token?: string; // this gets injected by the js-sdk
medium: string;
address: string;
}
interface IStateEvent {
type: string;
state_key?: string; // defaults to an empty string
content: object;
}
interface ICreateOpts {
visibility?: Visibility;
room_alias_name?: string;
name?: string;
topic?: string;
invite?: string[];
invite_3pid?: Invite3PID[];
room_version?: string;
creation_content?: object;
initial_state?: IStateEvent[];
preset?: Preset;
is_direct?: boolean;
power_level_content_override?: object;
}
interface IOpts {
dmUserId?: string;
createOpts?: ICreateOpts;
spinner?: boolean;
guestAccess?: boolean;
encryption?: boolean;
inlineErrors?: boolean;
andView?: boolean;
}
/** /**
* Create a new room, and switch to it. * Create a new room, and switch to it.
* *
@ -40,11 +93,12 @@ const E2EE_WK_KEY = "im.vector.riot.e2ee";
* Default: False * Default: False
* @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null. * @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null.
* Default: False * Default: False
* @param {bool=} opts.andView True to dispatch an action to view the room once it has been created.
* *
* @returns {Promise} which resolves to the room id, or null if the * @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed. * action was aborted or failed.
*/ */
export default function createRoom(opts) { export default function createRoom(opts: IOpts): Promise<string | null> {
opts = opts || {}; opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true; if (opts.spinner === undefined) opts.spinner = true;
if (opts.guestAccess === undefined) opts.guestAccess = true; if (opts.guestAccess === undefined) opts.guestAccess = true;
@ -59,12 +113,12 @@ export default function createRoom(opts) {
return Promise.resolve(null); return Promise.resolve(null);
} }
const defaultPreset = opts.dmUserId ? 'trusted_private_chat' : 'private_chat'; const defaultPreset = opts.dmUserId ? Preset.TrustedPrivateChat : Preset.PrivateChat;
// set some defaults for the creation // set some defaults for the creation
const createOpts = opts.createOpts || {}; const createOpts = opts.createOpts || {};
createOpts.preset = createOpts.preset || defaultPreset; createOpts.preset = createOpts.preset || defaultPreset;
createOpts.visibility = createOpts.visibility || 'private'; createOpts.visibility = createOpts.visibility || Visibility.Private;
if (opts.dmUserId && createOpts.invite === undefined) { if (opts.dmUserId && createOpts.invite === undefined) {
switch (getAddressType(opts.dmUserId)) { switch (getAddressType(opts.dmUserId)) {
case 'mx-user-id': case 'mx-user-id':
@ -166,7 +220,7 @@ export default function createRoom(opts) {
}); });
} }
export function findDMForUser(client, userId) { export function findDMForUser(client: MatrixClient, userId: string): Room {
const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId);
const rooms = roomIds.map(id => client.getRoom(id)); const rooms = roomIds.map(id => client.getRoom(id));
const suitableDMRooms = rooms.filter(r => { const suitableDMRooms = rooms.filter(r => {
@ -189,7 +243,7 @@ export function findDMForUser(client, userId) {
* NOTE: this assumes you've just created the room and there's not been an opportunity * NOTE: this assumes you've just created the room and there's not been an opportunity
* for other code to run, so we shouldn't miss RoomState.newMember when it comes by. * for other code to run, so we shouldn't miss RoomState.newMember when it comes by.
*/ */
export async function _waitForMember(client, roomId, userId, opts = { timeout: 1500 }) { export async function _waitForMember(client: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) {
const { timeout } = opts; const { timeout } = opts;
let handler; let handler;
return new Promise((resolve) => { return new Promise((resolve) => {
@ -212,7 +266,7 @@ export async function _waitForMember(client, roomId, userId, opts = { timeout: 1
* Ensure that for every user in a room, there is at least one device that we * Ensure that for every user in a room, there is at least one device that we
* can encrypt to. * can encrypt to.
*/ */
export async function canEncryptToAllUsers(client, userIds) { export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
const usersDeviceMap = await client.downloadKeys(userIds); const usersDeviceMap = await client.downloadKeys(userIds);
// { "@user:host": { "DEVICE": {...}, ... }, ... } // { "@user:host": { "DEVICE": {...}, ... }, ... }
return Object.values(usersDeviceMap).every((userDevices) => return Object.values(usersDeviceMap).every((userDevices) =>
@ -221,7 +275,7 @@ export async function canEncryptToAllUsers(client, userIds) {
); );
} }
export async function ensureDMExists(client, userId) { export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> {
const existingDMRoom = findDMForUser(client, userId); const existingDMRoom = findDMForUser(client, userId);
let roomId; let roomId;
if (existingDMRoom) { if (existingDMRoom) {

View file

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from './languageHandler.js';
import { _t } from './languageHandler';
export const GroupMemberType = PropTypes.shape({ export const GroupMemberType = PropTypes.shape({
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,

View file

@ -1200,14 +1200,16 @@
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>", "Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
"Not now": "Not now", "Not now": "Not now",
"Don't ask me again": "Don't ask me again", "Don't ask me again": "Don't ask me again",
"Sort by": "Sort by",
"Activity": "Activity",
"A-Z": "A-Z",
"Unread rooms": "Unread rooms", "Unread rooms": "Unread rooms",
"Always show first": "Always show first", "Always show first": "Always show first",
"Show": "Show", "Show": "Show",
"Message preview": "Message preview", "Message preview": "Message preview",
"Sort by": "Sort by",
"Activity": "Activity",
"A-Z": "A-Z",
"List options": "List options", "List options": "List options",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"Add room": "Add room", "Add room": "Add room",
"Show %(count)s more|other": "Show %(count)s more", "Show %(count)s more|other": "Show %(count)s more",
"Show %(count)s more|one": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more",
@ -2089,6 +2091,8 @@
"Find a room…": "Find a room…", "Find a room…": "Find a room…",
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",
"Clear filter": "Clear filter",
"Search rooms": "Search rooms",
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.", "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
@ -2100,8 +2104,6 @@
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"Active call": "Active call", "Active call": "Active call",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?", "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
"Search failed": "Search failed", "Search failed": "Search failed",
@ -2116,7 +2118,6 @@
"Click to mute video": "Click to mute video", "Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio", "Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio", "Click to mute audio": "Click to mute audio",
"Clear filter": "Clear filter",
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position", "Failed to load timeline position": "Failed to load timeline position",
@ -2131,7 +2132,7 @@
"All settings": "All settings", "All settings": "All settings",
"Archived rooms": "Archived rooms", "Archived rooms": "Archived rooms",
"Feedback": "Feedback", "Feedback": "Feedback",
"Account settings": "Account settings", "User menu": "User menu",
"Could not load user profile": "Could not load user profile", "Could not load user profile": "Could not load user profile",
"Verify this login": "Verify this login", "Verify this login": "Verify this login",
"Session verified": "Session verified", "Session verified": "Session verified",

View file

@ -1,7 +1,7 @@
/* /*
Copyright 2017 MTRNord and Cooperative EITA Copyright 2017 MTRNord and Cooperative EITA
Copyright 2017 Vector Creations Ltd. Copyright 2017 Vector Creations Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -20,10 +20,11 @@ limitations under the License.
import request from 'browser-request'; import request from 'browser-request';
import counterpart from 'counterpart'; import counterpart from 'counterpart';
import React from 'react'; import React from 'react';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
import PlatformPeg from "./PlatformPeg"; import PlatformPeg from "./PlatformPeg";
// $webapp is a webpack resolve alias pointing to the output directory, see webpack config // @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
import webpackLangJsonUrl from "$webapp/i18n/languages.json"; import webpackLangJsonUrl from "$webapp/i18n/languages.json";
const i18nFolder = 'i18n/'; const i18nFolder = 'i18n/';
@ -37,27 +38,31 @@ counterpart.setSeparator('|');
// Fall back to English // Fall back to English
counterpart.setFallbackLocale('en'); counterpart.setFallbackLocale('en');
interface ITranslatableError extends Error {
translatedMessage: string;
}
/** /**
* Helper function to create an error which has an English message * Helper function to create an error which has an English message
* with a translatedMessage property for use by the consumer. * with a translatedMessage property for use by the consumer.
* @param {string} message Message to translate. * @param {string} message Message to translate.
* @returns {Error} The constructed error. * @returns {Error} The constructed error.
*/ */
export function newTranslatableError(message) { export function newTranslatableError(message: string) {
const error = new Error(message); const error = new Error(message) as ITranslatableError;
error.translatedMessage = _t(message); error.translatedMessage = _t(message);
return error; return error;
} }
// Function which only purpose is to mark that a string is translatable // Function which only purpose is to mark that a string is translatable
// Does not actually do anything. It's helpful for automatic extraction of translatable strings // Does not actually do anything. It's helpful for automatic extraction of translatable strings
export function _td(s) { export function _td(s: string): string {
return s; return s;
} }
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly // Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
// Takes the same arguments as counterpart.translate() // Takes the same arguments as counterpart.translate()
function safeCounterpartTranslate(text, options) { function safeCounterpartTranslate(text: string, options?: object) {
// Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191 // Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null // The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else // values and instead will throw an error. This is a problem since everywhere else
@ -89,6 +94,13 @@ function safeCounterpartTranslate(text, options) {
return translated; return translated;
} }
interface IVariables {
count?: number;
[key: string]: number | string;
}
type Tags = Record<string, (sub: string) => React.ReactNode>;
/* /*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s". * @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
@ -105,7 +117,9 @@ function safeCounterpartTranslate(text, options) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function _t(text, variables, tags) { export function _t(text: string, variables?: IVariables): string;
export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode;
export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given // However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too // It is enough to pass the count variable, but in the future counterpart might make use of other information too
@ -141,23 +155,25 @@ export function _t(text, variables, tags) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function substitute(text, variables, tags) { export function substitute(text: string, variables?: IVariables): string;
let result = text; export function substitute(text: string, variables: IVariables, tags: Tags): string;
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
let result: React.ReactNode | string = text;
if (variables !== undefined) { if (variables !== undefined) {
const regexpMapping = {}; const regexpMapping: IVariables = {};
for (const variable in variables) { for (const variable in variables) {
regexpMapping[`%\\(${variable}\\)s`] = variables[variable]; regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
} }
result = replaceByRegexes(result, regexpMapping); result = replaceByRegexes(result as string, regexpMapping);
} }
if (tags !== undefined) { if (tags !== undefined) {
const regexpMapping = {}; const regexpMapping: Tags = {};
for (const tag in tags) { for (const tag in tags) {
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag]; regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
} }
result = replaceByRegexes(result, regexpMapping); result = replaceByRegexes(result as string, regexpMapping);
} }
return result; return result;
@ -172,7 +188,9 @@ export function substitute(text, variables, tags) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function replaceByRegexes(text, mapping) { export function replaceByRegexes(text: string, mapping: IVariables): string;
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
// We initially store our output as an array of strings and objects (e.g. React components). // We initially store our output as an array of strings and objects (e.g. React components).
// This will then be converted to a string or a <span> at the end // This will then be converted to a string or a <span> at the end
const output = [text]; const output = [text];
@ -189,7 +207,7 @@ export function replaceByRegexes(text, mapping) {
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects. // and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
// Otherwise there would be no need for the splitting and we could do simple replacement. // Otherwise there would be no need for the splitting and we could do simple replacement.
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
for (const outputIndex in output) { for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
const inputText = output[outputIndex]; const inputText = output[outputIndex];
if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them
continue; continue;
@ -216,7 +234,7 @@ export function replaceByRegexes(text, mapping) {
let replaced; let replaced;
// If substitution is a function, call it // If substitution is a function, call it
if (mapping[regexpString] instanceof Function) { if (mapping[regexpString] instanceof Function) {
replaced = mapping[regexpString].apply(null, capturedGroups); replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups);
} else { } else {
replaced = mapping[regexpString]; replaced = mapping[regexpString];
} }
@ -277,11 +295,11 @@ export function replaceByRegexes(text, mapping) {
// Allow overriding the text displayed when no translation exists // Allow overriding the text displayed when no translation exists
// Currently only used in unit tests to avoid having to load // Currently only used in unit tests to avoid having to load
// the translations in riot-web // the translations in riot-web
export function setMissingEntryGenerator(f) { export function setMissingEntryGenerator(f: (value: string) => void) {
counterpart.setMissingEntryGenerator(f); counterpart.setMissingEntryGenerator(f);
} }
export function setLanguage(preferredLangs) { export function setLanguage(preferredLangs: string | string[]) {
if (!Array.isArray(preferredLangs)) { if (!Array.isArray(preferredLangs)) {
preferredLangs = [preferredLangs]; preferredLangs = [preferredLangs];
} }
@ -358,8 +376,8 @@ export function getLanguageFromBrowser() {
* @param {string} language The input language string * @param {string} language The input language string
* @return {string[]} List of normalised languages * @return {string[]} List of normalised languages
*/ */
export function getNormalizedLanguageKeys(language) { export function getNormalizedLanguageKeys(language: string) {
const languageKeys = []; const languageKeys: string[] = [];
const normalizedLanguage = normalizeLanguageKey(language); const normalizedLanguage = normalizeLanguageKey(language);
const languageParts = normalizedLanguage.split('-'); const languageParts = normalizedLanguage.split('-');
if (languageParts.length === 2 && languageParts[0] === languageParts[1]) { if (languageParts.length === 2 && languageParts[0] === languageParts[1]) {
@ -380,7 +398,7 @@ export function getNormalizedLanguageKeys(language) {
* @param {string} language The language string to be normalized * @param {string} language The language string to be normalized
* @returns {string} The normalized language string * @returns {string} The normalized language string
*/ */
export function normalizeLanguageKey(language) { export function normalizeLanguageKey(language: string) {
return language.toLowerCase().replace("_", "-"); return language.toLowerCase().replace("_", "-");
} }
@ -396,7 +414,7 @@ export function getCurrentLanguage() {
* @param {string[]} langs List of language codes to pick from * @param {string[]} langs List of language codes to pick from
* @returns {string} The most appropriate language code from langs * @returns {string} The most appropriate language code from langs
*/ */
export function pickBestLanguage(langs) { export function pickBestLanguage(langs: string[]): string {
const currentLang = getCurrentLanguage(); const currentLang = getCurrentLanguage();
const normalisedLangs = langs.map(normalizeLanguageKey); const normalisedLangs = langs.map(normalizeLanguageKey);
@ -408,13 +426,13 @@ export function pickBestLanguage(langs) {
{ {
// Failing that, a different dialect of the same language // Failing that, a different dialect of the same language
const closeLangIndex = normalisedLangs.find((l) => l.substr(0, 2) === currentLang.substr(0, 2)); const closeLangIndex = normalisedLangs.findIndex((l) => l.substr(0, 2) === currentLang.substr(0, 2));
if (closeLangIndex > -1) return langs[closeLangIndex]; if (closeLangIndex > -1) return langs[closeLangIndex];
} }
{ {
// Neither of those? Try an english variant. // Neither of those? Try an english variant.
const enIndex = normalisedLangs.find((l) => l.startsWith('en')); const enIndex = normalisedLangs.findIndex((l) => l.startsWith('en'));
if (enIndex > -1) return langs[enIndex]; if (enIndex > -1) return langs[enIndex];
} }
@ -422,7 +440,7 @@ export function pickBestLanguage(langs) {
return langs[0]; return langs[0];
} }
function getLangsJson() { function getLangsJson(): Promise<object> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
let url; let url;
if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through
@ -443,7 +461,7 @@ function getLangsJson() {
}); });
} }
function weblateToCounterpart(inTrs) { function weblateToCounterpart(inTrs: object): object {
const outTrs = {}; const outTrs = {};
for (const key of Object.keys(inTrs)) { for (const key of Object.keys(inTrs)) {
@ -463,7 +481,7 @@ function weblateToCounterpart(inTrs) {
return outTrs; return outTrs;
} }
function getLanguage(langPath) { function getLanguage(langPath: string): object {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request( request(
{ method: "GET", url: langPath }, { method: "GET", url: langPath },

View file

@ -202,7 +202,7 @@ export const SETTINGS = {
default: false, default: false,
}, },
"useCompactLayout": { "useCompactLayout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
displayName: _td('Use a more compact Modern layout'), displayName: _td('Use a more compact Modern layout'),
default: false, default: false,
}, },

View file

@ -85,8 +85,8 @@ export class ListLayout {
} }
public get defaultVisibleTiles(): number { public get defaultVisibleTiles(): number {
// 10 is what "feels right", and mostly subject to design's opinion. // This number is what "feels right", and mostly subject to design's opinion.
return 10 + RESIZER_BOX_FACTOR; return 5 + RESIZER_BOX_FACTOR;
} }
public setVisibleTilesWithin(diff: number, maxPossible: number) { public setVisibleTilesWithin(diff: number, maxPossible: number) {

View file

@ -101,6 +101,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`); console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
this.algorithm.stickyRoom = null; this.algorithm.stickyRoom = null;
} else if (activeRoom !== this.algorithm.stickyRoom) { } else if (activeRoom !== this.algorithm.stickyRoom) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Changing sticky room to ${activeRoomId}`);
this.algorithm.stickyRoom = activeRoom; this.algorithm.stickyRoom = activeRoom;
} }
} }
@ -297,6 +299,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> { private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) { if (shouldUpdate) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
this.emit(LISTS_UPDATE_EVENT, this); this.emit(LISTS_UPDATE_EVENT, this);
} }
} }
@ -363,6 +367,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
} }
private onAlgorithmListUpdated = () => { private onAlgorithmListUpdated = () => {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Underlying algorithm has triggered a list update - refiring");
this.emit(LISTS_UPDATE_EVENT, this); this.emit(LISTS_UPDATE_EVENT, this);
}; };
@ -402,6 +408,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
} }
public addFilter(filter: IFilterCondition): void { public addFilter(filter: IFilterCondition): void {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Adding filter condition:", filter);
this.filterConditions.push(filter); this.filterConditions.push(filter);
if (this.algorithm) { if (this.algorithm) {
this.algorithm.addFilterCondition(filter); this.algorithm.addFilterCondition(filter);
@ -409,6 +417,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
} }
public removeFilter(filter: IFilterCondition): void { public removeFilter(filter: IFilterCondition): void {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Removing filter condition:", filter);
const idx = this.filterConditions.indexOf(filter); const idx = this.filterConditions.indexOf(filter);
if (idx >= 0) { if (idx >= 0) {
this.filterConditions.splice(idx, 1); this.filterConditions.splice(idx, 1);

View file

@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { arrayHasDiff, ArrayUtil } from "../../../utils/arrays"; import { arrayDiff, arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
import { getEnumValues } from "../../../utils/enums"; import { getEnumValues } from "../../../utils/enums";
import { DefaultTagID, RoomUpdateCause, TagID } from "../models"; import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
import { import {
@ -57,6 +57,7 @@ export class Algorithm extends EventEmitter {
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
private filteredRooms: ITagMap = {}; private filteredRooms: ITagMap = {};
private _stickyRoom: IStickyRoom = null; private _stickyRoom: IStickyRoom = null;
private _lastStickyRoom: IStickyRoom = null; // only not-null when changing the sticky room
private sortAlgorithms: ITagSortingMap; private sortAlgorithms: ITagSortingMap;
private listAlgorithms: IListOrderingMap; private listAlgorithms: IListOrderingMap;
private algorithms: IOrderingAlgorithmMap; private algorithms: IOrderingAlgorithmMap;
@ -162,9 +163,21 @@ export class Algorithm extends EventEmitter {
} }
private async updateStickyRoom(val: Room) { private async updateStickyRoom(val: Room) {
try {
return await this.doUpdateStickyRoom(val);
} finally {
this._lastStickyRoom = null; // clear to indicate we're done changing
}
}
private async doUpdateStickyRoom(val: Room) {
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms. // otherwise we risk duplicating rooms.
// Set the last sticky room to indicate that we're in a change. The code throughout the
// class can safely handle a null room, so this should be safe to do as a backup.
this._lastStickyRoom = this._stickyRoom || <IStickyRoom>{};
// It's possible to have no selected room. In that case, clear the sticky room // It's possible to have no selected room. In that case, clear the sticky room
if (!val) { if (!val) {
if (this._stickyRoom) { if (this._stickyRoom) {
@ -179,7 +192,7 @@ export class Algorithm extends EventEmitter {
} }
// When we do have a room though, we expect to be able to find it // When we do have a room though, we expect to be able to find it
const tag = this.roomIdsToTags[val.roomId][0]; let tag = this.roomIdsToTags[val.roomId][0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`); if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which // We specifically do NOT use the ordered rooms set as it contains the sticky room, which
@ -196,19 +209,41 @@ export class Algorithm extends EventEmitter {
// the same thing it no-ops. After we're done calling the algorithm, we'll issue // the same thing it no-ops. After we're done calling the algorithm, we'll issue
// a new update for ourselves. // a new update for ourselves.
const lastStickyRoom = this._stickyRoom; const lastStickyRoom = this._stickyRoom;
this._stickyRoom = null; this._stickyRoom = null; // clear before we update the algorithm
this.recalculateStickyRoom(); this.recalculateStickyRoom();
// When we do have the room, re-add the old room (if needed) to the algorithm // When we do have the room, re-add the old room (if needed) to the algorithm
// and remove the sticky room from the algorithm. This is so the underlying // and remove the sticky room from the algorithm. This is so the underlying
// algorithm doesn't try and confuse itself with the sticky room concept. // algorithm doesn't try and confuse itself with the sticky room concept.
if (lastStickyRoom) { // We don't add the new room if the sticky room isn't changing because that's
// an easy way to cause duplication. We have to do room ID checks instead of
// referential checks as the references can differ through the lifecycle.
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
// Lie to the algorithm and re-add the room to the algorithm // Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom); await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
} }
// Lie to the algorithm and remove the room from it's field of view // Lie to the algorithm and remove the room from it's field of view
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved); await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// Check for tag & position changes while we're here. We also check the room to ensure
// it is still the same room.
if (this._stickyRoom) {
if (this._stickyRoom.room !== val) {
// Check the room IDs just in case
if (this._stickyRoom.room.roomId === val.roomId) {
console.warn("Sticky room changed references");
} else {
throw new Error("Sticky room changed while the sticky room was changing");
}
}
console.warn(`Sticky room changed tag & position from ${tag} / ${position} `
+ `to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`);
tag = this._stickyRoom.tag;
position = this._stickyRoom.position;
}
// Now that we're done lying to the algorithm, we need to update our position // Now that we're done lying to the algorithm, we need to update our position
// marker only if the user is moving further down the same list. If they're switching // marker only if the user is moving further down the same list. If they're switching
// lists, or moving upwards, the position marker will splice in just fine but if // lists, or moving upwards, the position marker will splice in just fine but if
@ -272,6 +307,9 @@ export class Algorithm extends EventEmitter {
} }
} }
newMap[tagId] = allowedRoomsInThisTag; newMap[tagId] = allowedRoomsInThisTag;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
} }
const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]); const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
@ -307,6 +345,9 @@ export class Algorithm extends EventEmitter {
if (filteredRooms.length > 0) { if (filteredRooms.length > 0) {
this.filteredRooms[tagId] = filteredRooms; this.filteredRooms[tagId] = filteredRooms;
} }
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
} }
protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) { protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) {
@ -345,6 +386,8 @@ export class Algorithm extends EventEmitter {
} }
if (!this._cachedStickyRooms || !updatedTag) { if (!this._cachedStickyRooms || !updatedTag) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Generating clone of cached rooms for sticky room handling`);
const stickiedTagMap: ITagMap = {}; const stickiedTagMap: ITagMap = {};
for (const tagId of Object.keys(this.cachedRooms)) { for (const tagId of Object.keys(this.cachedRooms)) {
stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone
@ -355,6 +398,8 @@ export class Algorithm extends EventEmitter {
if (updatedTag) { if (updatedTag) {
// Update the tag indicated by the caller, if possible. This is mostly to ensure // Update the tag indicated by the caller, if possible. This is mostly to ensure
// our cache is up to date. // our cache is up to date.
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Replacing cached sticky rooms for ${updatedTag}`);
this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone
} }
@ -363,6 +408,8 @@ export class Algorithm extends EventEmitter {
// we might have updated from the cache is also our sticky room. // we might have updated from the cache is also our sticky room.
const sticky = this._stickyRoom; const sticky = this._stickyRoom;
if (!updatedTag || updatedTag === sticky.tag) { if (!updatedTag || updatedTag === sticky.tag) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room); this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
} }
@ -454,9 +501,13 @@ export class Algorithm extends EventEmitter {
// Split out the easy rooms first (leave and invite) // Split out the easy rooms first (leave and invite)
const memberships = splitRoomsByMembership(rooms); const memberships = splitRoomsByMembership(rooms);
for (const room of memberships[EffectiveMembership.Invite]) { for (const room of memberships[EffectiveMembership.Invite]) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
newTags[DefaultTagID.Invite].push(room); newTags[DefaultTagID.Invite].push(room);
} }
for (const room of memberships[EffectiveMembership.Leave]) { for (const room of memberships[EffectiveMembership.Leave]) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
newTags[DefaultTagID.Archived].push(room); newTags[DefaultTagID.Archived].push(room);
} }
@ -467,7 +518,11 @@ export class Algorithm extends EventEmitter {
let inTag = false; let inTag = false;
if (tags.length > 0) { if (tags.length > 0) {
for (const tag of tags) { for (const tag of tags) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
if (!isNullOrUndefined(newTags[tag])) { if (!isNullOrUndefined(newTags[tag])) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`);
newTags[tag].push(room); newTags[tag].push(room);
inTag = true; inTag = true;
} }
@ -477,6 +532,9 @@ export class Algorithm extends EventEmitter {
if (!inTag) { if (!inTag) {
// TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236 // TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236
newTags[DefaultTagID.Untagged].push(room); newTags[DefaultTagID.Untagged].push(room);
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`);
} }
} }
@ -537,7 +595,7 @@ export class Algorithm extends EventEmitter {
/** /**
* Updates the roomsToTags map * Updates the roomsToTags map
*/ */
protected updateTagsFromCache() { private updateTagsFromCache() {
const newMap = {}; const newMap = {};
const tags = Object.keys(this.cachedRooms); const tags = Object.keys(this.cachedRooms);
@ -584,21 +642,94 @@ export class Algorithm extends EventEmitter {
* processing. * processing.
*/ */
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) { if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
const roomTags = this.roomIdsToTags[room.roomId]; const roomTags = this.roomIdsToTags[room.roomId];
if (roomTags && roomTags.length > 0) { const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange; cause = RoomUpdateCause.PossibleTagChange;
} }
// If we have tags for a room and don't have the room referenced, the room reference
// probably changed. We need to swap out the problematic reference.
if (hasTags && !this.rooms.includes(room) && !isSticky) {
console.warn(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
// Sanity check
if (!this.rooms.includes(room)) {
throw new Error(`Failed to replace ${room.roomId} with an updated reference`);
}
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
} }
if (cause === RoomUpdateCause.PossibleTagChange) { if (cause === RoomUpdateCause.PossibleTagChange) {
// TODO: Be smarter and splice rather than regen the planet. https://github.com/vector-im/riot-web/issues/14035 let didTagChange = false;
// TODO: No-op if no change. https://github.com/vector-im/riot-web/issues/14035 const oldTags = this.roomIdsToTags[room.roomId] || [];
await this.setKnownRooms(this.rooms); const newTags = this.getTagsForRoom(room);
return true; const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Removing ${room.roomId} from ${rmTag}`);
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
}
for (const addTag of diff.added) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Adding ${room.roomId} to ${addTag}`);
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.warn(`Received no-op update for ${room.roomId} - changing to Timeline update`);
cause = RoomUpdateCause.Timeline;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
} else {
// We have to clear the lock as the sticky room change will trigger updates.
await this.setStickyRoomAsync(room);
}
}
} }
// If the update is for a room change which might be the sticky room, prevent it. We // If the update is for a room change which might be the sticky room, prevent it. We
@ -612,8 +743,9 @@ export class Algorithm extends EventEmitter {
} }
} }
if (cause === RoomUpdateCause.NewRoom && !this.roomIdsToTags[room.roomId]) { if (!this.roomIdsToTags[room.roomId]) {
console.log(`[RoomListDebug] Updating tags for new room ${room.roomId} (${room.name})`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
// Get the tags for the room and populate the cache // Get the tags for the room and populate the cache
const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
@ -623,9 +755,15 @@ export class Algorithm extends EventEmitter {
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
this.roomIdsToTags[room.roomId] = roomTags; this.roomIdsToTags[room.roomId] = roomTags;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
} }
let tags = this.roomIdsToTags[room.roomId]; // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
const tags = this.roomIdsToTags[room.roomId];
if (!tags) { if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`); console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false; return false;
@ -645,6 +783,8 @@ export class Algorithm extends EventEmitter {
changed = true; changed = true;
} }
return true; // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`);
return changed;
} }
} }

View file

@ -87,6 +87,9 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm); super(tagId, initialSortingAlgorithm);
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Constructed an ImportanceAlgorithm for ${tagId}`);
} }
// noinspection JSMethodCanBeStatic // noinspection JSMethodCanBeStatic

View file

@ -28,6 +28,9 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm); super(tagId, initialSortingAlgorithm);
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Constructed a NaturalAlgorithm for ${tagId}`);
} }
public async setRooms(rooms: Room[]): Promise<any> { public async setRooms(rooms: Room[]): Promise<any> {

View file

@ -19,6 +19,7 @@ import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm"; import { IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread"; import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../membership";
/** /**
* Sorts rooms according to the last event's timestamp in each room that seems * Sorts rooms according to the last event's timestamp in each room that seems
@ -37,6 +38,8 @@ export class RecentAlgorithm implements IAlgorithm {
// actually changed (probably needs to be done higher up?) then we could do an // actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes. // insertion sort or similar on the limited set of changes.
const myUserId = MatrixClientPeg.get().getUserId();
const tsCache: { [roomId: string]: number } = {}; const tsCache: { [roomId: string]: number } = {};
const getLastTs = (r: Room) => { const getLastTs = (r: Room) => {
if (tsCache[r.roomId]) { if (tsCache[r.roomId]) {
@ -50,13 +53,23 @@ export class RecentAlgorithm implements IAlgorithm {
return Number.MAX_SAFE_INTEGER; return Number.MAX_SAFE_INTEGER;
} }
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) { for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i]; const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
// TODO: Don't assume we're using the same client as the peg // TODO: Don't assume we're using the same client as the peg
if (ev.getSender() === MatrixClientPeg.get().getUserId() if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
|| Unread.eventTriggersUnreadCount(ev)) {
return ev.getTs(); return ev.getTs();
} }
} }

View file

@ -52,6 +52,8 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
const beforeRoomIds = this.roomIds; const beforeRoomIds = this.roomIds;
this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId); this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId);
if (arrayHasDiff(beforeRoomIds, this.roomIds)) { if (arrayHasDiff(beforeRoomIds, this.roomIds)) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Updating filter for group: ", this.community.groupId);
this.emit(FILTER_CHANGED); this.emit(FILTER_CHANGED);
} }
}; };

View file

@ -41,6 +41,8 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
public set search(val: string) { public set search(val: string) {
this._search = val; this._search = val;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Updating filter for room name search:", this._search);
this.emit(FILTER_CHANGED); this.emit(FILTER_CHANGED);
} }

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
// Returns a promise which resolves with a given value after the given number of ms // Returns a promise which resolves with a given value after the given number of ms
export function sleep<T>(ms: number, value: T): Promise<T> { export function sleep<T>(ms: number, value?: T): Promise<T> {
return new Promise((resolve => { setTimeout(resolve, ms, value); })); return new Promise((resolve => { setTimeout(resolve, ms, value); }));
} }

View file

@ -1257,6 +1257,11 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ== integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==
"@types/counterpart@^0.18.1":
version "0.18.1"
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
"@types/fbemitter@*": "@types/fbemitter@*":
version "2.0.32" version "2.0.32"
resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c" resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c"
@ -5821,8 +5826,8 @@ mathml-tag-names@^2.0.1:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "7.0.0" version "7.1.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f683f4544aa5da150836b01c754062809119fa97" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2a688bdac828dc62916437d83c72cef1e525d5f9"
dependencies: dependencies:
"@babel/runtime" "^7.8.3" "@babel/runtime" "^7.8.3"
another-json "^0.2.0" another-json "^0.2.0"