Merge remote-tracking branch 'github.com/develop' into develop
This commit is contained in:
@@ -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<ContentHelpers.TopicState>;
|
||||||
|
/**
|
||||||
|
* 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<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.
|
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 classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -66,10 +66,8 @@ import { canInviteTo } from "../../../utils/room/canInviteTo";
|
|||||||
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
|
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
|
||||||
import { useAccountData } from "../../../hooks/useAccountData";
|
import { useAccountData } from "../../../hooks/useAccountData";
|
||||||
import { useRoomState } from "../../../hooks/useRoomState";
|
import { useRoomState } from "../../../hooks/useRoomState";
|
||||||
import { useTopic } from "../../../hooks/room/useTopic";
|
|
||||||
import { Linkify, topicToHtml } from "../../../HtmlUtils";
|
import { Linkify, topicToHtml } from "../../../HtmlUtils";
|
||||||
import { Box } from "../../utils/Box";
|
import { Box } from "../../utils/Box";
|
||||||
import { onRoomTopicLinkClick } from "../elements/RoomTopic";
|
|
||||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
@@ -79,6 +77,7 @@ import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
|
|||||||
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
|
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
|
||||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||||
import { ReportRoomDialog } from "../dialogs/ReportRoomDialog.tsx";
|
import { ReportRoomDialog } from "../dialogs/ReportRoomDialog.tsx";
|
||||||
|
import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -115,21 +114,11 @@ const onRoomSettingsClick = (ev: Event): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null => {
|
const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null => {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const vm = useRoomTopicViewModel(room);
|
||||||
|
|
||||||
const topic = useTopic(room);
|
const body = topicToHtml(vm.topic?.text, vm.topic?.html);
|
||||||
const body = topicToHtml(topic?.text, topic?.html);
|
|
||||||
|
|
||||||
const canEditTopic = useRoomState(room, (state) =>
|
if (!body && !vm.canEditTopic) {
|
||||||
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) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +132,7 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
|
|||||||
className="mx_RoomSummaryCard_topic"
|
className="mx_RoomSummaryCard_topic"
|
||||||
>
|
>
|
||||||
<Box flex="1">
|
<Box flex="1">
|
||||||
<Link kind="primary" onClick={onEditClick}>
|
<Link kind="primary" onClick={vm.onEditClick}>
|
||||||
<Text size="sm" weight="regular">
|
<Text size="sm" weight="regular">
|
||||||
{_t("right_panel|add_topic")}
|
{_t("right_panel|add_topic")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -153,7 +142,7 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = expanded ? <Linkify>{body}</Linkify> : body;
|
const content = vm.expanded ? <Linkify>{body}</Linkify> : body;
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
as="section"
|
as="section"
|
||||||
@@ -161,33 +150,20 @@ const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null
|
|||||||
justify="center"
|
justify="center"
|
||||||
gap="var(--cpd-space-2x)"
|
gap="var(--cpd-space-2x)"
|
||||||
className={classNames("mx_RoomSummaryCard_topic", {
|
className={classNames("mx_RoomSummaryCard_topic", {
|
||||||
mx_RoomSummaryCard_topic_collapsed: !expanded,
|
mx_RoomSummaryCard_topic_collapsed: !vm.expanded,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Box flex="1" className="mx_RoomSummaryCard_topic_container">
|
<Box flex="1" className="mx_RoomSummaryCard_topic_container">
|
||||||
<Text
|
<Text size="sm" weight="regular" onClick={vm.onTopicLinkClick}>
|
||||||
size="sm"
|
|
||||||
weight="regular"
|
|
||||||
onClick={(ev: React.MouseEvent): void => {
|
|
||||||
if (ev.target instanceof HTMLAnchorElement) {
|
|
||||||
onRoomTopicLinkClick(ev);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
<IconButton
|
<IconButton className="mx_RoomSummaryCard_topic_chevron" size="24px" onClick={vm.onExpandedClick}>
|
||||||
className="mx_RoomSummaryCard_topic_chevron"
|
|
||||||
size="24px"
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
{expanded && canEditTopic && (
|
{vm.expanded && vm.canEditTopic && (
|
||||||
<Box flex="1" className="mx_RoomSummaryCard_topic_edit">
|
<Box flex="1" className="mx_RoomSummaryCard_topic_edit">
|
||||||
<Link kind="primary" onClick={onEditClick}>
|
<Link kind="primary" onClick={vm.onEditClick}>
|
||||||
<Text size="sm" weight="regular">
|
<Text size="sm" weight="regular">
|
||||||
{_t("action|edit")}
|
{_t("action|edit")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -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<HTMLElement>;
|
||||||
|
|
||||||
|
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<HTMLElement>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user