Add dialog to navigate long room topics (#8517)

This commit is contained in:
Germain 2022-05-13 08:55:10 +01:00 committed by GitHub
parent e0415d0123
commit fdd5494402
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 347 additions and 41 deletions

View file

@ -140,6 +140,10 @@ limitations under the License.
cursor: pointer; cursor: pointer;
} }
.mx_RoomTopic {
position: relative;
}
.mx_RoomHeader_topic { .mx_RoomHeader_topic {
$lineHeight: $font-16px; $lineHeight: $font-16px;
$lines: 2; $lines: 2;
@ -209,6 +213,7 @@ limitations under the License.
.mx_RoomHeader_appsButton::before { .mx_RoomHeader_appsButton::before {
mask-image: url('$(res)/img/element-icons/room/apps.svg'); mask-image: url('$(res)/img/element-icons/room/apps.svg');
} }
.mx_RoomHeader_appsButton_highlight::before { .mx_RoomHeader_appsButton_highlight::before {
background-color: $accent; background-color: $accent;
} }
@ -239,6 +244,7 @@ limitations under the License.
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.mx_RoomHeader { .mx_RoomHeader {
overflow: hidden; overflow: hidden;
} }

View file

@ -267,13 +267,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
{ settingsButton } { settingsButton }
</div> </div>
</div> </div>
<RoomTopic room={space}> <RoomTopic room={space} className="mx_SpaceRoomView_landing_topic" />
{ (topic, ref) => (
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
{ topic }
</div>
) }
</RoomTopic>
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} /> <SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>; </div>;

View 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,
});
}

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import React, { useEffect, useState } from "react"; import React, { useCallback, useContext, useEffect, useRef } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import classNames from "classnames";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { linkifyElement } from "../../../HtmlUtils"; 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; 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 = useTopic(room);
const [topic, setTopic] = useState(getTopic(room));
useTypedEventEmitter(room.currentState, RoomStateEvent.Events, (ev: MatrixEvent) => { const onClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (ev.getType() !== EventType.RoomTopic) return; props.onClick?.(e);
setTopic(getTopic(room)); 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(() => { useEffect(() => {
setTopic(getTopic(room)); linkifyElement(ref.current);
}, [room]); }, [topic]);
const ref = e => e && linkifyElement(e); const className = classNames(props.className, "mx_RoomTopic");
if (children) return children(topic, ref);
return <span ref={ref}>{ topic }</span>;
};
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>;
}

View file

@ -186,11 +186,10 @@ export default class RoomHeader extends React.Component<IProps, IState> {
</ContextMenuTooltipButton> </ContextMenuTooltipButton>
); );
const topicElement = <RoomTopic room={this.props.room}> const topicElement = <RoomTopic
{ (topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto"> room={this.props.room}
{ topic } className="mx_RoomHeader_topic"
</div> } />;
</RoomTopic>;
let roomAvatar; let roomAvatar;
if (this.props.room) { if (this.props.room) {

View file

@ -182,13 +182,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
<RoomName room={room} /> <RoomName room={room} />
</h1> </h1>
<RoomInfoLine room={room} /> <RoomInfoLine room={room} />
<RoomTopic room={room}> <RoomTopic room={room} className="mx_RoomPreviewCard_topic" />
{ (topic, ref) =>
topic ? <div className="mx_RoomPreviewCard_topic" ref={ref}>
{ topic }
</div> : null
}
</RoomTopic>
{ room.getJoinRule() === "public" && <RoomFacePile room={room} /> } { room.getJoinRule() === "public" && <RoomFacePile room={room} /> }
{ notice ? <div className="mx_RoomPreviewCard_notice"> { notice ? <div className="mx_RoomPreviewCard_notice">
{ notice } { notice }

View file

@ -25,8 +25,8 @@ import AccessibleButton from "../elements/AccessibleButton";
import SpaceBasicSettings from "./SpaceBasicSettings"; import SpaceBasicSettings from "./SpaceBasicSettings";
import { avatarUrlForRoom } from "../../../Avatar"; import { avatarUrlForRoom } from "../../../Avatar";
import { IDialogProps } from "../dialogs/IDialogProps"; import { IDialogProps } from "../dialogs/IDialogProps";
import { getTopic } from "../elements/RoomTopic";
import { leaveSpace } from "../../../utils/leave-behaviour"; import { leaveSpace } from "../../../utils/leave-behaviour";
import { getTopic } from "../../../hooks/room/useTopic";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;

View file

@ -313,4 +313,9 @@ export enum Action {
* logs. Fires with no payload. * logs. Fires with no payload.
*/ */
DumpDebugLogs = "dump_debug_logs", DumpDebugLogs = "dump_debug_logs",
/**
* Show current room topic
*/
ShowRoomTopic = "show_room_topic"
} }

View 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
View 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;
}

View file

@ -2370,6 +2370,8 @@
"Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", "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|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", "%(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", "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 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", "Use the <a>Desktop app</a> to search encrypted messages": "Use the <a>Desktop app</a> to search encrypted messages",

View 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
View 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");
});
});