From 1a0b43a769d07912d969e2fc476beb17a321d7a6 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 30 Aug 2023 15:41:56 +0100 Subject: [PATCH 1/4] Add E2E status in room header --- res/css/_common.pcss | 8 ++ src/components/views/rooms/RoomHeader.tsx | 38 +++++++- src/hooks/useEncryptionStatus.ts | 34 ++++++++ src/i18n/strings/en_EN.json | 1 + .../views/rooms/RoomHeader-test.tsx | 87 +++++++++++++++---- 5 files changed, 147 insertions(+), 21 deletions(-) create mode 100644 src/hooks/useEncryptionStatus.ts diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 6ba33dcc140..a560474a0b5 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -134,6 +134,14 @@ code { color: $muted-fg-color; } +.mx_Verified { + color: $e2e-verified-color; +} + +.mx_Untrusted { + color: $e2e-warning-color; +} + b { /* On Firefox, the default weight for `` is `bolder` which results in no bold */ /* effect since we only have specific weights of our fonts available. */ diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index eb324439645..095493f0314 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -14,15 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useMemo } from "react"; -import { Body as BodyText, IconButton } from "@vector-im/compound-web"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Body as BodyText, IconButton, Tooltip } from "@vector-im/compound-web"; import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call.svg"; import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg"; import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads-solid.svg"; import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications-solid.svg"; +import { Icon as VerifiedIcon } from "@vector-im/compound-design-tokens/icons/verified.svg"; +import { Icon as ErrorIcon } from "@vector-im/compound-design-tokens/icons/error.svg"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import { EventType, type Room } from "matrix-js-sdk/src/matrix"; -import type { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import { useRoomName } from "../../../hooks/useRoomName"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; @@ -41,6 +43,10 @@ import { NotificationColor } from "../../../stores/notifications/NotificationCol import { useGlobalNotificationState } from "../../../hooks/useGlobalNotificationState"; import SdkConfig from "../../../SdkConfig"; import { useFeatureEnabled } from "../../../hooks/useSettings"; +import { useEncryptionStatus } from "../../../hooks/useEncryptionStatus"; +import { E2EStatus } from "../../../utils/ShieldUtils"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useAccountData } from "../../../hooks/useAccountData"; /** * A helper to transform a notification color to the what the Compound Icon Button @@ -67,6 +73,8 @@ function showOrHidePanel(phase: RightPanelPhases): void { } export default function RoomHeader({ room }: { room: Room }): JSX.Element { + const client = useMatrixClientContext(); + const roomName = useRoomName(room); const roomTopic = useTopic(room); @@ -112,6 +120,18 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { 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 e2eStatus = useEncryptionStatus(client, room); + return ( {roomName} + + {isDirectMessage && e2eStatus === E2EStatus.Verified && ( + + + + )} + + {isDirectMessage && e2eStatus === E2EStatus.Warning && ( + + + + )} {roomTopic && ( diff --git a/src/hooks/useEncryptionStatus.ts b/src/hooks/useEncryptionStatus.ts new file mode 100644 index 00000000000..fd89770d24e --- /dev/null +++ b/src/hooks/useEncryptionStatus.ts @@ -0,0 +1,34 @@ +/* +Copyright 2023 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 { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { useEffect, useState } from "react"; + +import { E2EStatus, shieldStatusForRoom } from "../utils/ShieldUtils"; + +export function useEncryptionStatus(client: MatrixClient, room: Room): E2EStatus | null { + const [e2eStatus, setE2eStatus] = useState(null); + + useEffect(() => { + if (client.isCryptoEnabled()) { + shieldStatusForRoom(client, room).then((e2eStatus) => { + setE2eStatus(e2eStatus); + }); + } + }, [client, room]); + + return e2eStatus; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a4ecb6d551e..eeecba7d2b4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1821,6 +1821,7 @@ "Room %(name)s": "Room %(name)s", "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", + "Untrusted": "Untrusted", "Threads": "Threads", "Video room": "Video room", "Public space": "Public space", diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 73317a054ed..c52371804da 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -20,7 +20,7 @@ import { Room, EventType, MatrixEvent, PendingEventOrdering, MatrixCall } from " import userEvent from "@testing-library/user-event"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; -import { stubClient } from "../../../test-utils"; +import { stubClient, withClientContextRenderOptions } from "../../../test-utils"; import RoomHeader from "../../../../src/components/views/rooms/RoomHeader"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -57,7 +57,10 @@ describe("Roomeader", () => { }); it("renders the room header", () => { - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); expect(container).toHaveTextContent(ROOM_ID); }); @@ -75,26 +78,38 @@ describe("Roomeader", () => { }); await room.addLiveEvents([roomTopic]); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); expect(container).toHaveTextContent(TOPIC); }); it("opens the room summary", async () => { - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); await userEvent.click(getByText(container, ROOM_ID)); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary }); }); it("opens the thread panel", async () => { - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); await userEvent.click(getByTitle(container, "Threads")); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel }); }); it("opens the notifications panel", async () => { - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); await userEvent.click(getByTitle(container, "Notifications")); expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel }); @@ -103,7 +118,10 @@ describe("Roomeader", () => { describe("groups call disabled", () => { it("you can't call if you're alone", () => { mockRoomMembers(room, 1); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); for (const button of getAllByTitle(container, "There's no one here to call")) { expect(button).toBeDisabled(); } @@ -111,7 +129,10 @@ describe("Roomeader", () => { it("you can call when you're two in the room", async () => { mockRoomMembers(room, 2); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); const voiceButton = getByTitle(container, "Voice call"); const videoButton = getByTitle(container, "Video call"); expect(voiceButton).not.toBeDisabled(); @@ -132,7 +153,10 @@ describe("Roomeader", () => { // The JS-SDK does not export the class `MatrixCall` only the type {} as MatrixCall, ); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); for (const button of getAllByTitle(container, "Ongoing call")) { expect(button).toBeDisabled(); } @@ -141,7 +165,10 @@ describe("Roomeader", () => { it("can calls in large rooms if able to edit widgets", () => { mockRoomMembers(room, 10); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); expect(getByTitle(container, "Voice call")).not.toBeDisabled(); expect(getByTitle(container, "Video call")).not.toBeDisabled(); @@ -150,7 +177,10 @@ describe("Roomeader", () => { it("disable calls in large rooms by default", () => { mockRoomMembers(room, 10); jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); expect(getByTitle(container, "You do not have permission to start voice calls")).toBeDisabled(); expect(getByTitle(container, "You do not have permission to start video calls")).toBeDisabled(); }); @@ -166,7 +196,10 @@ describe("Roomeader", () => { // allow element calls jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); expect(screen.queryByTitle("Voice call")).toBeNull(); @@ -187,7 +220,10 @@ describe("Roomeader", () => { jest.spyOn(CallStore.instance, "getCall").mockReturnValue({} as Call); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); expect(getByTitle(container, "Ongoing call")).toBeDisabled(); }); @@ -197,7 +233,10 @@ describe("Roomeader", () => { // The JS-SDK does not export the class `MatrixCall` only the type {} as MatrixCall, ); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); for (const button of getAllByTitle(container, "Ongoing call")) { expect(button).toBeDisabled(); } @@ -205,7 +244,10 @@ describe("Roomeader", () => { it("can't call if you have no friends", () => { mockRoomMembers(room, 1); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); for (const button of getAllByTitle(container, "There's no one here to call")) { expect(button).toBeDisabled(); } @@ -213,7 +255,10 @@ describe("Roomeader", () => { it("calls using legacy or jitsi", async () => { mockRoomMembers(room, 2); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); const voiceButton = getByTitle(container, "Voice call"); const videoButton = getByTitle(container, "Video call"); @@ -236,7 +281,10 @@ describe("Roomeader", () => { return false; }); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); const voiceButton = getByTitle(container, "Voice call"); const videoButton = getByTitle(container, "Video call"); @@ -260,7 +308,10 @@ describe("Roomeader", () => { return false; }); - const { container } = render(); + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); const voiceButton = getByTitle(container, "Voice call"); const videoButton = getByTitle(container, "Video call"); From 8e3143a9bd3dcda26f3610e3cd2d1fbe5b93e3c4 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 30 Aug 2023 16:24:21 +0100 Subject: [PATCH 2/4] Clearer logic for dmRoomList Co-authored-by: Andy Balaam --- src/components/views/rooms/RoomHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 095493f0314..a06bfccd4f7 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -124,7 +124,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { const [isDirectMessage, setDirectMessage] = useState(false); useEffect(() => { for (const [, dmRoomList] of Object.entries(directRoomsList)) { - if (dmRoomList.includes(room?.roomId ?? "")) { + if (room?.roomId != "" && dmRoomList.includes(room.roomId)) { setDirectMessage(true); break; } From 8a09af75c6413f9aae32c4d85f84907c9eed361a Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 30 Aug 2023 17:39:48 +0100 Subject: [PATCH 3/4] Add test for E2E shield --- src/components/views/rooms/RoomHeader.tsx | 16 +++++-- .../views/rooms/RoomHeader-test.tsx | 47 +++++++++++++++++-- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index a06bfccd4f7..0af79266c92 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -124,7 +124,7 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { const [isDirectMessage, setDirectMessage] = useState(false); useEffect(() => { for (const [, dmRoomList] of Object.entries(directRoomsList)) { - if (room?.roomId != "" && dmRoomList.includes(room.roomId)) { + if (dmRoomList.includes(room?.roomId ?? "")) { setDirectMessage(true); break; } @@ -160,13 +160,23 @@ export default function RoomHeader({ room }: { room: Room }): JSX.Element { {isDirectMessage && e2eStatus === E2EStatus.Verified && ( - + )} {isDirectMessage && e2eStatus === E2EStatus.Warning && ( - + )} diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index c52371804da..9999397f284 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -15,12 +15,12 @@ limitations under the License. */ import React from "react"; -import { getAllByTitle, getByText, getByTitle, render, screen } from "@testing-library/react"; -import { Room, EventType, MatrixEvent, PendingEventOrdering, MatrixCall } from "matrix-js-sdk/src/matrix"; +import { getAllByTitle, getByLabelText, getByText, getByTitle, render, screen, waitFor } from "@testing-library/react"; +import { Room, EventType, MatrixEvent, PendingEventOrdering, MatrixCall, MatrixClient } from "matrix-js-sdk/src/matrix"; import userEvent from "@testing-library/user-event"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; -import { stubClient, withClientContextRenderOptions } from "../../../test-utils"; +import { mkEvent, stubClient, withClientContextRenderOptions } from "../../../test-utils"; import RoomHeader from "../../../../src/components/views/rooms/RoomHeader"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -32,6 +32,9 @@ import SdkConfig from "../../../../src/SdkConfig"; import dispatcher from "../../../../src/dispatcher/dispatcher"; import { CallStore } from "../../../../src/stores/CallStore"; import { Call, ElementCall } from "../../../../src/models/Call"; +import * as ShieldUtils from "../../../../src/utils/ShieldUtils"; + +jest.mock("../../../../src/utils/ShieldUtils"); describe("Roomeader", () => { let room: Room; @@ -327,6 +330,44 @@ describe("Roomeader", () => { expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true })); }); }); + + describe("dm", () => { + let client: MatrixClient; + beforeEach(() => { + 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; + }); + jest.spyOn(client, "isCryptoEnabled").mockReturnValue(true); + }); + + it.each([ + [ShieldUtils.E2EStatus.Verified, "Verified"], + [ShieldUtils.E2EStatus.Warning, "Untrusted"], + ])("shows the %s icon", async (value: ShieldUtils.E2EStatus, expectedLabel: string) => { + jest.spyOn(ShieldUtils, "shieldStatusForRoom").mockResolvedValue(value); + + const { container } = render( + , + withClientContextRenderOptions(MatrixClientPeg.get()!), + ); + + await waitFor(() => expect(getByLabelText(container, expectedLabel)).toBeInTheDocument()); + }); + }); }); /** From 421a97017c8c1961fbccfcbbcb81c3d08648dae3 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 30 Aug 2023 17:44:55 +0100 Subject: [PATCH 4/4] Remove dead code --- src/hooks/useAccountData.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/hooks/useAccountData.ts b/src/hooks/useAccountData.ts index b9399c25ea8..d59910d5033 100644 --- a/src/hooks/useAccountData.ts +++ b/src/hooks/useAccountData.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { useCallback, useState } from "react"; -import { ClientEvent, MatrixClient, MatrixEvent, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { useTypedEventEmitter } from "./useEventEmitter"; @@ -37,18 +37,20 @@ export const useAccountData = (cli: MatrixClient, eventType: strin return value || ({} as T); }; -// Hook to simplify listening to Matrix room account data -export const useRoomAccountData = (room: Room, eventType: string): T => { - const [value, setValue] = useState(() => tryGetContent(room.getAccountData(eventType))); +// Currently not used, commenting out otherwise the dead code CI is unhappy. +// But this code is valid and probably will be needed. - const handler = useCallback( - (event) => { - if (event.getType() !== eventType) return; - setValue(event.getContent()); - }, - [eventType], - ); - useTypedEventEmitter(room, RoomEvent.AccountData, handler); +// export const useRoomAccountData = (room: Room, eventType: string): T => { +// const [value, setValue] = useState(() => tryGetContent(room.getAccountData(eventType))); - return value || ({} as T); -}; +// const handler = useCallback( +// (event) => { +// if (event.getType() !== eventType) return; +// setValue(event.getContent()); +// }, +// [eventType], +// ); +// useTypedEventEmitter(room, RoomEvent.AccountData, handler); + +// return value || ({} as T); +// };