Add presence icons; Convert to generic icon component

For https://github.com/vector-im/riot-web/issues/14039
This commit is contained in:
Travis Ralston 2020-06-16 14:43:48 -06:00
parent bcebef7e56
commit e4a51a7c01
7 changed files with 258 additions and 38 deletions

View file

@ -189,6 +189,7 @@
@import "./views/rooms/_RoomSublist2.scss"; @import "./views/rooms/_RoomSublist2.scss";
@import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTile.scss";
@import "./views/rooms/_RoomTile2.scss"; @import "./views/rooms/_RoomTile2.scss";
@import "./views/rooms/_RoomTileIcon.scss";
@import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss";
@import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SearchBar.scss";
@import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_SendMessageComposer.scss";

View file

@ -34,28 +34,10 @@ limitations under the License.
margin-right: 8px; margin-right: 8px;
position: relative; position: relative;
.mx_RoomTile2_publicRoom { .mx_RoomTileIcon {
width: 12px;
height: 12px;
border-radius: 12px;
background-color: $roomlist2-bg-color; // to match the room list itself
position: absolute; position: absolute;
bottom: 0; bottom: 0;
right: 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');
}
} }
} }

View file

@ -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;
}

View file

@ -186,6 +186,10 @@ $roomtile2-preview-color: #9e9e9e;
$roomtile2-default-badge-bg-color: #61708b; $roomtile2-default-badge-bg-color: #61708b;
$roomtile2-selected-bg-color: #FFF; $roomtile2-selected-bg-color: #FFF;
$presence-online: $accent-color;
$presence-away: orange; // TODO: Get color
$presence-offline: #E3E8F0;
// ******************** // ********************
$roomtile-name-color: #61708b; $roomtile-name-color: #61708b;

View file

@ -21,7 +21,7 @@ import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; 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 RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
@ -31,6 +31,7 @@ import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/MessagePreviewStore"; import { MessagePreviewStore } from "../../../stores/MessagePreviewStore";
import RoomTileIcon from "./RoomTileIcon";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -86,12 +87,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); 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() { public componentWillUnmount() {
if (this.props.room) { if (this.props.room) {
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
@ -187,25 +182,25 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<ul> <ul>
<li> <li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}> <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar"/>
<span>{_t("Favourite")}</span> <span>{_t("Favourite")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
<li> <li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}> <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown"/>
<span>{_t("Low Priority")}</span> <span>{_t("Low Priority")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
<li> <li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.DM)}> <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.DM)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconUser" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconUser"/>
<span>{_t("Direct Chat")}</span> <span>{_t("Direct Chat")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
<li> <li>
<AccessibleButton onClick={this.onOpenRoomSettings}> <AccessibleButton onClick={this.onOpenRoomSettings}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings"/>
<span>{_t("Settings")}</span> <span>{_t("Settings")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
@ -215,7 +210,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<ul> <ul>
<li className="mx_RoomTile2_contextMenu_redRow"> <li className="mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}> <AccessibleButton onClick={this.onLeaveRoomClick}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut"/>
<span>{_t("Leave Room")}</span> <span>{_t("Leave Room")}</span>
</AccessibleButton> </AccessibleButton>
</li> </li>
@ -253,7 +248,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
'mx_RoomTile2_minimized': this.props.isMinimized, 'mx_RoomTile2_minimized': this.props.isMinimized,
}); });
const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />; const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true}/>;
// 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;
@ -294,11 +289,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) nameContainer = null; if (this.props.isMinimized) nameContainer = null;
let globe = null;
if (this.isPublicRoom && this.props.tag !== DefaultTagID.DM) {
globe = <span className='mx_RoomTile2_publicRoom' />; // sizing and such set by CSS
}
const avatarSize = 32; const avatarSize = 32;
return ( return (
<React.Fragment> <React.Fragment>
@ -316,7 +306,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
> >
<div className="mx_RoomTile2_avatarContainer"> <div className="mx_RoomTile2_avatarContainer">
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize}/> <RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize}/>
{globe} <RoomTileIcon room={this.props.room} tag={this.props.tag}/>
</div> </div>
{nameContainer} {nameContainer}
<div className="mx_RoomTile2_badgeContainer"> <div className="mx_RoomTile2_badgeContainer">

View file

@ -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<IProps, IState> {
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 <span className={`mx_RoomTileIcon mx_RoomTileIcon_${this.state.icon.toLowerCase()}`} />;
}
}

26
src/utils/presence.ts Normal file
View file

@ -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;
}