From 13ab1985aa45321ecaf0a0f758b0d23c5a5aaa0f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 6 Aug 2024 09:58:15 +0100 Subject: [PATCH 1/7] 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 <7t3chguy@gmail.com> --- res/css/_components.pcss | 1 + .../views/avatars/_WithPresenceIndicator.pcss | 54 +++++++ .../views/avatars/WithPresenceIndicator.tsx | 140 ++++++++++++++++++ src/components/views/rooms/PresenceLabel.tsx | 2 +- src/components/views/rooms/RoomHeader.tsx | 5 +- 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 res/css/views/avatars/_WithPresenceIndicator.pcss create mode 100644 src/components/views/avatars/WithPresenceIndicator.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index f81ad2bd409..85ac596d082 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -117,6 +117,7 @@ @import "./views/avatars/_BaseAvatar.pcss"; @import "./views/avatars/_DecoratedRoomAvatar.pcss"; @import "./views/avatars/_WidgetAvatar.pcss"; +@import "./views/avatars/_WithPresenceIndicator.pcss"; @import "./views/beta/_BetaCard.pcss"; @import "./views/context_menus/_DeviceContextMenu.pcss"; @import "./views/context_menus/_IconizedContextMenu.pcss"; diff --git a/res/css/views/avatars/_WithPresenceIndicator.pcss b/res/css/views/avatars/_WithPresenceIndicator.pcss new file mode 100644 index 00000000000..0a7f0c2ad9f --- /dev/null +++ b/res/css/views/avatars/_WithPresenceIndicator.pcss @@ -0,0 +1,54 @@ +/* +Copyright 2024 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_WithPresenceIndicator { + position: relative; + contain: content; + line-height: 0; + + .mx_WithPresenceIndicator_icon { + position: absolute; + right: -2px; + bottom: -2px; + } + + .mx_WithPresenceIndicator_icon::before { + content: ""; + width: 100%; + height: 100%; + right: 0; + bottom: 0; + position: absolute; + border: 2px solid var(--cpd-color-bg-canvas-default); + border-radius: 50%; + } + + .mx_WithPresenceIndicator_icon_offline::before { + background-color: $presence-offline; + } + + .mx_WithPresenceIndicator_icon_online::before { + background-color: $accent; + } + + .mx_WithPresenceIndicator_icon_away::before { + background-color: $presence-away; + } + + .mx_WithPresenceIndicator_icon_busy::before { + background-color: $presence-busy; + } +} diff --git a/src/components/views/avatars/WithPresenceIndicator.tsx b/src/components/views/avatars/WithPresenceIndicator.tsx new file mode 100644 index 00000000000..187ab5f8261 --- /dev/null +++ b/src/components/views/avatars/WithPresenceIndicator.tsx @@ -0,0 +1,140 @@ +/* +Copyright 2024 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, { ReactNode, useEffect, useState } from "react"; +import { ClientEvent, Room, RoomMember, RoomStateEvent, UserEvent } from "matrix-js-sdk/src/matrix"; +import { Tooltip } from "@vector-im/compound-web"; + +import { isPresenceEnabled } from "../../../utils/presence"; +import { _t } from "../../../languageHandler"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { BUSY_PRESENCE_NAME } from "../rooms/PresenceLabel"; + +interface Props { + room: Room; + size: string; // CSS size + tooltipProps?: { + tabIndex?: number; + }; + children: ReactNode; +} + +enum Presence { + // Note: the names here are used in CSS class names + Online = "ONLINE", + Away = "AWAY", + Offline = "OFFLINE", + Busy = "BUSY", +} + +function tooltipText(variant: Presence): string { + switch (variant) { + case Presence.Online: + return _t("presence|online"); + case Presence.Away: + return _t("presence|away"); + case Presence.Offline: + return _t("presence|offline"); + case Presence.Busy: + return _t("presence|busy"); + } +} + +const useDmMember = (room: Room): RoomMember | null => { + const [dmMember, setDmMember] = useState(null); + const updateDmMember = (): void => { + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + if (otherUserId && getJoinedNonFunctionalMembers(room).length === 2 && isPresenceEnabled(room.client)) { + setDmMember(room.getMember(otherUserId)); + } + }; + + useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember); + useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember); + useEffect(updateDmMember, [room]); + + return dmMember; +}; + +function getPresenceIcon(member: RoomMember | null): Presence | null { + if (!member?.user) return null; + + const presence = member.user.presence; + const isOnline = member.user.currentlyActive || presence === "online"; + if (BUSY_PRESENCE_NAME.matches(member.user.presence)) { + return Presence.Busy; + } + if (isOnline) { + return Presence.Online; + } + if (presence === "offline") { + return Presence.Offline; + } + if (presence === "unavailable") { + return Presence.Away; + } + + return null; +} + +const usePresence = (member: RoomMember | null): Presence | null => { + const [presence, setPresence] = useState(getPresenceIcon(member)); + const updatePresence = (): void => { + setPresence(getPresenceIcon(member)); + }; + + useEventEmitter(member?.user, UserEvent.Presence, updatePresence); + useEventEmitter(member?.user, UserEvent.CurrentlyActive, updatePresence); + useEffect(updatePresence, [member]); + + return presence; +}; + +const WithPresenceIndicator: React.FC = ({ room, size, tooltipProps, children }) => { + const dmMember = useDmMember(room); + const presence = usePresence(dmMember); + + let icon: JSX.Element | undefined; + if (presence) { + icon = ( +
+ ); + } + + if (!presence) return <>{children}; + + return ( +
+ {children} + {icon && ( + + {icon} + + )} +
+ ); +}; + +export default WithPresenceIndicator; diff --git a/src/components/views/rooms/PresenceLabel.tsx b/src/components/views/rooms/PresenceLabel.tsx index bdbc7e23e2a..55e6b111d96 100644 --- a/src/components/views/rooms/PresenceLabel.tsx +++ b/src/components/views/rooms/PresenceLabel.tsx @@ -21,7 +21,7 @@ import classNames from "classnames"; import { _t } from "../../../languageHandler"; import { formatDuration } from "../../../DateUtils"; -const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy"); +export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy"); interface IProps { // number of milliseconds ago this user was last active. diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 11873ee129e..a10ccd26975 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -58,6 +58,7 @@ import { ButtonEvent } from "../elements/AccessibleButton"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement"; import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnouncementOpen"; import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore"; +import WithPresenceIndicator from "../avatars/WithPresenceIndicator"; export default function RoomHeader({ room, @@ -259,7 +260,9 @@ export default function RoomHeader({ }} className="mx_RoomHeader_infoWrapper" > - + + + Date: Tue, 6 Aug 2024 09:58:27 +0100 Subject: [PATCH 2/7] Add oobData to new room header avatar Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomHeader.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index a10ccd26975..9bfb1b7b3e5 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -59,13 +59,16 @@ import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement"; import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnouncementOpen"; import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore"; import WithPresenceIndicator from "../avatars/WithPresenceIndicator"; +import { IOOBData } from "../../../stores/ThreepidInviteStore"; export default function RoomHeader({ room, additionalButtons, + oobData, }: { room: Room; additionalButtons?: ViewRoomOpts["buttons"]; + oobData?: IOOBData; }): JSX.Element { const client = useMatrixClientContext(); @@ -261,7 +264,7 @@ export default function RoomHeader({ className="mx_RoomHeader_infoWrapper" > - + Date: Tue, 6 Aug 2024 10:01:05 +0100 Subject: [PATCH 3/7] Simplify Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../views/avatars/WithPresenceIndicator.tsx | 2 +- src/components/views/rooms/RoomHeader.tsx | 23 ++++++------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/components/views/avatars/WithPresenceIndicator.tsx b/src/components/views/avatars/WithPresenceIndicator.tsx index 187ab5f8261..5bc30e63975 100644 --- a/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/src/components/views/avatars/WithPresenceIndicator.tsx @@ -55,7 +55,7 @@ function tooltipText(variant: Presence): string { } } -const useDmMember = (room: Room): RoomMember | null => { +export const useDmMember = (room: Room): RoomMember | null => { const [dmMember, setDmMember] = useState(null); const updateDmMember = (): void => { const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 9bfb1b7b3e5..c3e5ad78ae0 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web"; import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg"; 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 import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; -import { EventType, JoinRule, type Room } from "matrix-js-sdk/src/matrix"; +import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { useRoomName } from "../../../hooks/useRoomName"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; -import { useAccountData } from "../../../hooks/useAccountData"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useRoomMemberCount, useRoomMembers } from "../../../hooks/useRoomMembers"; import { _t } from "../../../languageHandler"; @@ -58,7 +57,7 @@ import { ButtonEvent } from "../elements/AccessibleButton"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement"; import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnouncementOpen"; import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore"; -import WithPresenceIndicator from "../avatars/WithPresenceIndicator"; +import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator"; import { IOOBData } from "../../../stores/ThreepidInviteStore"; export default function RoomHeader({ @@ -73,7 +72,7 @@ export default function RoomHeader({ const client = useMatrixClientContext(); const roomName = useRoomName(room); - const roomState = useRoomState(room); + const joinRule = useRoomState(room, (state) => state.getJoinRule()); const members = useRoomMembers(room, 2500); const memberCount = useRoomMemberCount(room, { throttleWait: 2500 }); @@ -104,16 +103,8 @@ export default function RoomHeader({ const threadNotifications = useRoomThreadNotifications(room); const globalNotificationState = useGlobalNotificationState(); - const directRoomsList = useAccountData>(client, EventType.Direct); - const [isDirectMessage, setDirectMessage] = useState(false); - useEffect(() => { - for (const [, dmRoomList] of Object.entries(directRoomsList)) { - if (dmRoomList.includes(room?.roomId ?? "")) { - setDirectMessage(true); - break; - } - } - }, [room, directRoomsList]); + const dmMember = useDmMember(room); + const isDirectMessage = !!dmMember; const e2eStatus = useEncryptionStatus(client, room); const notificationsEnabled = useFeatureEnabled("feature_notifications"); @@ -278,7 +269,7 @@ export default function RoomHeader({ > {roomName} - {!isDirectMessage && roomState.getJoinRule() === JoinRule.Public && ( + {!isDirectMessage && joinRule === JoinRule.Public && ( Date: Tue, 6 Aug 2024 10:28:53 +0100 Subject: [PATCH 4/7] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../views/avatars/WithPresenceIndicator.tsx | 23 +++--- .../views/rooms/RoomHeader-test.tsx | 76 ++++++++----------- .../__snapshots__/RoomHeader-test.tsx.snap | 19 +++-- 3 files changed, 58 insertions(+), 60 deletions(-) diff --git a/src/components/views/avatars/WithPresenceIndicator.tsx b/src/components/views/avatars/WithPresenceIndicator.tsx index 5bc30e63975..32e04ef9154 100644 --- a/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/src/components/views/avatars/WithPresenceIndicator.tsx @@ -55,13 +55,15 @@ function tooltipText(variant: Presence): string { } } +function getDmMember(room: Room): RoomMember | null { + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + return otherUserId ? room.getMember(otherUserId) : null; +} + export const useDmMember = (room: Room): RoomMember | null => { - const [dmMember, setDmMember] = useState(null); + const [dmMember, setDmMember] = useState(getDmMember(room)); const updateDmMember = (): void => { - const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - if (otherUserId && getJoinedNonFunctionalMembers(room).length === 2 && isPresenceEnabled(room.client)) { - setDmMember(room.getMember(otherUserId)); - } + setDmMember(getDmMember(room)); }; useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember); @@ -71,7 +73,7 @@ export const useDmMember = (room: Room): RoomMember | null => { return dmMember; }; -function getPresenceIcon(member: RoomMember | null): Presence | null { +function getPresence(member: RoomMember | null): Presence | null { if (!member?.user) return null; const presence = member.user.presence; @@ -92,22 +94,23 @@ function getPresenceIcon(member: RoomMember | null): Presence | null { return null; } -const usePresence = (member: RoomMember | null): Presence | null => { - const [presence, setPresence] = useState(getPresenceIcon(member)); +const usePresence = (room: Room, member: RoomMember | null): Presence | null => { + const [presence, setPresence] = useState(getPresence(member)); const updatePresence = (): void => { - setPresence(getPresenceIcon(member)); + setPresence(getPresence(member)); }; useEventEmitter(member?.user, UserEvent.Presence, updatePresence); useEventEmitter(member?.user, UserEvent.CurrentlyActive, updatePresence); useEffect(updatePresence, [member]); + if (getJoinedNonFunctionalMembers(room).length !== 2 || !isPresenceEnabled(room.client)) return null; return presence; }; const WithPresenceIndicator: React.FC = ({ room, size, tooltipProps, children }) => { const dmMember = useDmMember(room); - const presence = usePresence(dmMember); + const presence = usePresence(room, dmMember); let icon: JSX.Element | undefined; if (presence) { diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 06eaaa1837c..0e84b5a0b21 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -40,8 +40,9 @@ import { waitFor, } from "@testing-library/react"; import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; +import { mocked } from "jest-mock"; -import { filterConsole, mkEvent, stubClient } from "../../../test-utils"; +import { filterConsole, stubClient } from "../../../test-utils"; import RoomHeader from "../../../../src/components/views/rooms/RoomHeader"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -111,37 +112,6 @@ describe("RoomHeader", () => { expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary }); }); - it("does not show the face pile for DMs", () => { - const client = MatrixClientPeg.get()!; - - jest.spyOn(client, "getAccountData").mockReturnValue( - mkEvent({ - event: true, - type: EventType.Direct, - user: client.getSafeUserId(), - content: { - "user@example.com": [room.roomId], - }, - }), - ); - - room.getJoinedMembers = jest.fn().mockReturnValue([ - { - userId: "@me:example.org", - name: "Member", - rawDisplayName: "Member", - roomId: room.roomId, - membership: KnownMembership.Join, - getAvatarUrl: () => "mxc://avatar.url/image.png", - getMxcAvatarUrl: () => "mxc://avatar.url/image.png", - }, - ]); - - const { asFragment } = render(, getWrapper()); - - expect(asFragment()).toMatchSnapshot(); - }); - it("shows a face pile for rooms", async () => { const members = [ { @@ -620,20 +590,30 @@ describe("RoomHeader", () => { client = MatrixClientPeg.get()!; // Make the mocked room a DM - jest.spyOn(client, "getAccountData").mockImplementation((eventType: string): MatrixEvent | undefined => { - if (eventType === EventType.Direct) { - return mkEvent({ - event: true, - content: { - [client.getUserId()!]: [room.roomId], - }, - type: EventType.Direct, - user: client.getSafeUserId(), - }); - } - - return undefined; + mocked(DMRoomMap.shared().getUserIdForRoomId).mockImplementation((roomId) => { + if (roomId === room.roomId) return "@user:example.com"; }); + room.getMember = jest.fn((userId) => new RoomMember(room.roomId, userId)); + room.getJoinedMembers = jest.fn().mockReturnValue([ + { + userId: "@me:example.org", + name: "Member", + rawDisplayName: "Member", + roomId: room.roomId, + membership: KnownMembership.Join, + getAvatarUrl: () => "mxc://avatar.url/image.png", + getMxcAvatarUrl: () => "mxc://avatar.url/image.png", + }, + { + userId: "@bob:example.org", + name: "Other Member", + rawDisplayName: "Other Member", + roomId: room.roomId, + membership: KnownMembership.Join, + getAvatarUrl: () => "mxc://avatar.url/image.png", + getMxcAvatarUrl: () => "mxc://avatar.url/image.png", + }, + ]); jest.spyOn(client, "isCryptoEnabled").mockReturnValue(true); }); @@ -647,6 +627,12 @@ describe("RoomHeader", () => { await waitFor(() => expect(getByLabelText(container, expectedLabel)).toBeInTheDocument()); }); + + it("does not show the face pile for DMs", () => { + const { asFragment } = render(, getWrapper()); + + expect(asFragment()).toMatchSnapshot(); + }); }); it("renders additionalButtons", async () => { diff --git a/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap index dd76c363676..17ed172be2b 100644 --- a/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoomHeader does not show the face pile for DMs 1`] = ` +exports[`RoomHeader dm does not show the face pile for DMs 1`] = `