From 09ceb3c580ffc06d93920240ccabea7644aab74a Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 17 Apr 2025 17:56:19 +0200 Subject: [PATCH] MVVM RoomSummaryCard Topic (#29710) * feat: create roomSummaryCardTopic view model * chore: add comments and small update on test mock --- .../RoomSummaryCardTopicViewModel.tsx | 84 +++++++++++++++++ .../views/right_panel/RoomSummaryCard.tsx | 48 +++------- .../RoomSummaryCardTopicViewModel-test.tsx | 91 +++++++++++++++++++ 3 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx create mode 100644 test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel-test.tsx diff --git a/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx new file mode 100644 index 0000000000..10cbc5b568 --- /dev/null +++ b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx @@ -0,0 +1,84 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { type SyntheticEvent, useState } from "react"; +import { EventType, type Room, type ContentHelpers } from "matrix-js-sdk/src/matrix"; +import { type Optional } from "matrix-events-sdk"; + +import { useRoomState } from "../../../hooks/useRoomState"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { onRoomTopicLinkClick } from "../../views/elements/RoomTopic"; +import { useTopic } from "../../../hooks/room/useTopic"; + +export interface RoomTopicState { + /** + * The topic of the room, the value is taken from the room state + */ + topic: Optional; + /** + * Whether the topic is expanded or not + */ + expanded: boolean; + /** + * Whether the user have the permission to edit the topic + */ + canEditTopic: boolean; + /** + * The callback when the edit button is clicked + */ + onEditClick: (e: SyntheticEvent) => void; + /** + * When the expand button is clicked, it changes expanded state + */ + onExpandedClick: (ev: SyntheticEvent) => void; + /** + * The callback when the topic link is clicked + */ + onTopicLinkClick: React.MouseEventHandler; +} + +/** + * The view model for the room topic used in the RoomSummaryCard + * @param room - the room to get the topic from + * @returns the room topic state + */ +export function useRoomTopicViewModel(room: Room): RoomTopicState { + const [expanded, setExpanded] = useState(true); + + const topic = useTopic(room); + + const canEditTopic = useRoomState(room, (state) => + state.maySendStateEvent(EventType.RoomTopic, room.client.getSafeUserId()), + ); + + const onEditClick = (e: SyntheticEvent): void => { + e.preventDefault(); + e.stopPropagation(); + defaultDispatcher.dispatch({ action: "open_room_settings" }); + }; + + const onExpandedClick = (e: SyntheticEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setExpanded((_expanded) => !_expanded); + }; + + const onTopicLinkClick = (e: React.MouseEvent): void => { + if (e.target instanceof HTMLAnchorElement) { + onRoomTopicLinkClick(e); + return; + } + }; + + return { + topic, + expanded, + canEditTopic, + onEditClick, + onExpandedClick, + onTopicLinkClick, + }; +} diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index a44f649d01..7894ec0d89 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, type ChangeEvent, type SyntheticEvent, useContext, useEffect, useRef, useState } from "react"; +import React, { type JSX, type ChangeEvent, useContext, useEffect, useRef, useState } from "react"; import classNames from "classnames"; import { MenuItem, @@ -66,10 +66,8 @@ import { canInviteTo } from "../../../utils/room/canInviteTo"; import { inviteToRoom } from "../../../utils/room/inviteToRoom"; import { useAccountData } from "../../../hooks/useAccountData"; import { useRoomState } from "../../../hooks/useRoomState"; -import { useTopic } from "../../../hooks/room/useTopic"; import { Linkify, topicToHtml } from "../../../HtmlUtils"; import { Box } from "../../utils/Box"; -import { onRoomTopicLinkClick } from "../elements/RoomTopic"; import { useDispatcher } from "../../../hooks/useDispatcher"; import { Action } from "../../../dispatcher/actions"; import { Key } from "../../../Keyboard"; @@ -79,6 +77,7 @@ import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; import { ReportRoomDialog } from "../dialogs/ReportRoomDialog.tsx"; +import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx"; interface IProps { room: Room; @@ -115,21 +114,11 @@ const onRoomSettingsClick = (ev: Event): void => { }; const RoomTopic: React.FC> = ({ room }): JSX.Element | null => { - const [expanded, setExpanded] = useState(true); + const vm = useRoomTopicViewModel(room); - const topic = useTopic(room); - const body = topicToHtml(topic?.text, topic?.html); + const body = topicToHtml(vm.topic?.text, vm.topic?.html); - const canEditTopic = useRoomState(room, (state) => - state.maySendStateEvent(EventType.RoomTopic, room.client.getSafeUserId()), - ); - const onEditClick = (e: SyntheticEvent): void => { - e.preventDefault(); - e.stopPropagation(); - defaultDispatcher.dispatch({ action: "open_room_settings" }); - }; - - if (!body && !canEditTopic) { + if (!body && !vm.canEditTopic) { return null; } @@ -143,7 +132,7 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null className="mx_RoomSummaryCard_topic" > - + {_t("right_panel|add_topic")} @@ -153,7 +142,7 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null ); } - const content = expanded ? {body} : body; + const content = vm.expanded ? {body} : body; return ( > = ({ room }): JSX.Element | null justify="center" gap="var(--cpd-space-2x)" className={classNames("mx_RoomSummaryCard_topic", { - mx_RoomSummaryCard_topic_collapsed: !expanded, + mx_RoomSummaryCard_topic_collapsed: !vm.expanded, })} > - { - if (ev.target instanceof HTMLAnchorElement) { - onRoomTopicLinkClick(ev); - return; - } - }} - > + {content} - setExpanded(!expanded)} - > + - {expanded && canEditTopic && ( + {vm.expanded && vm.canEditTopic && ( - + {_t("action|edit")} diff --git a/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel-test.tsx new file mode 100644 index 0000000000..b1db7bcb19 --- /dev/null +++ b/test/unit-tests/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel-test.tsx @@ -0,0 +1,91 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { renderHook } from "jest-matrix-react"; +import { act, type SyntheticEvent } from "react"; + +import { useRoomTopicViewModel } from "../../../../../src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel"; +import { createTestClient, mkStubRoom } from "../../../../test-utils"; +import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; +import { onRoomTopicLinkClick } from "../../../../../src/components/views/elements/RoomTopic"; + +jest.mock("../../../../../src/components/views/elements/RoomTopic"); + +describe("RoomSummaryCardTopicViewModel", () => { + const client = createTestClient(); + const mockRoom = mkStubRoom("!room:example.com", "Test Room", client); + const mockUserId = "@user:example.com"; + const mockEvent = { preventDefault: jest.fn(), stopPropagation: jest.fn() } as unknown as SyntheticEvent; + + beforeEach(() => { + // Mock room client's getSafeUserId + mockRoom.client.getSafeUserId = jest.fn().mockReturnValue(mockUserId); + jest.spyOn(defaultDispatcher, "dispatch"); + + (onRoomTopicLinkClick as jest.Mock).mockReset(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function render() { + return renderHook(() => useRoomTopicViewModel(mockRoom)); + } + + it("should initialize with expanded state", () => { + const { result } = render(); + expect(result.current.expanded).toBe(true); + }); + + it("should toggle expanded state on click", async () => { + const { result } = render(); + + await act(() => { + result.current.onExpandedClick(mockEvent); + }); + + expect(result.current.expanded).toBe(false); + }); + + it("should handle edit click", () => { + const { result } = render(); + result.current.onEditClick(mockEvent); + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "open_room_settings" }); + }); + + it("should handle topic link clicks when the target is an anchor element", () => { + const { result } = render(); + const mockAnchorEvent = { target: document.createElement("a") } as unknown as React.MouseEvent; + + result.current.onTopicLinkClick(mockAnchorEvent); + expect(onRoomTopicLinkClick).toHaveBeenCalledWith(mockAnchorEvent); + }); + + it("should handle topic link clicks when the target is not an anchor element", () => { + const { result } = render(); + const mockNonAnchorEvent = { + target: document.createElement("div"), + } as unknown as React.MouseEvent; + + result.current.onTopicLinkClick(mockNonAnchorEvent); + expect(onRoomTopicLinkClick).not.toHaveBeenCalled(); + }); + + describe("Topic editing permissions", () => { + it("should allow editing when user has permission", () => { + mockRoom.currentState.maySendStateEvent = jest.fn().mockReturnValue(true); + const { result } = render(); + expect(result.current.canEditTopic).toBe(true); + }); + + it("should not allow editing when user lacks permission", () => { + mockRoom.currentState.maySendStateEvent = jest.fn().mockReturnValue(false); + const { result } = render(); + expect(result.current.canEditTopic).toBe(false); + }); + }); +});