Add dialog to navigate long room topics (#8517)
This commit is contained in:
parent
e0415d0123
commit
fdd5494402
13 changed files with 347 additions and 41 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -267,13 +267,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
|
|||
{ settingsButton }
|
||||
</div>
|
||||
</div>
|
||||
<RoomTopic room={space}>
|
||||
{ (topic, ref) => (
|
||||
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
|
||||
{ topic }
|
||||
</div>
|
||||
) }
|
||||
</RoomTopic>
|
||||
<RoomTopic room={space} className="mx_SpaceRoomView_landing_topic" />
|
||||
|
||||
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
|
||||
</div>;
|
||||
|
|
39
src/components/views/elements/Linkify.tsx
Normal file
39
src/components/views/elements/Linkify.tsx
Normal file
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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<HTMLDivElement> {
|
||||
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<HTMLDivElement>();
|
||||
const hovered = useHover(ref);
|
||||
|
||||
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));
|
||||
const topic = useTopic(room);
|
||||
|
||||
const onClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
props.onClick?.(e);
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName.toUpperCase() === "A") {
|
||||
return;
|
||||
}
|
||||
|
||||
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: <div>
|
||||
<Linkify as="p">{ topic }</Linkify>
|
||||
{ canSetTopic && <AccessibleButton
|
||||
kind="primary_outline"
|
||||
onClick={() => {
|
||||
modal.close();
|
||||
dis.dispatch({ action: "open_room_settings" });
|
||||
}}>
|
||||
{ _t("Edit topic") }
|
||||
</AccessibleButton> }
|
||||
</div>,
|
||||
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 <span ref={ref}>{ topic }</span>;
|
||||
};
|
||||
const className = classNames(props.className, "mx_RoomTopic");
|
||||
|
||||
export default RoomTopic;
|
||||
return <div {...props}
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
dir="auto"
|
||||
className={className}
|
||||
>
|
||||
{ topic }
|
||||
{ hovered && (
|
||||
<Tooltip label={_t("Click to read topic")} alignment={Alignment.Bottom} />
|
||||
) }
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -186,11 +186,10 @@ export default class RoomHeader extends React.Component<IProps, IState> {
|
|||
</ContextMenuTooltipButton>
|
||||
);
|
||||
|
||||
const topicElement = <RoomTopic room={this.props.room}>
|
||||
{ (topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto">
|
||||
{ topic }
|
||||
</div> }
|
||||
</RoomTopic>;
|
||||
const topicElement = <RoomTopic
|
||||
room={this.props.room}
|
||||
className="mx_RoomHeader_topic"
|
||||
/>;
|
||||
|
||||
let roomAvatar;
|
||||
if (this.props.room) {
|
||||
|
|
|
@ -182,13 +182,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
|||
<RoomName room={room} />
|
||||
</h1>
|
||||
<RoomInfoLine room={room} />
|
||||
<RoomTopic room={room}>
|
||||
{ (topic, ref) =>
|
||||
topic ? <div className="mx_RoomPreviewCard_topic" ref={ref}>
|
||||
{ topic }
|
||||
</div> : null
|
||||
}
|
||||
</RoomTopic>
|
||||
<RoomTopic room={room} className="mx_RoomPreviewCard_topic" />
|
||||
{ room.getJoinRule() === "public" && <RoomFacePile room={room} /> }
|
||||
{ notice ? <div className="mx_RoomPreviewCard_notice">
|
||||
{ notice }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -313,4 +313,9 @@ export enum Action {
|
|||
* logs. Fires with no payload.
|
||||
*/
|
||||
DumpDebugLogs = "dump_debug_logs",
|
||||
|
||||
/**
|
||||
* Show current room topic
|
||||
*/
|
||||
ShowRoomTopic = "show_room_topic"
|
||||
}
|
||||
|
|
40
src/hooks/room/useTopic.ts
Normal file
40
src/hooks/room/useTopic.ts
Normal file
|
@ -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;
|
||||
}
|
42
src/hooks/useHover.ts
Normal file
42
src/hooks/useHover.ts
Normal file
|
@ -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<HTMLElement>) {
|
||||
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;
|
||||
}
|
|
@ -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 <a>your settings</a> for more information": "Message search initialisation failed, check <a>your settings</a> for more information",
|
||||
"Use the <a>Desktop app</a> to see all encrypted files": "Use the <a>Desktop app</a> to see all encrypted files",
|
||||
"Use the <a>Desktop app</a> to search encrypted messages": "Use the <a>Desktop app</a> to search encrypted messages",
|
||||
|
|
64
test/components/views/elements/Linkify-test.tsx
Normal file
64
test/components/views/elements/Linkify-test.tsx
Normal file
|
@ -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(<Linkify>
|
||||
https://perdu.com
|
||||
</Linkify>);
|
||||
expect(wrapper.html()).toBe('<div><a href="https://perdu.com">https://perdu.com</a></div>');
|
||||
});
|
||||
|
||||
it("changes the root tag name", () => {
|
||||
const TAG_NAME = "p";
|
||||
|
||||
const wrapper = mount(<Linkify as={TAG_NAME}>
|
||||
Hello world!
|
||||
</Linkify>);
|
||||
|
||||
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 <div onClick={onClick}>
|
||||
<Linkify>
|
||||
{ n % 2 === 0
|
||||
? "https://perdu.com"
|
||||
: "https://matrix.org" }
|
||||
</Linkify>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const wrapper = mount(<DummyTest />);
|
||||
|
||||
expect(wrapper.html()).toBe('<div><div><a href="https://perdu.com">https://perdu.com</a></div></div>');
|
||||
|
||||
wrapper.find('div').at(0).simulate('click');
|
||||
|
||||
expect(wrapper.html()).toBe('<div><div><a href="https://matrix.org">https://matrix.org</a></div></div>');
|
||||
});
|
||||
});
|
69
test/useTopic-test.tsx
Normal file
69
test/useTopic-test.tsx
Normal file
|
@ -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 <p>{ topic }</p>;
|
||||
}
|
||||
|
||||
const wrapper = mount(<RoomTopic />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue