Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit dde19f3

Browse files
authored
Add missing presence indicator to new room header (#12865)
* Add missing presence indicator to new room header DecoratedRoomAvatar doesn't match Figma styles so created a composable avatar wrapper Signed-off-by: Michael Telatynski <[email protected]> * Add oobData to new room header avatar Signed-off-by: Michael Telatynski <[email protected]> * Simplify Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Simplify Signed-off-by: Michael Telatynski <[email protected]> * Add tests Signed-off-by: Michael Telatynski <[email protected]> * Improve coverage Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent ca8d63a commit dde19f3

File tree

9 files changed

+414
-67
lines changed

9 files changed

+414
-67
lines changed

res/css/_components.pcss

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
@import "./views/avatars/_BaseAvatar.pcss";
118118
@import "./views/avatars/_DecoratedRoomAvatar.pcss";
119119
@import "./views/avatars/_WidgetAvatar.pcss";
120+
@import "./views/avatars/_WithPresenceIndicator.pcss";
120121
@import "./views/beta/_BetaCard.pcss";
121122
@import "./views/context_menus/_DeviceContextMenu.pcss";
122123
@import "./views/context_menus/_IconizedContextMenu.pcss";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
.mx_WithPresenceIndicator {
18+
position: relative;
19+
contain: content;
20+
line-height: 0;
21+
22+
.mx_WithPresenceIndicator_icon {
23+
position: absolute;
24+
right: -2px;
25+
bottom: -2px;
26+
}
27+
28+
.mx_WithPresenceIndicator_icon::before {
29+
content: "";
30+
width: 100%;
31+
height: 100%;
32+
right: 0;
33+
bottom: 0;
34+
position: absolute;
35+
border: 2px solid var(--cpd-color-bg-canvas-default);
36+
border-radius: 50%;
37+
}
38+
39+
.mx_WithPresenceIndicator_icon_offline::before {
40+
background-color: $presence-offline;
41+
}
42+
43+
.mx_WithPresenceIndicator_icon_online::before {
44+
background-color: $accent;
45+
}
46+
47+
.mx_WithPresenceIndicator_icon_away::before {
48+
background-color: $presence-away;
49+
}
50+
51+
.mx_WithPresenceIndicator_icon_busy::before {
52+
background-color: $presence-busy;
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { ReactNode, useEffect, useState } from "react";
18+
import { ClientEvent, Room, RoomMember, RoomStateEvent, UserEvent } from "matrix-js-sdk/src/matrix";
19+
import { Tooltip } from "@vector-im/compound-web";
20+
21+
import { isPresenceEnabled } from "../../../utils/presence";
22+
import { _t } from "../../../languageHandler";
23+
import DMRoomMap from "../../../utils/DMRoomMap";
24+
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
25+
import { useEventEmitter } from "../../../hooks/useEventEmitter";
26+
import { BUSY_PRESENCE_NAME } from "../rooms/PresenceLabel";
27+
28+
interface Props {
29+
room: Room;
30+
size: string; // CSS size
31+
tooltipProps?: {
32+
tabIndex?: number;
33+
};
34+
children: ReactNode;
35+
}
36+
37+
enum Presence {
38+
// Note: the names here are used in CSS class names
39+
Online = "ONLINE",
40+
Away = "AWAY",
41+
Offline = "OFFLINE",
42+
Busy = "BUSY",
43+
}
44+
45+
function tooltipText(variant: Presence): string {
46+
switch (variant) {
47+
case Presence.Online:
48+
return _t("presence|online");
49+
case Presence.Away:
50+
return _t("presence|away");
51+
case Presence.Offline:
52+
return _t("presence|offline");
53+
case Presence.Busy:
54+
return _t("presence|busy");
55+
}
56+
}
57+
58+
function getDmMember(room: Room): RoomMember | null {
59+
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
60+
return otherUserId ? room.getMember(otherUserId) : null;
61+
}
62+
63+
export const useDmMember = (room: Room): RoomMember | null => {
64+
const [dmMember, setDmMember] = useState<RoomMember | null>(getDmMember(room));
65+
const updateDmMember = (): void => {
66+
setDmMember(getDmMember(room));
67+
};
68+
69+
useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember);
70+
useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember);
71+
useEffect(updateDmMember, [room]);
72+
73+
return dmMember;
74+
};
75+
76+
function getPresence(member: RoomMember | null): Presence | null {
77+
if (!member?.user) return null;
78+
79+
const presence = member.user.presence;
80+
const isOnline = member.user.currentlyActive || presence === "online";
81+
if (BUSY_PRESENCE_NAME.matches(member.user.presence)) {
82+
return Presence.Busy;
83+
}
84+
if (isOnline) {
85+
return Presence.Online;
86+
}
87+
if (presence === "offline") {
88+
return Presence.Offline;
89+
}
90+
if (presence === "unavailable") {
91+
return Presence.Away;
92+
}
93+
94+
return null;
95+
}
96+
97+
const usePresence = (room: Room, member: RoomMember | null): Presence | null => {
98+
const [presence, setPresence] = useState<Presence | null>(getPresence(member));
99+
const updatePresence = (): void => {
100+
setPresence(getPresence(member));
101+
};
102+
103+
useEventEmitter(member?.user, UserEvent.Presence, updatePresence);
104+
useEventEmitter(member?.user, UserEvent.CurrentlyActive, updatePresence);
105+
useEffect(updatePresence, [member]);
106+
107+
if (getJoinedNonFunctionalMembers(room).length !== 2 || !isPresenceEnabled(room.client)) return null;
108+
return presence;
109+
};
110+
111+
const WithPresenceIndicator: React.FC<Props> = ({ room, size, tooltipProps, children }) => {
112+
const dmMember = useDmMember(room);
113+
const presence = usePresence(room, dmMember);
114+
115+
let icon: JSX.Element | undefined;
116+
if (presence) {
117+
icon = (
118+
<div
119+
tabIndex={tooltipProps?.tabIndex ?? 0}
120+
className={`mx_WithPresenceIndicator_icon mx_WithPresenceIndicator_icon_${presence.toLowerCase()}`}
121+
style={{
122+
width: size,
123+
height: size,
124+
}}
125+
/>
126+
);
127+
}
128+
129+
if (!presence) return <>{children}</>;
130+
131+
return (
132+
<div className="mx_WithPresenceIndicator">
133+
{children}
134+
<Tooltip label={tooltipText(presence)} placement="bottom">
135+
{icon}
136+
</Tooltip>
137+
</div>
138+
);
139+
};
140+
141+
export default WithPresenceIndicator;

src/components/views/rooms/PresenceLabel.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import classNames from "classnames";
2121
import { _t } from "../../../languageHandler";
2222
import { formatDuration } from "../../../DateUtils";
2323

24-
const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy");
24+
export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy");
2525

2626
interface IProps {
2727
// number of milliseconds ago this user was last active.

src/components/views/rooms/RoomHeader.tsx

+13-16
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React, { useCallback, useEffect, useMemo, useState } from "react";
17+
import React, { useCallback, useMemo, useState } from "react";
1818
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
1919
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg";
2020
import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg";
@@ -25,12 +25,11 @@ import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/ico
2525
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
2626
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
2727
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
28-
import { EventType, JoinRule, type Room } from "matrix-js-sdk/src/matrix";
28+
import { JoinRule, type Room } from "matrix-js-sdk/src/matrix";
2929
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
3030

3131
import { useRoomName } from "../../../hooks/useRoomName";
3232
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
33-
import { useAccountData } from "../../../hooks/useAccountData";
3433
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
3534
import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers";
3635
import { _t } from "../../../languageHandler";
@@ -58,18 +57,22 @@ import { ButtonEvent } from "../elements/AccessibleButton";
5857
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement";
5958
import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnouncementOpen";
6059
import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore";
60+
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
61+
import { IOOBData } from "../../../stores/ThreepidInviteStore";
6162

6263
export default function RoomHeader({
6364
room,
6465
additionalButtons,
66+
oobData,
6567
}: {
6668
room: Room;
6769
additionalButtons?: ViewRoomOpts["buttons"];
70+
oobData?: IOOBData;
6871
}): JSX.Element {
6972
const client = useMatrixClientContext();
7073

7174
const roomName = useRoomName(room);
72-
const roomState = useRoomState(room);
75+
const joinRule = useRoomState(room, (state) => state.getJoinRule());
7376

7477
const members = useRoomMembers(room, 2500);
7578
const memberCount = useRoomMemberCount(room, { throttleWait: 2500 });
@@ -100,16 +103,8 @@ export default function RoomHeader({
100103
const threadNotifications = useRoomThreadNotifications(room);
101104
const globalNotificationState = useGlobalNotificationState();
102105

103-
const directRoomsList = useAccountData<Record<string, string[]>>(client, EventType.Direct);
104-
const [isDirectMessage, setDirectMessage] = useState(false);
105-
useEffect(() => {
106-
for (const [, dmRoomList] of Object.entries(directRoomsList)) {
107-
if (dmRoomList.includes(room?.roomId ?? "")) {
108-
setDirectMessage(true);
109-
break;
110-
}
111-
}
112-
}, [room, directRoomsList]);
106+
const dmMember = useDmMember(room);
107+
const isDirectMessage = !!dmMember;
113108
const e2eStatus = useEncryptionStatus(client, room);
114109

115110
const notificationsEnabled = useFeatureEnabled("feature_notifications");
@@ -259,7 +254,9 @@ export default function RoomHeader({
259254
}}
260255
className="mx_RoomHeader_infoWrapper"
261256
>
262-
<RoomAvatar room={room} size="40px" />
257+
<WithPresenceIndicator room={room} size="8px">
258+
<RoomAvatar room={room} size="40px" oobData={oobData} />
259+
</WithPresenceIndicator>
263260
<Box flex="1" className="mx_RoomHeader_info">
264261
<BodyText
265262
as="div"
@@ -272,7 +269,7 @@ export default function RoomHeader({
272269
>
273270
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
274271

275-
{!isDirectMessage && roomState.getJoinRule() === JoinRule.Public && (
272+
{!isDirectMessage && joinRule === JoinRule.Public && (
276273
<Tooltip label={_t("common|public_room")} placement="right">
277274
<PublicIcon
278275
width="16px"

0 commit comments

Comments
 (0)