diff --git a/res/css/_components.pcss b/res/css/_components.pcss index f81ad2bd40..85ac596d08 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 0000000000..0a7f0c2ad9 --- /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 0000000000..1d32a99e00 --- /dev/null +++ b/src/components/views/avatars/WithPresenceIndicator.tsx @@ -0,0 +1,141 @@ +/* +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"); + } +} + +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(getDmMember(room)); + const updateDmMember = (): void => { + setDmMember(getDmMember(room)); + }; + + useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember); + useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember); + useEffect(updateDmMember, [room]); + + return dmMember; +}; + +function getPresence(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 = (room: Room, member: RoomMember | null): Presence | null => { + const [presence, setPresence] = useState(getPresence(member)); + const updatePresence = (): void => { + 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(room, dmMember); + + let icon: JSX.Element | undefined; + if (presence) { + icon = ( +
+ ); + } + + if (!presence) return <>{children}; + + return ( +
+ {children} + + {icon} + +
+ ); +}; + +export default WithPresenceIndicator; diff --git a/src/components/views/rooms/PresenceLabel.tsx b/src/components/views/rooms/PresenceLabel.tsx index bdbc7e23e2..55e6b111d9 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 11873ee129..c3e5ad78ae 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,18 +57,22 @@ import { ButtonEvent } from "../elements/AccessibleButton"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement"; import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnouncementOpen"; import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore"; +import WithPresenceIndicator, { useDmMember } 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(); 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 }); @@ -100,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"); @@ -259,7 +254,9 @@ export default function RoomHeader({ }} className="mx_RoomHeader_infoWrapper" > - + + + {roomName} - {!isDirectMessage && roomState.getJoinRule() === JoinRule.Public && ( + {!isDirectMessage && joinRule === JoinRule.Public && ( ({ + getJoinedNonFunctionalMembers: jest.fn().mockReturnValue([1, 2]), +})); + +describe("WithPresenceIndicator", () => { + const ROOM_ID = "roomId"; + + let mockClient: MatrixClient; + let room: Room; + + function renderComponent() { + return render( + + + , + ); + } + + beforeEach(() => { + stubClient(); + mockClient = mocked(MatrixClientPeg.safeGet()); + room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders only child if presence is disabled", async () => { + mocked(isPresenceEnabled).mockReturnValue(false); + const { container } = renderComponent(); + + expect(container.children).toHaveLength(1); + expect(container.children[0].tagName).toBe("SPAN"); + }); + + it.each([ + ["online", "Online"], + ["offline", "Offline"], + ["unavailable", "Away"], + ])("renders presence indicator with tooltip for DM rooms", async (presenceStr, renderedStr) => { + mocked(isPresenceEnabled).mockReturnValue(true); + const DM_USER_ID = "@bob:foo.bar"; + const dmRoomMap = { + getUserIdForRoomId: () => { + return DM_USER_ID; + }, + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + room.getMember = jest.fn((userId) => { + const member = new RoomMember(room.roomId, userId); + member.user = new User(userId); + member.user.presence = presenceStr; + return member; + }); + + const { container, asFragment } = renderComponent(); + + const presence = container.querySelector(".mx_WithPresenceIndicator_icon")!; + expect(presence).toBeVisible(); + await userEvent.hover(presence!); + + // wait for the tooltip to open + const tooltip = await waitFor(() => { + const tooltip = document.getElementById(presence.getAttribute("aria-describedby")!); + expect(tooltip).toBeVisible(); + return tooltip; + }); + expect(tooltip).toHaveTextContent(renderedStr); + + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/avatars/__snapshots__/WithPresenceIndicator-test.tsx.snap b/test/components/views/avatars/__snapshots__/WithPresenceIndicator-test.tsx.snap new file mode 100644 index 0000000000..aefa78f271 --- /dev/null +++ b/test/components/views/avatars/__snapshots__/WithPresenceIndicator-test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WithPresenceIndicator renders presence indicator with tooltip for DM rooms 1`] = ` + +
+ +
+
+ +`; + +exports[`WithPresenceIndicator renders presence indicator with tooltip for DM rooms 2`] = ` + +
+ +
+
+ +`; + +exports[`WithPresenceIndicator renders presence indicator with tooltip for DM rooms 3`] = ` + +
+ +
+
+ +`; diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 06eaaa1837..0e84b5a0b2 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 dd76c36367..17ed172be2 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`] = `