From e4a51a7c01fe3d6f2e0aab8168c8afa7044d57fc Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 16 Jun 2020 14:43:48 -0600 Subject: [PATCH] Add presence icons; Convert to generic icon component For https://github.com/vector-im/riot-web/issues/14039 --- res/css/_components.scss | 1 + res/css/views/rooms/_RoomTile2.scss | 20 +-- res/css/views/rooms/_RoomTileIcon.scss | 69 +++++++++ res/themes/light/css/_light.scss | 4 + src/components/views/rooms/RoomTile2.tsx | 28 ++-- src/components/views/rooms/RoomTileIcon.tsx | 148 ++++++++++++++++++++ src/utils/presence.ts | 26 ++++ 7 files changed, 258 insertions(+), 38 deletions(-) create mode 100644 res/css/views/rooms/_RoomTileIcon.scss create mode 100644 src/components/views/rooms/RoomTileIcon.tsx create mode 100644 src/utils/presence.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 31f319e76f..66eb98ea9d 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -189,6 +189,7 @@ @import "./views/rooms/_RoomSublist2.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTile2.scss"; +@import "./views/rooms/_RoomTileIcon.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SendMessageComposer.scss"; diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index 2e9fe4a31e..001499fea5 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -34,28 +34,10 @@ limitations under the License. margin-right: 8px; position: relative; - .mx_RoomTile2_publicRoom { - width: 12px; - height: 12px; - border-radius: 12px; - background-color: $roomlist2-bg-color; // to match the room list itself + .mx_RoomTileIcon { position: absolute; bottom: 0; right: 0; - - &::before { - content: ''; - width: 8px; - height: 8px; - top: 2px; - left: 2px; - position: absolute; - mask-position: center; - mask-size: contain; - mask-repeat: no-repeat; - background: $primary-fg-color; - mask-image: url('$(res)/img/globe.svg'); - } } } diff --git a/res/css/views/rooms/_RoomTileIcon.scss b/res/css/views/rooms/_RoomTileIcon.scss new file mode 100644 index 0000000000..adc8ea2994 --- /dev/null +++ b/res/css/views/rooms/_RoomTileIcon.scss @@ -0,0 +1,69 @@ +/* +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_RoomTileIcon { + width: 12px; + height: 12px; + border-radius: 12px; + background-color: $roomlist2-bg-color; // to match the room list itself +} + +.mx_RoomTileIcon_globe::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + mask-image: url('$(res)/img/globe.svg'); +} + +.mx_RoomTileIcon_offline::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + border-radius: 8px; + background-color: $presence-offline; +} + +.mx_RoomTileIcon_online::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + border-radius: 8px; + background-color: $presence-online; +} + +.mx_RoomTileIcon_away::before { + content: ''; + width: 8px; + height: 8px; + top: 2px; + left: 2px; + position: absolute; + border-radius: 8px; + background-color: $presence-away; +} diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 18a25b2663..355cc1301c 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -186,6 +186,10 @@ $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; $roomtile2-selected-bg-color: #FFF; +$presence-online: $accent-color; +$presence-away: orange; // TODO: Get color +$presence-offline: #E3E8F0; + // ******************** $roomtile-name-color: #61708b; diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index f0d99eed99..8343851f66 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -21,7 +21,7 @@ import React, { createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; -import AccessibleButton, {ButtonEvent} from "../../views/elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import RoomAvatar from "../../views/avatars/RoomAvatar"; import dis from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; @@ -31,6 +31,7 @@ import { _t } from "../../../languageHandler"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { MessagePreviewStore } from "../../../stores/MessagePreviewStore"; +import RoomTileIcon from "./RoomTileIcon"; /******************************************************************* * CAUTION * @@ -86,12 +87,6 @@ export default class RoomTile2 extends React.Component { ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); } - private get isPublicRoom(): boolean { - const joinRules = this.props.room.currentState.getStateEvents("m.room.join_rules", ""); - const joinRule = joinRules && joinRules.getContent().join_rule; - return joinRule === 'public'; - } - public componentWillUnmount() { if (this.props.room) { ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); @@ -187,25 +182,25 @@ export default class RoomTile2 extends React.Component {
  • this.onTagRoom(e, DefaultTagID.Favourite)}> - + {_t("Favourite")}
  • this.onTagRoom(e, DefaultTagID.LowPriority)}> - + {_t("Low Priority")}
  • this.onTagRoom(e, DefaultTagID.DM)}> - + {_t("Direct Chat")}
  • - + {_t("Settings")}
  • @@ -215,7 +210,7 @@ export default class RoomTile2 extends React.Component {
    • - + {_t("Leave Room")}
    • @@ -253,7 +248,7 @@ export default class RoomTile2 extends React.Component { 'mx_RoomTile2_minimized': this.props.isMinimized, }); - const badge = ; + const badge = ; // TODO: the original RoomTile uses state for the room name. Do we need to? let name = this.props.room.name; @@ -294,11 +289,6 @@ export default class RoomTile2 extends React.Component { ); if (this.props.isMinimized) nameContainer = null; - let globe = null; - if (this.isPublicRoom && this.props.tag !== DefaultTagID.DM) { - globe = ; // sizing and such set by CSS - } - const avatarSize = 32; return ( @@ -316,7 +306,7 @@ export default class RoomTile2 extends React.Component { >
      - {globe} +
      {nameContainer}
      diff --git a/src/components/views/rooms/RoomTileIcon.tsx b/src/components/views/rooms/RoomTileIcon.tsx new file mode 100644 index 0000000000..fb967bb811 --- /dev/null +++ b/src/components/views/rooms/RoomTileIcon.tsx @@ -0,0 +1,148 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { 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 ActiveRoomObserver from "../../../ActiveRoomObserver"; +import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { User } from "matrix-js-sdk/src/models/user"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import SdkConfig from "../../../SdkConfig"; +import { isPresenceEnabled } from "../../../utils/presence"; + +enum Icon { + // Note: the names here are used in CSS class names + None = "NONE", // ... except this one + Globe = "GLOBE", + PresenceOnline = "ONLINE", + PresenceAway = "AWAY", + PresenceOffline = "OFFLINE", +} + +interface IProps { + room: Room; + tag: TagID; +} + +interface IState { + icon: Icon; +} + +export default class RoomTileIcon extends React.Component { + private isUnmounted = false; + private dmUser: User; + private isWatchingTimeline = false; + + constructor(props: IProps) { + super(props); + + this.state = { + icon: this.getIcon(), + }; + } + + private get isPublicRoom(): boolean { + const joinRules = this.props.room.currentState.getStateEvents("m.room.join_rules", ""); + const joinRule = joinRules && joinRules.getContent().join_rule; + return joinRule === 'public'; + } + + public componentWillUnmount() { + this.isUnmounted = true; + if (this.isWatchingTimeline) this.props.room.off('Room.timeline', this.onRoomTimeline); + this.unsubscribePresence(); + } + + private unsubscribePresence() { + if (this.dmUser) { + this.dmUser.off('User.currentlyActive', this.onPresenceUpdate); + this.dmUser.off('User.presence', this.onPresenceUpdate); + } + } + + private onRoomTimeline = (ev: MatrixEvent, room: Room) => { + if (this.isUnmounted) return; + + // apparently these can happen? + if (!room) return; + if (this.props.room.roomId !== room.roomId) return; + + if (ev.getType() === 'm.room.join_rules' || ev.getType() === 'm.room.member') { + this.setState({icon: this.getIcon()}); + } + }; + + private onPresenceUpdate = () => { + if (this.isUnmounted) return; + + let newIcon = this.getPresenceIcon(); + if (newIcon !== this.state.icon) this.setState({icon: newIcon}); + }; + + private getPresenceIcon(): Icon { + let newIcon = Icon.None; + + const isOnline = this.dmUser.currentlyActive || this.dmUser.presence === 'online'; + if (isOnline) { + newIcon = Icon.PresenceOnline; + } else if (this.dmUser.presence === 'offline') { + newIcon = Icon.PresenceOffline; + } else if (this.dmUser.presence === 'unavailable') { + newIcon = Icon.PresenceAway; + } + + return newIcon; + } + + private getIcon(): Icon { + let defaultIcon = Icon.None; + this.unsubscribePresence(); + if (this.props.tag === DefaultTagID.DM && this.props.room.getJoinedMemberCount() === 2) { + // Track presence, if available + if (isPresenceEnabled()) { + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId); + if (otherUserId) { + this.dmUser = MatrixClientPeg.get().getUser(otherUserId); + if (this.dmUser) { + this.dmUser.on('User.currentlyActive', this.onPresenceUpdate); + this.dmUser.on('User.presence', this.onPresenceUpdate); + defaultIcon = this.getPresenceIcon(); + } + } + } + } else { + // Track publicity + defaultIcon = this.isPublicRoom ? Icon.Globe : Icon.None; + this.props.room.on('Room.timeline', this.onRoomTimeline); + this.isWatchingTimeline = true; + } + return defaultIcon; + } + + public render(): React.ReactElement { + if (this.state.icon === Icon.None) return null; + + return ; + } +} diff --git a/src/utils/presence.ts b/src/utils/presence.ts new file mode 100644 index 0000000000..f2c208265e --- /dev/null +++ b/src/utils/presence.ts @@ -0,0 +1,26 @@ +/* +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 { MatrixClientPeg } from "../MatrixClientPeg"; +import SdkConfig from "../SdkConfig"; + +export function isPresenceEnabled() { + const hsUrl = MatrixClientPeg.get().baseUrl; + const urls = SdkConfig.get()['enable_presence_by_hs_url']; + if (!urls) return true; + if (urls[hsUrl] || urls[hsUrl] === undefined) return true; + return false; +}