diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 85c139402be..119bbc90b83 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -140,6 +140,10 @@ limitations under the License. cursor: pointer; } +.mx_RoomTopic { + position: relative; +} + .mx_RoomHeader_topic { $lineHeight: $font-16px; $lines: 2; @@ -209,6 +213,7 @@ limitations under the License. .mx_RoomHeader_appsButton::before { mask-image: url('$(res)/img/element-icons/room/apps.svg'); } + .mx_RoomHeader_appsButton_highlight::before { background-color: $accent; } @@ -239,6 +244,7 @@ limitations under the License. padding: 0; margin: 0; } + .mx_RoomHeader { overflow: hidden; } diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 379f15c38bb..12734d9a8c1 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -267,13 +267,7 @@ const SpaceLanding = ({ space }: { space: Room }) => { { settingsButton } - - { (topic, ref) => ( -
- { topic } -
- ) } -
+ ; diff --git a/src/components/views/elements/Linkify.tsx b/src/components/views/elements/Linkify.tsx new file mode 100644 index 00000000000..6263df1a121 --- /dev/null +++ b/src/components/views/elements/Linkify.tsx @@ -0,0 +1,39 @@ +/* +Copyright 2022 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, { useEffect, useRef } from "react"; +import linkifyElement from "linkify-element"; + +interface Props { + as?: string; + children: React.ReactNode; +} + +export function Linkify({ + as = "div", + children, +}: Props): JSX.Element { + const ref = useRef(); + + useEffect(() => { + linkifyElement(ref.current); + }, [children]); + + return React.createElement(as, { + children, + ref, + }); +} diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index 4120f6780e7..a50d3b33080 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 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. @@ -14,35 +14,87 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, useState } from "react"; -import { EventType } from "matrix-js-sdk/src/@types/event"; +import React, { useCallback, useContext, useEffect, useRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; -import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import classNames from "classnames"; +import { EventType } from "matrix-js-sdk/src/@types/event"; -import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { linkifyElement } from "../../../HtmlUtils"; +import { useTopic } from "../../../hooks/room/useTopic"; +import useHover from "../../../hooks/useHover"; +import Tooltip, { Alignment } from "./Tooltip"; +import { _t } from "../../../languageHandler"; +import dis from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import Modal from "../../../Modal"; +import InfoDialog from "../dialogs/InfoDialog"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import AccessibleButton from "./AccessibleButton"; +import { Linkify } from "./Linkify"; -interface IProps { +interface IProps extends React.HTMLProps { room?: Room; - children?(topic: string, ref: (element: HTMLElement) => void): JSX.Element; } -export const getTopic = room => room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic; +export default function RoomTopic({ + room, + ...props +}: IProps) { + const client = useContext(MatrixClientContext); + const ref = useRef(); + const hovered = useHover(ref); + + const topic = useTopic(room); + + const onClick = useCallback((e: React.MouseEvent) => { + props.onClick?.(e); + const target = e.target as HTMLElement; + if (target.tagName.toUpperCase() === "A") { + return; + } -const RoomTopic = ({ room, children }: IProps): JSX.Element => { - const [topic, setTopic] = useState(getTopic(room)); - useTypedEventEmitter(room.currentState, RoomStateEvent.Events, (ev: MatrixEvent) => { - if (ev.getType() !== EventType.RoomTopic) return; - setTopic(getTopic(room)); + dis.fire(Action.ShowRoomTopic); + }, [props]); + + useDispatcher(dis, (payload) => { + if (payload.action === Action.ShowRoomTopic) { + const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getUserId()); + + const modal = Modal.createDialog(InfoDialog, { + title: room.name, + description:
+ { topic } + { canSetTopic && { + modal.close(); + dis.dispatch({ action: "open_room_settings" }); + }}> + { _t("Edit topic") } + } +
, + hasCloseButton: true, + button: false, + }); + } }); + useEffect(() => { - setTopic(getTopic(room)); - }, [room]); + linkifyElement(ref.current); + }, [topic]); - const ref = e => e && linkifyElement(e); - if (children) return children(topic, ref); - return { topic }; -}; + const className = classNames(props.className, "mx_RoomTopic"); -export default RoomTopic; + return
+ { topic } + { hovered && ( + + ) } +
; +} diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 9983b6f39c3..ec206b0e369 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -186,11 +186,10 @@ export default class RoomHeader extends React.Component { ); - const topicElement = - { (topic, ref) =>
- { topic } -
} -
; + const topicElement = ; let roomAvatar; if (this.props.room) { diff --git a/src/components/views/rooms/RoomPreviewCard.tsx b/src/components/views/rooms/RoomPreviewCard.tsx index e197a3259c5..71b22765743 100644 --- a/src/components/views/rooms/RoomPreviewCard.tsx +++ b/src/components/views/rooms/RoomPreviewCard.tsx @@ -182,13 +182,7 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton - - { (topic, ref) => - topic ?
- { topic } -
: null - } -
+ { room.getJoinRule() === "public" && } { notice ?
{ notice } diff --git a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx index b572122da1b..8247ee25e93 100644 --- a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx +++ b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx @@ -25,8 +25,8 @@ import AccessibleButton from "../elements/AccessibleButton"; import SpaceBasicSettings from "./SpaceBasicSettings"; import { avatarUrlForRoom } from "../../../Avatar"; import { IDialogProps } from "../dialogs/IDialogProps"; -import { getTopic } from "../elements/RoomTopic"; import { leaveSpace } from "../../../utils/leave-behaviour"; +import { getTopic } from "../../../hooks/room/useTopic"; interface IProps extends IDialogProps { matrixClient: MatrixClient; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 0331f7de945..27f0b423c07 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -313,4 +313,9 @@ export enum Action { * logs. Fires with no payload. */ DumpDebugLogs = "dump_debug_logs", + + /** + * Show current room topic + */ + ShowRoomTopic = "show_room_topic" } diff --git a/src/hooks/room/useTopic.ts b/src/hooks/room/useTopic.ts new file mode 100644 index 00000000000..b01065c37ca --- /dev/null +++ b/src/hooks/room/useTopic.ts @@ -0,0 +1,40 @@ +/* +Copyright 2022 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 { useEffect, useState } from "react"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; + +import { useTypedEventEmitter } from "../useEventEmitter"; + +export const getTopic = (room: Room) => { + return room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic; +}; + +export function useTopic(room: Room): string { + const [topic, setTopic] = useState(getTopic(room)); + useTypedEventEmitter(room.currentState, RoomStateEvent.Events, (ev: MatrixEvent) => { + if (ev.getType() !== EventType.RoomTopic) return; + setTopic(getTopic(room)); + }); + useEffect(() => { + setTopic(getTopic(room)); + }, [room]); + + return topic; +} diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts new file mode 100644 index 00000000000..9f7c1012255 --- /dev/null +++ b/src/hooks/useHover.ts @@ -0,0 +1,42 @@ +/* +Copyright 2022 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, { useEffect, useState } from "react"; + +export default function useHover(ref: React.MutableRefObject) { + const [hovered, setHoverState] = useState(false); + + const handleMouseOver = () => setHoverState(true); + const handleMouseOut = () => setHoverState(false); + + useEffect( + () => { + const node = ref.current; + if (node) { + node.addEventListener("mouseover", handleMouseOver); + node.addEventListener("mouseout", handleMouseOut); + + return () => { + node.removeEventListener("mouseover", handleMouseOver); + node.removeEventListener("mouseout", handleMouseOut); + }; + } + }, + [ref], + ); + + return hovered; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0c7e3b77b94..15df64eeec8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2370,6 +2370,8 @@ "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", + "Edit topic": "Edit topic", + "Click to read topic": "Click to read topic", "Message search initialisation failed, check your settings for more information": "Message search initialisation failed, check your settings for more information", "Use the Desktop app to see all encrypted files": "Use the Desktop app to see all encrypted files", "Use the Desktop app to search encrypted messages": "Use the Desktop app to search encrypted messages", diff --git a/test/components/views/elements/Linkify-test.tsx b/test/components/views/elements/Linkify-test.tsx new file mode 100644 index 00000000000..7224c543736 --- /dev/null +++ b/test/components/views/elements/Linkify-test.tsx @@ -0,0 +1,64 @@ +/* +Copyright 2021 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, { useState } from "react"; +import { mount } from "enzyme"; + +import { Linkify } from "../../../../src/components/views/elements/Linkify"; + +describe("Linkify", () => { + it("linkifies the context", () => { + const wrapper = mount( + https://perdu.com + ); + expect(wrapper.html()).toBe(''); + }); + + it("changes the root tag name", () => { + const TAG_NAME = "p"; + + const wrapper = mount( + Hello world! + ); + + expect(wrapper.find("p")).toHaveLength(1); + }); + + it("relinkifies on update", () => { + function DummyTest() { + const [n, setN] = useState(0); + function onClick() { + setN(n + 1); + } + + // upon clicking the element, change the content, and expect + // linkify to update + return
+ + { n % 2 === 0 + ? "https://perdu.com" + : "https://matrix.org" } + +
; + } + + const wrapper = mount(); + + expect(wrapper.html()).toBe(''); + + wrapper.find('div').at(0).simulate('click'); + + expect(wrapper.html()).toBe(''); + }); +}); diff --git a/test/useTopic-test.tsx b/test/useTopic-test.tsx new file mode 100644 index 00000000000..75096b43e48 --- /dev/null +++ b/test/useTopic-test.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2022 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 from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; + +import { useTopic } from "../src/hooks/room/useTopic"; +import { mkEvent, stubClient } from "./test-utils"; +import { MatrixClientPeg } from "../src/MatrixClientPeg"; + +describe("useTopic", () => { + it("should display the room topic", () => { + stubClient(); + const room = new Room("!TESTROOM", MatrixClientPeg.get(), "@alice:example.org"); + const topic = mkEvent({ + type: 'm.room.topic', + room: '!TESTROOM', + user: '@alice:example.org', + content: { + topic: 'Test topic', + }, + ts: 123, + event: true, + }); + + room.addLiveEvents([topic]); + + function RoomTopic() { + const topic = useTopic(room); + return

{ topic }

; + } + + const wrapper = mount(); + + expect(wrapper.text()).toBe("Test topic"); + + const updatedTopic = mkEvent({ + type: 'm.room.topic', + room: '!TESTROOM', + user: '@alice:example.org', + content: { + topic: 'New topic', + }, + ts: 666, + event: true, + }); + + act(() => { + room.addLiveEvents([updatedTopic]); + }); + + expect(wrapper.text()).toBe("New topic"); + }); +});