Merge branch 'develop' into travis/room-list/breadcrumbs

This commit is contained in:
Travis Ralston 2020-06-09 08:11:08 -06:00
commit 9377fa513f
28 changed files with 857 additions and 258 deletions

View file

@ -19,6 +19,7 @@
@import "./structures/_NotificationPanel.scss"; @import "./structures/_NotificationPanel.scss";
@import "./structures/_RightPanel.scss"; @import "./structures/_RightPanel.scss";
@import "./structures/_RoomDirectory.scss"; @import "./structures/_RoomDirectory.scss";
@import "./structures/_RoomSearch.scss";
@import "./structures/_RoomStatusBar.scss"; @import "./structures/_RoomStatusBar.scss";
@import "./structures/_RoomSubList.scss"; @import "./structures/_RoomSubList.scss";
@import "./structures/_RoomView.scss"; @import "./structures/_RoomView.scss";
@ -171,6 +172,7 @@
@import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MemberList.scss";
@import "./views/rooms/_MessageComposer.scss"; @import "./views/rooms/_MessageComposer.scss";
@import "./views/rooms/_MessageComposerFormatBar.scss"; @import "./views/rooms/_MessageComposerFormatBar.scss";
@import "./views/rooms/_NotificationBadge.scss";
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PinnedEventsPanel.scss"; @import "./views/rooms/_PinnedEventsPanel.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";

View file

@ -88,7 +88,44 @@ $roomListMinimizedWidth: 50px;
} }
.mx_LeftPanel2_filterContainer { .mx_LeftPanel2_filterContainer {
// TODO: Improve CSS for filtering and its input margin-left: 12px;
margin-right: 12px;
// Create a flexbox to organize the inputs
display: flex;
align-items: center;
.mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
// Cheaty way to return the occupied space to the filter input
margin: 0;
width: 0;
// Don't forget to hide the masked ::before icon
visibility: hidden;
}
.mx_LeftPanel2_exploreButton {
width: 28px;
height: 28px;
border-radius: 20px;
background-color: #fff; // TODO: Variable and theme
position: relative;
margin-left: 8px;
&::before {
content: '';
position: absolute;
top: 6px;
left: 6px;
width: 16px;
height: 16px;
mask-image: url('$(res)/img/feather-customised/compass.svg');
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $primary-fg-color;
}
}
} }
.mx_LeftPanel2_actualRoomListContainer { .mx_LeftPanel2_actualRoomListContainer {

View file

@ -0,0 +1,70 @@
/*
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.
*/
// Note: this component expects to be contained within a flexbox
.mx_RoomSearch {
flex: 1;
border-radius: 20px;
background-color: #fff; // TODO: Variable & theme
height: 26px;
padding: 2px;
// Create a flexbox for the icons (easier to manage)
display: flex;
align-items: center;
.mx_RoomSearch_icon {
width: 16px;
height: 16px;
mask: url('$(res)/img/feather-customised/search-input.svg');
mask-repeat: no-repeat;
background: $primary-fg-color;
margin-left: 7px;
}
.mx_RoomSearch_input {
border: none !important; // !important to override default app-wide styles
flex: 1 !important; // !important to override default app-wide styles
color: $primary-fg-color !important; // !important to override default app-wide styles
padding: 0;
height: 100%;
width: 100%;
font-size: $font-12px;
line-height: $font-16px;
&:not(.mx_RoomSearch_inputExpanded)::placeholder {
color: $primary-fg-color !important; // !important to override default app-wide styles
}
}
&.mx_RoomSearch_expanded {
.mx_RoomSearch_clearButton {
width: 16px;
height: 16px;
mask-image: url('$(res)/img/feather-customised/x.svg');
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $primary-fg-color;
margin-right: 8px;
}
}
.mx_RoomSearch_clearButton {
width: 0;
height: 0;
}
}

View file

@ -0,0 +1,72 @@
/*
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.
*/
.mx_NotificationBadge {
&:not(.mx_NotificationBadge_visible) {
display: none;
}
// Badges are structured a bit weirdly to work around issues with non-monospace
// font styles. The badge pill is actually a background div and the count floats
// within that. For example:
//
// ( 99+ ) <-- Rounded pill is a _bg class.
// ^- The count is an element floating within that.
&.mx_NotificationBadge_visible {
background-color: $roomtile2-badge-color;
margin-right: 14px;
// Create a flexbox to order the count a bit easier
display: flex;
align-items: center;
justify-content: center;
&.mx_NotificationBadge_highlighted {
// TODO: Use a more specific variable
background-color: $warning-color;
}
// These are the 3 background types
&.mx_NotificationBadge_dot {
width: 6px;
height: 6px;
border-radius: 6px;
margin-right: 18px;
}
&.mx_NotificationBadge_2char {
width: 16px;
height: 16px;
border-radius: 16px;
}
&.mx_NotificationBadge_3char {
width: 26px;
height: 16px;
border-radius: 16px;
}
// The following is the floating badge
.mx_NotificationBadge_count {
font-size: $font-10px;
line-height: $font-14px;
color: #fff; // TODO: Variable
}
}
}

View file

@ -30,11 +30,36 @@ limitations under the License.
margin-bottom: 12px; margin-bottom: 12px;
.mx_RoomSublist2_headerContainer { .mx_RoomSublist2_headerContainer {
// Create a flexbox to make ordering easy
display: flex;
align-items: center;
.mx_RoomSublist2_badgeContainer {
opacity: 0.8;
padding-right: 7px;
// Create another flexbox row because it's super easy to position the badge at
// the end this way.
display: flex;
align-items: center;
justify-content: flex-end;
}
.mx_RoomSublist2_headerText {
text-transform: uppercase; text-transform: uppercase;
opacity: 0.5; opacity: 0.5;
line-height: $font-16px; line-height: $font-16px;
font-size: $font-12px; font-size: $font-12px;
padding-bottom: 8px; padding-bottom: 8px;
width: 100%;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
} }
.mx_RoomSublist2_resizeBox { .mx_RoomSublist2_resizeBox {

View file

@ -50,11 +50,14 @@ limitations under the License.
// TODO: Ellipsis on the name and preview // TODO: Ellipsis on the name and preview
.mx_RoomTile2_name { .mx_RoomTile2_name {
font-weight: 600;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-19px; line-height: $font-19px;
} }
.mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents {
font-weight: 600;
}
.mx_RoomTile2_messagePreview { .mx_RoomTile2_messagePreview {
font-size: $font-13px; font-size: $font-13px;
line-height: $font-18px; line-height: $font-18px;
@ -70,34 +73,5 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
.mx_RoomTile2_badge {
background-color: $roomtile2-badge-color;
&:not(.mx_RoomTile2_badgeEmpty) {
border-radius: 16px;
font-size: $font-10px;
line-height: $font-14px;
text-align: center;
font-weight: bold;
margin-right: 14px;
color: #fff; // TODO: Variable
// TODO: Confirm padding on counted badges
padding: 2px 5px;
}
&.mx_RoomTile2_badgeEmpty {
width: 6px;
height: 6px;
border-radius: 6px;
margin-right: 18px;
}
&.mx_RoomTile2_badgeHighlight {
// TODO: Use a more specific variable
background-color: $warning-color;
}
}
} }
} }

View file

@ -43,3 +43,7 @@ limitations under the License.
padding-left: 20px; padding-left: 20px;
padding-right: 5px; padding-right: 5px;
} }
.mx_SettingsTab_customFontSizeField {
margin-left: calc($font-16px + 10px);
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-compass"><circle cx="12" cy="12" r="10"></circle><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon></svg>

After

Width:  |  Height:  |  Size: 342 B

View file

@ -22,9 +22,10 @@ import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import * as sdk from "../../index"; import * as sdk from "../../index";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'}); const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
const onClickExplore = () => dis.dispatch({action: 'view_room_directory'}); const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'}); const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
const HomePage = () => { const HomePage = () => {

View file

@ -252,7 +252,7 @@ const LeftPanel = createReactClass({
if (!this.props.collapsed) { if (!this.props.collapsed) {
exploreButton = ( exploreButton = (
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}> <div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton> <AccessibleButton onClick={() => dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}</AccessibleButton>
</div> </div>
); );
} }

View file

@ -18,15 +18,15 @@ import * as React from "react";
import TagPanel from "./TagPanel"; import TagPanel from "./TagPanel";
import classNames from "classnames"; import classNames from "classnames";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import AccessibleButton from "../views/elements/AccessibleButton";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import SearchBox from "./SearchBox"; import SearchBox from "./SearchBox";
import RoomList2 from "../views/rooms/RoomList2"; import RoomList2 from "../views/rooms/RoomList2";
import TopLeftMenuButton from "./TopLeftMenuButton";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseAvatar from '../views/avatars/BaseAvatar'; import BaseAvatar from '../views/avatars/BaseAvatar';
import UserMenuButton from "./UserMenuButton"; import UserMenuButton from "./UserMenuButton";
import RoomSearch from "./RoomSearch";
import AccessibleButton from "../views/elements/AccessibleButton";
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2"; import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
@ -44,7 +44,6 @@ interface IProps {
} }
interface IState { interface IState {
searchExpanded: boolean;
searchFilter: string; // TODO: Move search into room list? searchFilter: string; // TODO: Move search into room list?
showBreadcrumbs: boolean; showBreadcrumbs: boolean;
} }
@ -61,7 +60,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
super(props); super(props);
this.state = { this.state = {
searchExpanded: false,
searchFilter: "", searchFilter: "",
showBreadcrumbs: BreadcrumbsStore.instance.visible, showBreadcrumbs: BreadcrumbsStore.instance.visible,
}; };
@ -77,24 +75,10 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
this.setState({searchFilter: term}); this.setState({searchFilter: term});
}; };
private onSearchCleared = (source: string): void => { private onExplore = () => {
if (source === "keyboard") { dis.fire(Action.ViewRoomDirectory);
dis.fire(Action.FocusComposer);
}
this.setState({searchExpanded: false});
}
private onSearchFocus = (): void => {
this.setState({searchExpanded: true});
}; };
private onSearchBlur = (event: FocusEvent): void => {
const target = event.target as HTMLInputElement;
if (target.value.length === 0) {
this.setState({searchExpanded: false});
}
}
private onBreadcrumbsUpdate = () => { private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible; const newVal = BreadcrumbsStore.instance.visible;
if (newVal !== this.state.showBreadcrumbs) { if (newVal !== this.state.showBreadcrumbs) {
@ -151,6 +135,22 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
); );
} }
private renderSearchExplore(): React.ReactNode {
// TODO: Collapsed support
return (
<div className="mx_LeftPanel2_filterContainer">
<RoomSearch onQueryUpdate={this.onSearch} />
<AccessibleButton
tabIndex={-1}
className='mx_LeftPanel2_exploreButton'
onClick={this.onExplore}
alt={_t("Explore rooms")}
/>
</div>
);
}
public render(): React.ReactNode { public render(): React.ReactNode {
const tagPanel = ( const tagPanel = (
<div className="mx_LeftPanel2_tagPanelContainer"> <div className="mx_LeftPanel2_tagPanelContainer">
@ -158,18 +158,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
</div> </div>
); );
const searchBox = (<SearchBox
className="mx_LeftPanel2_filterRoomsSearch"
enableRoomSearchFocus={true}
blurredPlaceholder={_t('Filter')}
placeholder={_t('Filter rooms…')}
onKeyDown={() => {/*TODO*/}}
onSearch={this.onSearch}
onCleared={this.onSearchCleared}
onFocus={this.onSearchFocus}
onBlur={this.onSearchBlur}
collapsed={false}/>); // TODO: Collapsed support
// TODO: Improve props for RoomList2 // TODO: Improve props for RoomList2
const roomList = <RoomList2 const roomList = <RoomList2
onKeyDown={() => {/*TODO*/}} onKeyDown={() => {/*TODO*/}}
@ -191,14 +179,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
{tagPanel} {tagPanel}
<aside className="mx_LeftPanel2_roomListContainer"> <aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()} {this.renderHeader()}
<div {this.renderSearchExplore()}
className="mx_LeftPanel2_filterContainer"
onKeyDown={() => {/*TODO*/}}
onFocus={() => {/*TODO*/}}
onBlur={() => {/*TODO*/}}
>
{searchBox}
</div>
<div className="mx_LeftPanel2_actualRoomListContainer"> <div className="mx_LeftPanel2_actualRoomListContainer">
{roomList} {roomList}
</div> </div>

View file

@ -624,7 +624,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog); Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
break; break;
} }
case 'view_room_directory': { case Action.ViewRoomDirectory: {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
'mx_RoomDirectory_dialogWrapper', false, true); 'mx_RoomDirectory_dialogWrapper', false, true);
@ -1611,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'require_registration', action: 'require_registration',
}); });
} else if (screen === 'directory') { } else if (screen === 'directory') {
dis.dispatch({ dis.fire(Action.ViewRoomDirectory);
action: 'view_room_directory',
});
} else if (screen === 'groups') { } else if (screen === 'groups') {
dis.dispatch({ dis.dispatch({
action: 'view_my_groups', action: 'view_my_groups',

View file

@ -0,0 +1,144 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import { ActionPayload } from "../../dispatcher/payloads";
import { throttle } from 'lodash';
import { Key } from "../../Keyboard";
import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
onQueryUpdate: (newQuery: string) => void;
}
interface IState {
query: string;
focused: boolean;
}
export default class RoomSearch extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private inputRef: React.RefObject<HTMLInputElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
query: "",
focused: false,
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'view_room' && payload.clear_search) {
this.clearInput();
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
this.inputRef.current.focus();
}
};
private clearInput = () => {
if (!this.inputRef.current) return;
this.inputRef.current.value = "";
this.onChange();
};
private onChange = () => {
if (!this.inputRef.current) return;
this.setState({query: this.inputRef.current.value});
this.onSearchUpdated();
};
// it wants this at the top of the file, but we know better
// tslint:disable-next-line
private onSearchUpdated = throttle(
() => {
// We can't use the state variable because it can lag behind the input.
// The lag is most obvious when deleting/clearing text with the keyboard.
this.props.onQueryUpdate(this.inputRef.current.value);
}, 200, {trailing: true, leading: true},
);
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
this.setState({focused: true});
ev.target.select();
};
private onBlur = () => {
this.setState({focused: false});
};
private onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === Key.ESCAPE) {
this.clearInput();
defaultDispatcher.fire(Action.FocusComposer);
}
};
public render(): React.ReactNode {
const classes = classNames({
'mx_RoomSearch': true,
'mx_RoomSearch_expanded': this.state.query || this.state.focused,
});
const inputClasses = classNames({
'mx_RoomSearch_input': true,
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
});
return (
<div className={classes}>
<div className='mx_RoomSearch_icon'/>
<input
type="text"
ref={this.inputRef}
className={inputClasses}
value={this.state.query}
onFocus={this.onFocus}
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={_t("Search")}
autoComplete="off"
/>
<AccessibleButton
tabIndex={-1}
className='mx_RoomSearch_clearButton'
onClick={this.clearInput}
/>
</div>
);
}
}

View file

@ -1458,9 +1458,7 @@ export default createReactClass({
// using /leave rather than /join. In the short term though, we // using /leave rather than /join. In the short term though, we
// just ignore them. // just ignore them.
// https://github.com/vector-im/vector-web/issues/1134 // https://github.com/vector-im/vector-web/issues/1134
dis.dispatch({ dis.fire(Action.ViewRoomDirectory);
action: 'view_room_directory',
});
}, },
onSearchClick: function() { onSearchClick: function() {

View file

@ -18,11 +18,12 @@ import React from 'react';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {Action} from "../../../dispatcher/actions";
const RoomDirectoryButton = function(props) { const RoomDirectoryButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton'); const ActionButton = sdk.getComponent('elements.ActionButton');
return ( return (
<ActionButton action="view_room_directory" <ActionButton action={Action.ViewRoomDirectory}
mouseOverAction={props.callout ? "callout_room_directory" : null} mouseOverAction={props.callout ? "callout_room_directory" : null}
label={_t("Room directory")} label={_t("Room directory")}
iconPath={require("../../../../res/img/icons-directory.svg")} iconPath={require("../../../../res/img/icons-directory.svg")}

View file

@ -0,0 +1,279 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
import { Room } from "matrix-js-sdk/src/models/room";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import * as RoomNotifs from '../../../RoomNotifs';
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
import * as Unread from '../../../Unread';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { EventEmitter } from "events";
import { arrayDiff } from "../../../utils/arrays";
export const NOTIFICATION_STATE_UPDATE = "update";
export enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
Bold, // no badge, show as unread
Grey, // unread notified messages
Red, // unread pings
}
export interface INotificationState extends EventEmitter {
symbol?: string;
count: number;
color: NotificationColor;
}
interface IProps {
notification: INotificationState;
/**
* If true, the badge will conditionally display a badge without count for the user.
*/
allowNoCount: boolean;
}
interface IState {
}
export default class NotificationBadge extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
public componentDidUpdate(prevProps: Readonly<IProps>) {
if (prevProps.notification) {
prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
private onNotificationUpdate = () => {
this.forceUpdate(); // notification state changed - update
};
public render(): React.ReactElement {
// Don't show a badge if we don't need to
if (this.props.notification.color <= NotificationColor.Bold) return null;
const hasNotif = this.props.notification.color >= NotificationColor.Red;
const hasCount = this.props.notification.color >= NotificationColor.Grey;
const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount");
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
if (isEmptyBadge) symbol = "";
const classes = classNames({
'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': hasCount,
'mx_NotificationBadge_highlighted': hasNotif,
'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
'mx_NotificationBadge_3char': symbol.length > 2,
});
return (
<div className={classes}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</div>
);
}
}
export class RoomNotificationState extends EventEmitter {
private _symbol: string;
private _count: number;
private _color: NotificationColor;
constructor(private room: Room) {
super();
this.room.on("Room.receipt", this.handleRoomEventUpdate);
this.room.on("Room.timeline", this.handleRoomEventUpdate);
this.room.on("Room.redaction", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
this.updateNotificationState();
}
public get symbol(): string {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
}
public destroy(): void {
this.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
}
}
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();
if (roomId !== this.room.roomId) return; // ignore - not for us
this.updateNotificationState();
};
private updateNotificationState() {
const before = {count: this.count, symbol: this.symbol, color: this.color};
if (this.roomIsInvite) {
this._color = NotificationColor.Red;
this._symbol = "!";
this._count = 1; // not used, technically
} else {
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight');
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total');
// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
// count. If that count is broken for some reason, assume zero. This avoids us showing
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
// Note: we only set the symbol if we have an actual count. We don't want to show
// zero on badges.
if (redNotifs > 0) {
this._color = NotificationColor.Red;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
} else if (greyNotifs > 0) {
this._color = NotificationColor.Grey;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
} else {
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room);
if (hasUnread) {
this._color = NotificationColor.Bold;
} else {
this._color = NotificationColor.None;
}
// no symbol or count for this state
this._count = 0;
this._symbol = null;
}
}
// finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color};
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
}
}
export class ListNotificationState extends EventEmitter {
private _count: number;
private _color: NotificationColor;
private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {};
constructor(private byTileCount = false) {
super();
}
public get symbol(): string {
return null; // This notification state doesn't support symbols
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
public setRooms(rooms: Room[]) {
// If we're only concerned about the tile count, don't bother setting up listeners.
if (this.byTileCount) {
this.rooms = rooms;
this.calculateTotalState();
return;
}
const oldRooms = this.rooms;
const diff = arrayDiff(oldRooms, rooms);
for (const oldRoom of diff.removed) {
const state = this.states[oldRoom.roomId];
delete this.states[oldRoom.roomId];
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
state.destroy();
}
for (const newRoom of diff.added) {
const state = new RoomNotificationState(newRoom);
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
this.states[newRoom.roomId] = state;
}
this.calculateTotalState();
}
private onRoomNotificationStateUpdate = () => {
this.calculateTotalState();
};
private calculateTotalState() {
const before = {count: this.count, symbol: this.symbol, color: this.color};
if (this.byTileCount) {
this._color = NotificationColor.Red;
this._count = this.rooms.length;
} else {
this._count = 0;
this._color = NotificationColor.None;
for (const state of Object.values(this.states)) {
this._count += state.count;
this._color = Math.max(this.color, state.color);
}
}
// finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color};
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
}
}

View file

@ -26,7 +26,7 @@ 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 { DefaultTagID, TagID } from "../../../stores/room-list/models"; import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -56,13 +56,19 @@ interface IProps {
} }
interface IState { interface IState {
notificationState: ListNotificationState;
} }
export default class RoomSublist2 extends React.Component<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef(); private headerButton = createRef();
private hasTiles(): boolean { constructor(props: IProps) {
return this.numTiles > 0; super(props);
this.state = {
notificationState: new ListNotificationState(this.props.isInvite),
};
this.state.notificationState.setRooms(this.props.rooms);
} }
private get numTiles(): number { private get numTiles(): number {
@ -70,6 +76,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
return (this.props.rooms || []).length; return (this.props.rooms || []).length;
} }
public componentDidUpdate() {
this.state.notificationState.setRooms(this.props.rooms);
}
private onAddRoom = (e) => { private onAddRoom = (e) => {
e.stopPropagation(); e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom(); if (this.props.onAddRoom) this.props.onAddRoom();
@ -106,13 +116,6 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
private renderHeader(): React.ReactElement { private renderHeader(): React.ReactElement {
// TODO: Handle badge count
// const notifications = !this.props.isInvite
// ? RoomNotifs.aggregateNotificationCount(this.props.rooms)
// : {count: 0, highlight: true};
// const notifCount = notifications.count;
// const notifHighlight = notifications.highlight;
// TODO: Title on collapsed // TODO: Title on collapsed
// TODO: Incoming call box // TODO: Incoming call box
@ -123,42 +126,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
// TODO: Collapsed state // TODO: Collapsed state
// TODO: Handle badge count
// let badge; const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>;
// if (true) { // !isCollapsed
// const showCount = localStorage.getItem("mx_rls_count") || notifHighlight;
// const badgeClasses = classNames({
// 'mx_RoomSublist2_badge': true,
// 'mx_RoomSublist2_badgeHighlight': notifHighlight,
// 'mx_RoomSublist2_badgeEmpty': !showCount,
// });
// // Wrap the contents in a div and apply styles to the child div so that the browser default outline works
// if (notifCount > 0) {
// const count = <div>{FormattingUtils.formatCount(notifCount)}</div>;
// badge = (
// <AccessibleButton
// tabIndex={tabIndex}
// className={badgeClasses}
// aria-label={_t("Jump to first unread room.")}
// >
// {showCount ? count : null}
// </AccessibleButton>
// );
// } else if (this.props.isInvite && this.hasTiles()) {
// // Render the `!` badge for invites
// badge = (
// <AccessibleButton
// tabIndex={tabIndex}
// className={badgeClasses}
// aria-label={_t("Jump to first invite.")}
// >
// <div>
// {FormattingUtils.formatCount(this.numTiles)}
// </div>
// </AccessibleButton>
// );
// }
// }
// TODO: Aux button // TODO: Aux button
// let addRoomButton = null; // let addRoomButton = null;
@ -185,6 +154,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
> >
<span>{this.props.label}</span> <span>{this.props.label}</span>
</AccessibleButton> </AccessibleButton>
<div className="mx_RoomSublist2_badgeContainer">
{badge}
</div>
</div> </div>
); );
}} }}

View file

@ -25,13 +25,8 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar"; import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import * as RoomNotifs from '../../../RoomNotifs';
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
import * as Unread from '../../../Unread';
import * as FormattingUtils from "../../../utils/FormattingUtils";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -41,14 +36,6 @@ import ActiveRoomObserver from "../../../ActiveRoomObserver";
* warning disappears. * * warning disappears. *
*******************************************************************/ *******************************************************************/
enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
Bold, // no badge, show as unread
Grey, // unread notified messages
Red, // unread pings
}
interface IProps { interface IProps {
room: Room; room: Room;
showMessagePreview: boolean; showMessagePreview: boolean;
@ -58,11 +45,6 @@ interface IProps {
// TODO: Incoming call boxes? // TODO: Incoming call boxes?
} }
interface INotificationState {
symbol: string;
color: NotificationColor;
}
interface IState { interface IState {
hover: boolean; hover: boolean;
notificationState: INotificationState; notificationState: INotificationState;
@ -88,89 +70,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.state = { this.state = {
hover: false, hover: false,
notificationState: this.getNotificationState(), notificationState: new RoomNotificationState(this.props.room),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
}; };
this.props.room.on("Room.receipt", this.handleRoomEventUpdate);
this.props.room.on("Room.timeline", this.handleRoomEventUpdate);
this.props.room.on("Room.redaction", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
} }
public componentWillUnmount() { public componentWillUnmount() {
if (this.props.room) { if (this.props.room) {
this.props.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
this.props.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.props.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
} }
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
}
}
// XXX: This is a bit of an awful-looking hack. We should probably be using state for
// this, but instead we're kinda forced to either duplicate the code or thread a variable
// through the code paths. This feels like the least evil option.
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.props.room.getMyMembership()) === EffectiveMembership.Invite;
}
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();
// Sanity check: should never happen
if (roomId !== this.props.room.roomId) return;
this.updateNotificationState();
};
private updateNotificationState() {
this.setState({notificationState: this.getNotificationState()});
}
private getNotificationState(): INotificationState {
const state: INotificationState = {
color: NotificationColor.None,
symbol: null,
};
if (this.roomIsInvite) {
state.color = NotificationColor.Red;
state.symbol = "!";
} else {
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'total');
// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
// count. If that count is broken for some reason, assume zero. This avoids us showing
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
// Note: we only set the symbol if we have an actual count. We don't want to show
// zero on badges.
if (redNotifs > 0) {
state.color = NotificationColor.Red;
state.symbol = FormattingUtils.formatCount(trueCount);
} else if (greyNotifs > 0) {
state.color = NotificationColor.Grey;
state.symbol = FormattingUtils.formatCount(trueCount);
} else {
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.props.room);
if (hasUnread) {
state.color = NotificationColor.Bold;
// no symbol for this state
}
}
}
return state;
} }
private onTileMouseEnter = () => { private onTileMouseEnter = () => {
@ -206,19 +116,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
'mx_RoomTile2_selected': this.state.selected, 'mx_RoomTile2_selected': this.state.selected,
}); });
let badge; const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
const hasBadge = this.state.notificationState.color > NotificationColor.Bold;
if (hasBadge) {
const hasNotif = this.state.notificationState.color >= NotificationColor.Red;
const isEmptyBadge = !localStorage.getItem("mx_rl_rt_badgeCount");
const badgeClasses = classNames({
'mx_RoomTile2_badge': true,
'mx_RoomTile2_badgeHighlight': hasNotif,
'mx_RoomTile2_badgeEmpty': isEmptyBadge,
});
const symbol = this.state.notificationState.symbol;
badge = <div className={badgeClasses}>{isEmptyBadge ? null : symbol}</div>;
}
// TODO: the original RoomTile uses state for the room name. Do we need to? // TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name; let name = this.props.room.name;
@ -237,6 +135,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
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,
}); });
const avatarSize = 32; const avatarSize = 32;

View file

@ -281,7 +281,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
values={[13, 15, 16, 18, 20]} values={[13, 15, 16, 18, 20]}
value={parseInt(this.state.fontSize, 10)} value={parseInt(this.state.fontSize, 10)}
onSelectionChange={this.onFontSizeChanged} onSelectionChange={this.onFontSizeChanged}
displayFunc={value => ""} displayFunc={_ => ""}
disabled={this.state.useCustomFontSize} disabled={this.state.useCustomFontSize}
/> />
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div> <div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
@ -290,9 +290,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
name="useCustomFontSize" name="useCustomFontSize"
level={SettingLevel.ACCOUNT} level={SettingLevel.ACCOUNT}
onChange={(checked) => this.setState({useCustomFontSize: checked})} onChange={(checked) => this.setState({useCustomFontSize: checked})}
useCheckbox={true}
/> />
<Field <Field
type="text" type="number"
label={_t("Font size")} label={_t("Font size")}
autoComplete="off" autoComplete="off"
placeholder={this.state.fontSize.toString()} placeholder={this.state.fontSize.toString()}
@ -301,6 +302,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
onValidate={this.onValidateFontSize} onValidate={this.onValidateFontSize}
onChange={(value) => this.setState({fontSize: value.target.value})} onChange={(value) => this.setState({fontSize: value.target.value})}
disabled={!this.state.useCustomFontSize} disabled={!this.state.useCustomFontSize}
className="mx_SettingsTab_customFontSizeField"
/> />
</div>; </div>;
} }

View file

@ -40,6 +40,11 @@ export enum Action {
*/ */
ViewUserSettings = "view_user_settings", ViewUserSettings = "view_user_settings",
/**
* Opens the room directory. No additional payload information required.
*/
ViewRoomDirectory = "view_room_directory",
/** /**
* Sets the current tooltip. Should be use with ViewTooltipPayload. * Sets the current tooltip. Should be use with ViewTooltipPayload.
*/ */

View file

@ -1959,6 +1959,7 @@
"Explore": "Explore", "Explore": "Explore",
"Filter": "Filter", "Filter": "Filter",
"Filter rooms…": "Filter rooms…", "Filter rooms…": "Filter rooms…",
"Explore rooms": "Explore rooms",
"Failed to reject invitation": "Failed to reject invitation", "Failed to reject invitation": "Failed to reject invitation",
"This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.", "This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.",
"Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?", "Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
@ -2004,7 +2005,6 @@
"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>.",
"Explore rooms": "Explore 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.",

View file

@ -20,9 +20,11 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { EffectiveMembership, splitRoomsByMembership } from "../../membership"; import { EffectiveMembership, splitRoomsByMembership } from "../../membership";
import { ITagMap, ITagSortingMap } from "../models"; import { ITagMap, ITagSortingMap } from "../models";
import DMRoomMap from "../../../../utils/DMRoomMap"; import DMRoomMap from "../../../../utils/DMRoomMap";
import { FILTER_CHANGED, IFilterCondition } from "../../filters/IFilterCondition"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../../filters/IFilterCondition";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { UPDATE_EVENT } from "../../../AsyncStore"; import { UPDATE_EVENT } from "../../../AsyncStore";
import { ArrayUtil } from "../../../../utils/arrays";
import { getEnumValues } from "../../../../utils/enums";
// TODO: Add locking support to avoid concurrent writes? // TODO: Add locking support to avoid concurrent writes?
@ -184,22 +186,33 @@ export abstract class Algorithm extends EventEmitter {
} }
console.warn("Recalculating filtered room list"); console.warn("Recalculating filtered room list");
const allowedByFilters = new Set<Room>();
const filters = Array.from(this.allowedByFilter.keys()); const filters = Array.from(this.allowedByFilter.keys());
const orderedFilters = new ArrayUtil(filters)
.groupBy(f => f.relativePriority)
.orderBy(getEnumValues(FilterPriority))
.value;
const newMap: ITagMap = {}; const newMap: ITagMap = {};
for (const tagId of Object.keys(this.cachedRooms)) { for (const tagId of Object.keys(this.cachedRooms)) {
// Cheaply clone the rooms so we can more easily do operations on the list. // Cheaply clone the rooms so we can more easily do operations on the list.
// We optimize our lookups by trying to reduce sample size as much as possible // We optimize our lookups by trying to reduce sample size as much as possible
// to the rooms we know will be deduped by the Set. // to the rooms we know will be deduped by the Set.
const rooms = this.cachedRooms[tagId]; const rooms = this.cachedRooms[tagId];
const remainingRooms = rooms.map(r => r).filter(r => !allowedByFilters.has(r)); let remainingRooms = rooms.map(r => r);
const allowedRoomsInThisTag = []; let allowedRoomsInThisTag = [];
for (const filter of filters) { let lastFilterPriority = orderedFilters[0].relativePriority;
for (const filter of orderedFilters) {
if (filter.relativePriority !== lastFilterPriority) {
// Every time the filter changes priority, we want more specific filtering.
// To accomplish that, reset the variables to make it look like the process
// has started over, but using the filtered rooms as the seed.
remainingRooms = allowedRoomsInThisTag;
allowedRoomsInThisTag = [];
lastFilterPriority = filter.relativePriority;
}
const filteredRooms = remainingRooms.filter(r => filter.isVisible(r)); const filteredRooms = remainingRooms.filter(r => filter.isVisible(r));
for (const room of filteredRooms) { for (const room of filteredRooms) {
const idx = remainingRooms.indexOf(room); const idx = remainingRooms.indexOf(room);
if (idx >= 0) remainingRooms.splice(idx, 1); if (idx >= 0) remainingRooms.splice(idx, 1);
allowedByFilters.add(room);
allowedRoomsInThisTag.push(room); allowedRoomsInThisTag.push(room);
} }
} }
@ -207,7 +220,8 @@ export abstract class Algorithm extends EventEmitter {
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`); console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
} }
this.allowedRoomsByFilters = allowedByFilters; const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
this.allowedRoomsByFilters = new Set(allowedRooms);
this.filteredRooms = newMap; this.filteredRooms = newMap;
this.emit(LIST_UPDATED_EVENT); this.emit(LIST_UPDATED_EVENT);
} }

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
import { Group } from "matrix-js-sdk/src/models/group"; import { Group } from "matrix-js-sdk/src/models/group";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import GroupStore from "../../GroupStore"; import GroupStore from "../../GroupStore";
@ -37,6 +37,11 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
this.onStoreUpdate(); // trigger a false update to seed the store this.onStoreUpdate(); // trigger a false update to seed the store
} }
public get relativePriority(): FilterPriority {
// Lowest priority so we can coarsely find rooms.
return FilterPriority.Lowest;
}
public isVisible(room: Room): boolean { public isVisible(room: Room): boolean {
return this.roomIds.includes(room.roomId); return this.roomIds.includes(room.roomId);
} }

View file

@ -19,6 +19,12 @@ import { EventEmitter } from "events";
export const FILTER_CHANGED = "filter_changed"; export const FILTER_CHANGED = "filter_changed";
export enum FilterPriority {
Lowest,
// in the middle would be Low, Normal, and High if we had a need
Highest,
}
/** /**
* A filter condition for the room list, determining if a room * A filter condition for the room list, determining if a room
* should be shown or not. * should be shown or not.
@ -32,6 +38,12 @@ export const FILTER_CHANGED = "filter_changed";
* as a change in the user's input), this emits FILTER_CHANGED. * as a change in the user's input), this emits FILTER_CHANGED.
*/ */
export interface IFilterCondition extends EventEmitter { export interface IFilterCondition extends EventEmitter {
/**
* The relative priority that this filter should be applied with.
* Lower priorities get applied first.
*/
relativePriority: FilterPriority;
/** /**
* Determines if a given room should be visible under this * Determines if a given room should be visible under this
* condition. * condition.

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
/** /**
@ -29,6 +29,11 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
super(); super();
} }
public get relativePriority(): FilterPriority {
// We want this one to be at the highest priority so it can search within other filters.
return FilterPriority.Highest;
}
public get search(): string { public get search(): string {
return this._search; return this._search;
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); 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.
@ -21,8 +21,8 @@ import { _t } from '../languageHandler';
* formats numbers to fit into ~3 characters, suitable for badge counts * formats numbers to fit into ~3 characters, suitable for badge counts
* e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B * e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B
*/ */
export function formatCount(count) { export function formatCount(count: number): string {
if (count < 1000) return count; if (count < 1000) return count.toString();
if (count < 10000) return (count / 1000).toFixed(1) + "K"; if (count < 10000) return (count / 1000).toFixed(1) + "K";
if (count < 100000) return (count / 1000).toFixed(0) + "K"; if (count < 100000) return (count / 1000).toFixed(0) + "K";
if (count < 10000000) return (count / 1000000).toFixed(1) + "M"; if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
@ -34,7 +34,7 @@ export function formatCount(count) {
* Format a count showing the whole number but making it a bit more readable. * Format a count showing the whole number but making it a bit more readable.
* e.g: 1000 => 1,000 * e.g: 1000 => 1,000
*/ */
export function formatCountLong(count) { export function formatCountLong(count: number): string {
const formatter = new Intl.NumberFormat(); const formatter = new Intl.NumberFormat();
return formatter.format(count) return formatter.format(count)
} }
@ -43,7 +43,7 @@ export function formatCountLong(count) {
* format a size in bytes into a human readable form * format a size in bytes into a human readable form
* e.g: 1024 -> 1.00 KB * e.g: 1024 -> 1.00 KB
*/ */
export function formatBytes(bytes, decimals = 2) { export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
@ -62,7 +62,7 @@ export function formatBytes(bytes, decimals = 2) {
* *
* @return {string} * @return {string}
*/ */
export function formatCryptoKey(key) { export function formatCryptoKey(key: string): string {
return key.match(/.{1,4}/g).join(" "); return key.match(/.{1,4}/g).join(" ");
} }
/** /**
@ -72,7 +72,7 @@ export function formatCryptoKey(key) {
* *
* @return {number} * @return {number}
*/ */
export function hashCode(str) { export function hashCode(str: string): number {
let hash = 0; let hash = 0;
let i; let i;
let chr; let chr;
@ -87,7 +87,7 @@ export function hashCode(str) {
return Math.abs(hash); return Math.abs(hash);
} }
export function getUserNameColorClass(userId) { export function getUserNameColorClass(userId: string): string {
const colorNumber = (hashCode(userId) % 8) + 1; const colorNumber = (hashCode(userId) % 8) + 1;
return `mx_Username_color${colorNumber}`; return `mx_Username_color${colorNumber}`;
} }
@ -103,7 +103,7 @@ export function getUserNameColorClass(userId) {
* @returns {string} a string constructed by joining `items` with a comma * @returns {string} a string constructed by joining `items` with a comma
* between each item, but with the last item appended as " and [lastItem]". * between each item, but with the last item appended as " and [lastItem]".
*/ */
export function formatCommaSeparatedList(items, itemLimit) { export function formatCommaSeparatedList(items: string[], itemLimit?: number): string {
const remaining = itemLimit === undefined ? 0 : Math.max( const remaining = itemLimit === undefined ? 0 : Math.max(
items.length - itemLimit, 0, items.length - itemLimit, 0,
); );
@ -119,3 +119,14 @@ export function formatCommaSeparatedList(items, itemLimit) {
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
} }
} }
/**
* Formats a number into a 'minimal' badge count (9, 98, 99+).
* @param count The number to convert
* @returns The badge count, stringified.
*/
export function formatMinimalBadgeCount(count: number): string {
// we specifically go from "98" to "99+"
if (count < 99) return count.toString();
return "99+";
}

View file

@ -45,3 +45,63 @@ export function arrayDiff<T>(a: T[], b: T[]): { added: T[], removed: T[] } {
removed: a.filter(i => !b.includes(i)), removed: a.filter(i => !b.includes(i)),
}; };
} }
/**
* Helper functions to perform LINQ-like queries on arrays.
*/
export class ArrayUtil<T> {
/**
* Create a new array helper.
* @param a The array to help. Can be modified in-place.
*/
constructor(private a: T[]) {
}
/**
* The value of this array, after all appropriate alterations.
*/
public get value(): T[] {
return this.a;
}
/**
* Groups an array by keys.
* @param fn The key-finding function.
* @returns This.
*/
public groupBy<K>(fn: (a: T) => K): GroupedArray<K, T> {
const obj = this.a.reduce((rv: Map<K, T[]>, val: T) => {
const k = fn(val);
if (!rv.has(k)) rv.set(k, []);
rv.get(k).push(val);
return rv;
}, new Map<K, T[]>());
return new GroupedArray(obj);
}
}
/**
* Helper functions to perform LINQ-like queries on groups (maps).
*/
export class GroupedArray<K, T> {
/**
* Creates a new group helper.
* @param val The group to help. Can be modified in-place.
*/
constructor(private val: Map<K, T[]>) {
}
/**
* Orders the grouping into an array using the provided key order.
* @param keyOrder The key order.
* @returns An array helper of the result.
*/
public orderBy(keyOrder: K[]): ArrayUtil<T> {
const a: T[] = [];
for (const k of keyOrder) {
if (!this.val.has(k)) continue;
a.push(...this.val.get(k));
}
return new ArrayUtil(a);
}
}

27
src/utils/enums.ts Normal file
View file

@ -0,0 +1,27 @@
/*
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.
*/
/**
* Get the values for an enum.
* @param e The enum.
* @returns The enum values.
*/
export function getEnumValues<T>(e: any): T[] {
const keys = Object.keys(e);
return keys
.filter(k => ['string', 'number'].includes(typeof(e[k])))
.map(k => e[k]);
}