Merge branch 'develop' into travis/room-list/breadcrumbs
This commit is contained in:
commit
9377fa513f
28 changed files with 857 additions and 258 deletions
|
@ -22,9 +22,10 @@ import { _t } from "../../languageHandler";
|
|||
import SdkConfig from "../../SdkConfig";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
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 HomePage = () => {
|
||||
|
|
|
@ -252,7 +252,7 @@ const LeftPanel = createReactClass({
|
|||
if (!this.props.collapsed) {
|
||||
exploreButton = (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,15 +18,15 @@ import * as React from "react";
|
|||
import TagPanel from "./TagPanel";
|
||||
import classNames from "classnames";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import { _t } from "../../languageHandler";
|
||||
import SearchBox from "./SearchBox";
|
||||
import RoomList2 from "../views/rooms/RoomList2";
|
||||
import TopLeftMenuButton from "./TopLeftMenuButton";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||
import UserMenuButton from "./UserMenuButton";
|
||||
import RoomSearch from "./RoomSearch";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
|
||||
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
|
@ -44,7 +44,6 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
searchExpanded: boolean;
|
||||
searchFilter: string; // TODO: Move search into room list?
|
||||
showBreadcrumbs: boolean;
|
||||
}
|
||||
|
@ -61,7 +60,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
searchExpanded: false,
|
||||
searchFilter: "",
|
||||
showBreadcrumbs: BreadcrumbsStore.instance.visible,
|
||||
};
|
||||
|
@ -77,24 +75,10 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
this.setState({searchFilter: term});
|
||||
};
|
||||
|
||||
private onSearchCleared = (source: string): void => {
|
||||
if (source === "keyboard") {
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
this.setState({searchExpanded: false});
|
||||
}
|
||||
|
||||
private onSearchFocus = (): void => {
|
||||
this.setState({searchExpanded: true});
|
||||
private onExplore = () => {
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
private onSearchBlur = (event: FocusEvent): void => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.value.length === 0) {
|
||||
this.setState({searchExpanded: false});
|
||||
}
|
||||
}
|
||||
|
||||
private onBreadcrumbsUpdate = () => {
|
||||
const newVal = BreadcrumbsStore.instance.visible;
|
||||
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 {
|
||||
const tagPanel = (
|
||||
<div className="mx_LeftPanel2_tagPanelContainer">
|
||||
|
@ -158,18 +158,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
</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
|
||||
const roomList = <RoomList2
|
||||
onKeyDown={() => {/*TODO*/}}
|
||||
|
@ -191,14 +179,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
{tagPanel}
|
||||
<aside className="mx_LeftPanel2_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
<div
|
||||
className="mx_LeftPanel2_filterContainer"
|
||||
onKeyDown={() => {/*TODO*/}}
|
||||
onFocus={() => {/*TODO*/}}
|
||||
onBlur={() => {/*TODO*/}}
|
||||
>
|
||||
{searchBox}
|
||||
</div>
|
||||
{this.renderSearchExplore()}
|
||||
<div className="mx_LeftPanel2_actualRoomListContainer">
|
||||
{roomList}
|
||||
</div>
|
||||
|
|
|
@ -624,7 +624,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
||||
break;
|
||||
}
|
||||
case 'view_room_directory': {
|
||||
case Action.ViewRoomDirectory: {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
||||
'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
|
@ -1611,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
action: 'require_registration',
|
||||
});
|
||||
} else if (screen === 'directory') {
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
} else if (screen === 'groups') {
|
||||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
|
|
144
src/components/structures/RoomSearch.tsx
Normal file
144
src/components/structures/RoomSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1458,9 +1458,7 @@ export default createReactClass({
|
|||
// using /leave rather than /join. In the short term though, we
|
||||
// just ignore them.
|
||||
// https://github.com/vector-im/vector-web/issues/1134
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
},
|
||||
|
||||
onSearchClick: function() {
|
||||
|
|
|
@ -18,11 +18,12 @@ import React from 'react';
|
|||
import * as sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
const RoomDirectoryButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action="view_room_directory"
|
||||
<ActionButton action={Action.ViewRoomDirectory}
|
||||
mouseOverAction={props.callout ? "callout_room_directory" : null}
|
||||
label={_t("Room directory")}
|
||||
iconPath={require("../../../../res/img/icons-directory.svg")}
|
||||
|
|
279
src/components/views/rooms/NotificationBadge.tsx
Normal file
279
src/components/views/rooms/NotificationBadge.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
|
|||
import RoomTile2 from "./RoomTile2";
|
||||
import { ResizableBox, ResizeCallbackData } from "react-resizable";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -56,13 +56,19 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
notificationState: ListNotificationState;
|
||||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef();
|
||||
|
||||
private hasTiles(): boolean {
|
||||
return this.numTiles > 0;
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
notificationState: new ListNotificationState(this.props.isInvite),
|
||||
};
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
|
@ -70,6 +76,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
return (this.props.rooms || []).length;
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
|
@ -106,13 +116,6 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
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: Incoming call box
|
||||
|
||||
|
@ -123,42 +126,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
// TODO: Collapsed state
|
||||
// TODO: Handle badge count
|
||||
// let badge;
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>;
|
||||
|
||||
// TODO: Aux button
|
||||
// let addRoomButton = null;
|
||||
|
@ -185,6 +154,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
>
|
||||
<span>{this.props.label}</span>
|
||||
</AccessibleButton>
|
||||
<div className="mx_RoomSublist2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
|
|
@ -25,13 +25,8 @@ 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 * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -41,14 +36,6 @@ import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
|||
* 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 {
|
||||
room: Room;
|
||||
showMessagePreview: boolean;
|
||||
|
@ -58,11 +45,6 @@ interface IProps {
|
|||
// TODO: Incoming call boxes?
|
||||
}
|
||||
|
||||
interface INotificationState {
|
||||
symbol: string;
|
||||
color: NotificationColor;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
notificationState: INotificationState;
|
||||
|
@ -88,89 +70,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
hover: false,
|
||||
notificationState: this.getNotificationState(),
|
||||
notificationState: new RoomNotificationState(this.props.room),
|
||||
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);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
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);
|
||||
}
|
||||
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 = () => {
|
||||
|
@ -206,19 +116,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
'mx_RoomTile2_selected': this.state.selected,
|
||||
});
|
||||
|
||||
let badge;
|
||||
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>;
|
||||
}
|
||||
const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
|
||||
|
||||
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||
let name = this.props.room.name;
|
||||
|
@ -237,6 +135,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
const nameClasses = classNames({
|
||||
"mx_RoomTile2_name": true,
|
||||
"mx_RoomTile2_nameWithPreview": !!messagePreview,
|
||||
"mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
|
||||
});
|
||||
|
||||
const avatarSize = 32;
|
||||
|
|
|
@ -281,7 +281,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
values={[13, 15, 16, 18, 20]}
|
||||
value={parseInt(this.state.fontSize, 10)}
|
||||
onSelectionChange={this.onFontSizeChanged}
|
||||
displayFunc={value => ""}
|
||||
displayFunc={_ => ""}
|
||||
disabled={this.state.useCustomFontSize}
|
||||
/>
|
||||
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
|
||||
|
@ -290,9 +290,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
name="useCustomFontSize"
|
||||
level={SettingLevel.ACCOUNT}
|
||||
onChange={(checked) => this.setState({useCustomFontSize: checked})}
|
||||
useCheckbox={true}
|
||||
/>
|
||||
<Field
|
||||
type="text"
|
||||
type="number"
|
||||
label={_t("Font size")}
|
||||
autoComplete="off"
|
||||
placeholder={this.state.fontSize.toString()}
|
||||
|
@ -301,6 +302,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
onValidate={this.onValidateFontSize}
|
||||
onChange={(value) => this.setState({fontSize: value.target.value})}
|
||||
disabled={!this.state.useCustomFontSize}
|
||||
className="mx_SettingsTab_customFontSizeField"
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue