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 f74d0ff5a4..001499fea5 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -32,6 +32,13 @@ limitations under the License. .mx_RoomTile2_avatarContainer { margin-right: 8px; + position: relative; + + .mx_RoomTileIcon { + position: absolute; + bottom: 0; + right: 0; + } } .mx_RoomTile2_nameContainer { 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/img/globe.svg b/res/img/globe.svg new file mode 100644 index 0000000000..cc22bc6e66 --- /dev/null +++ b/res/img/globe.svg @@ -0,0 +1,6 @@ + + + + + + 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/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 9f8b8579c3..f03cb3ecbd 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -145,6 +145,7 @@ export default class RoomSublist2 extends React.Component { key={`room-${room.roomId}`} showMessagePreview={this.props.layout.showPreviews} isMinimized={this.props.isMinimized} + tag={this.props.layout.tagId} /> ); } diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 10845d3840..671b981ac5 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 * @@ -44,6 +45,7 @@ interface IProps { room: Room; showMessagePreview: boolean; isMinimized: boolean; + tag: TagID; // TODO: Allow falsifying counts (for invites and stuff) // TODO: Transparency? Was this ever used? @@ -303,7 +305,8 @@ export default class RoomTile2 extends React.Component { role="treeitem" >
- + +
{nameContainer}
diff --git a/src/components/views/rooms/RoomTileIcon.tsx b/src/components/views/rooms/RoomTileIcon.tsx new file mode 100644 index 0000000000..b0cf10e313 --- /dev/null +++ b/src/components/views/rooms/RoomTileIcon.tsx @@ -0,0 +1,150 @@ +/* +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 { Room } from "matrix-js-sdk/src/models/room"; +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 { 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 _dmUser: User; + private isUnmounted = false; + private isWatchingTimeline = false; + + constructor(props: IProps) { + super(props); + + this.state = { + icon: this.calculateIcon(), + }; + } + + 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'; + } + + private get dmUser(): User { + return this._dmUser; + } + + private set dmUser(val: User) { + const oldUser = this._dmUser; + this._dmUser = val; + if (oldUser && oldUser !== this._dmUser) { + oldUser.off('User.currentlyActive', this.onPresenceUpdate); + oldUser.off('User.presence', this.onPresenceUpdate); + } + if (this._dmUser && oldUser !== this._dmUser) { + this._dmUser.on('User.currentlyActive', this.onPresenceUpdate); + this._dmUser.on('User.presence', this.onPresenceUpdate); + } + } + + public componentWillUnmount() { + this.isUnmounted = true; + if (this.isWatchingTimeline) this.props.room.off('Room.timeline', this.onRoomTimeline); + this.dmUser = null; // clear listeners, if any + } + + 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.calculateIcon()}); + } + }; + + private onPresenceUpdate = () => { + if (this.isUnmounted) return; + + let newIcon = this.getPresenceIcon(); + if (newIcon !== this.state.icon) this.setState({icon: newIcon}); + }; + + private getPresenceIcon(): Icon { + if (!this.dmUser) return Icon.None; + + let icon = Icon.None; + + const isOnline = this.dmUser.currentlyActive || this.dmUser.presence === 'online'; + if (isOnline) { + icon = Icon.PresenceOnline; + } else if (this.dmUser.presence === 'offline') { + icon = Icon.PresenceOffline; + } else if (this.dmUser.presence === 'unavailable') { + icon = Icon.PresenceAway; + } + + return icon; + } + + private calculateIcon(): Icon { + let icon = Icon.None; + + 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); + icon = this.getPresenceIcon(); + } + } + } else { + // Track publicity + icon = this.isPublicRoom ? Icon.Globe : Icon.None; + if (!this.isWatchingTimeline) { + this.props.room.on('Room.timeline', this.onRoomTimeline); + this.isWatchingTimeline = true; + } + } + return icon; + } + + 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; +}