diff --git a/res/css/_components.scss b/res/css/_components.scss index 66af2ba00f..f0073eff81 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -172,6 +172,7 @@ @import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MessageComposer.scss"; @import "./views/rooms/_MessageComposerFormatBar.scss"; +@import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventsPanel.scss"; @import "./views/rooms/_PresenceLabel.scss"; diff --git a/res/css/views/rooms/_NotificationBadge.scss b/res/css/views/rooms/_NotificationBadge.scss new file mode 100644 index 0000000000..609e41c583 --- /dev/null +++ b/res/css/views/rooms/_NotificationBadge.scss @@ -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 + } + } +} diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index e6e5af3b48..cfb9bc3b6d 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -30,11 +30,36 @@ limitations under the License. margin-bottom: 12px; .mx_RoomSublist2_headerContainer { - text-transform: uppercase; - opacity: 0.5; - line-height: $font-16px; - font-size: $font-12px; - padding-bottom: 8px; + // 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; + opacity: 0.5; + line-height: $font-16px; + font-size: $font-12px; + padding-bottom: 8px; + + width: 100%; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } } .mx_RoomSublist2_resizeBox { diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index 3151bb8716..41c9469bc1 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -50,11 +50,14 @@ limitations under the License. // TODO: Ellipsis on the name and preview .mx_RoomTile2_name { - font-weight: 600; font-size: $font-14px; line-height: $font-19px; } + .mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents { + font-weight: 600; + } + .mx_RoomTile2_messagePreview { font-size: $font-13px; line-height: $font-18px; @@ -70,34 +73,5 @@ limitations under the License. display: flex; align-items: center; 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; - } - } } } diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx new file mode 100644 index 0000000000..50af2ee1d0 --- /dev/null +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -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 { + constructor(props: IProps) { + super(props); + this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + } + + public componentDidUpdate(prevProps: Readonly) { + 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 ( +
+ {symbol} +
+ ); + } +} + +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); + } + } +} diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 650a3ae645..cd27156cbd 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -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 { 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 { 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 { } 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 { 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 =
{FormattingUtils.formatCount(notifCount)}
; - // badge = ( - // - // {showCount ? count : null} - // - // ); - // } else if (this.props.isInvite && this.hasTiles()) { - // // Render the `!` badge for invites - // badge = ( - // - //
- // {FormattingUtils.formatCount(this.numTiles)} - //
- //
- // ); - // } - // } + + const badge = ; // TODO: Aux button // let addRoomButton = null; @@ -185,6 +154,9 @@ export default class RoomSublist2 extends React.Component { > {this.props.label} +
+ {badge} +
); }} diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 09d7b46ba5..d4f64e4571 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -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 { 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 { '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 =
{isEmptyBadge ? null : symbol}
; - } + const badge = ; // 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 { const nameClasses = classNames({ "mx_RoomTile2_name": true, "mx_RoomTile2_nameWithPreview": !!messagePreview, + "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold, }); const avatarSize = 32;