Iterate design of right panel empty state (#12796)

* Add reusable empty state for the right panel

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-07-19 18:17:40 +01:00 committed by GitHub
parent d202295015
commit 0fc1c53a8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 266 additions and 280 deletions

View file

@ -28,6 +28,7 @@ import {
TimelineWindow,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Icon as FilesIcon } from "@vector-im/compound-design-tokens/icons/files.svg";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import EventIndexPeg from "../../indexing/EventIndexPeg";
@ -40,6 +41,7 @@ import Spinner from "../views/elements/Spinner";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import Measured from "../views/elements/Measured";
import EmptyState from "../views/right_panel/EmptyState";
interface IProps {
roomId: string;
@ -255,10 +257,11 @@ class FilePanel extends React.Component<IProps, IState> {
// wrap a TimelinePanel with the jump-to-event bits turned off.
const emptyState = (
<div className="mx_RightPanel_empty mx_FilePanel_empty">
<h2>{_t("file_panel|empty_heading")}</h2>
<p>{_t("file_panel|empty_description")}</p>
</div>
<EmptyState
Icon={FilesIcon}
title={_t("file_panel|empty_heading")}
description={_t("file_panel|empty_description")}
/>
);
const isRoomEncrypted = this.noRoom ? false : MatrixClientPeg.safeGet().isRoomEncrypted(this.props.roomId);

View file

@ -16,6 +16,7 @@ limitations under the License.
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Icon as NotificationsIcon } from "@vector-im/compound-design-tokens/icons/notifications.svg";
import { _t } from "../../languageHandler";
import { MatrixClientPeg } from "../../MatrixClientPeg";
@ -26,6 +27,7 @@ import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import Measured from "../views/elements/Measured";
import Heading from "../views/typography/Heading";
import EmptyState from "../views/right_panel/EmptyState";
interface IProps {
onClose(): void;
@ -57,10 +59,11 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
public render(): React.ReactNode {
const emptyState = (
<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t("notif_panel|empty_heading")}</h2>
<p>{_t("notif_panel|empty_description")}</p>
</div>
<EmptyState
Icon={NotificationsIcon}
title={_t("notif_panel|empty_heading")}
description={_t("notif_panel|empty_description")}
/>
);
let content: JSX.Element;

View file

@ -19,6 +19,7 @@ import React, { useContext, useEffect, useRef, useState } from "react";
import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix";
import { IconButton, Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { Icon as ThreadsIcon } from "@vector-im/compound-design-tokens/icons/threads.svg";
import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg";
import BaseCard from "../views/right_panel/BaseCard";
@ -37,6 +38,7 @@ import { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import Heading from "../views/typography/Heading";
import { clearRoomNotification } from "../../utils/notifications";
import EmptyState from "../views/right_panel/EmptyState";
interface IProps {
roomId: string;
@ -73,8 +75,7 @@ export const ThreadPanelHeaderFilterOptionItem: React.FC<
export const ThreadPanelHeader: React.FC<{
filterOption: ThreadFilterType;
setFilterOption: (filterOption: ThreadFilterType) => void;
empty: boolean;
}> = ({ filterOption, setFilterOption, empty }) => {
}> = ({ filterOption, setFilterOption }) => {
const mxClient = useMatrixClientContext();
const roomContext = useRoomContext();
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
@ -140,86 +141,24 @@ export const ThreadPanelHeader: React.FC<{
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("common|threads")}
</Heading>
{!empty && (
<>
<Tooltip label={_t("threads|mark_all_read")}>
<IconButton
onClick={onMarkAllThreadsReadClick}
aria-label={_t("threads|mark_all_read")}
size="24px"
>
<MarkAllThreadsReadIcon />
</IconButton>
</Tooltip>
<div className="mx_ThreadPanel_vertical_separator" />
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
ref={button}
isExpanded={menuDisplayed}
onClick={(ev: ButtonEvent) => {
openMenu();
PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev);
}}
>
{`${_t("threads|show_thread_filter")} ${value?.label}`}
</ContextMenuButton>
{contextMenu}
</>
)}
</div>
);
};
interface EmptyThreadIProps {
hasThreads: boolean;
filterOption: ThreadFilterType;
showAllThreadsCallback: () => void;
}
const EmptyThread: React.FC<EmptyThreadIProps> = ({ hasThreads, filterOption, showAllThreadsCallback }) => {
let body: JSX.Element;
if (hasThreads) {
body = (
<>
<p>
{_t("threads|empty_has_threads_tip", {
replyInThread: _t("action|reply_in_thread"),
})}
</p>
<p>
{/* Always display that paragraph to prevent layout shift when hiding the button */}
{filterOption === ThreadFilterType.My ? (
<button onClick={showAllThreadsCallback}>{_t("threads|show_all_threads")}</button>
) : (
<>&nbsp;</>
)}
</p>
</>
);
} else {
body = (
<>
<p>{_t("threads|empty_explainer")}</p>
<p className="mx_ThreadPanel_empty_tip">
{_t(
"threads|empty_tip",
{
replyInThread: _t("action|reply_in_thread"),
},
{
b: (sub) => <b>{sub}</b>,
},
)}
</p>
</>
);
}
return (
<div className="mx_ThreadPanel_empty">
<div className="mx_ThreadPanel_largeIcon" />
<h2>{_t("threads|empty_heading")}</h2>
{body}
<Tooltip label={_t("threads|mark_all_read")}>
<IconButton onClick={onMarkAllThreadsReadClick} aria-label={_t("threads|mark_all_read")} size="24px">
<MarkAllThreadsReadIcon />
</IconButton>
</Tooltip>
<div className="mx_ThreadPanel_vertical_separator" />
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
ref={button}
isExpanded={menuDisplayed}
onClick={(ev: ButtonEvent) => {
openMenu();
PosthogTrackers.trackInteraction("WebRightPanelThreadPanelFilterDropdown", ev);
}}
>
{`${_t("threads|show_thread_filter")} ${value?.label}`}
</ContextMenuButton>
{contextMenu}
</div>
);
};
@ -268,11 +207,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
<BaseCard
hideHeaderButtons
header={
<ThreadPanelHeader
filterOption={filterOption}
setFilterOption={setFilterOption}
empty={!hasThreads}
/>
hasThreads && <ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />
}
id="thread-panel"
className="mx_ThreadPanel"
@ -295,10 +230,12 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
timelineSet={timelineSet}
showUrlPreview={false} // No URL previews at the threads list level
empty={
<EmptyThread
hasThreads={hasThreads}
filterOption={filterOption}
showAllThreadsCallback={() => setFilterOption(ThreadFilterType.All)}
<EmptyState
Icon={ThreadsIcon}
title={_t("threads|empty_title")}
description={_t("threads|empty_description", {
replyInThread: _t("action|reply_in_thread"),
})}
/>
}
alwaysShowTimestamps={true}

View file

@ -0,0 +1,42 @@
/*
Copyright 2024 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, { ComponentType } from "react";
import { Text } from "@vector-im/compound-web";
import { Flex } from "../../utils/Flex";
interface Props {
Icon: ComponentType<React.SVGAttributes<SVGElement>>;
title: string;
description: string;
}
const EmptyState: React.FC<Props> = ({ Icon, title, description }) => {
return (
<Flex className="mx_EmptyState" direction="column" gap="var(--cpd-space-4x)" align="center" justify="center">
<Icon width="32px" height="32px" />
<Text size="lg" weight="semibold">
{title}
</Text>
<Text size="md" weight="regular">
{description}
</Text>
</Flex>
);
};
export default EmptyState;

View file

@ -3193,16 +3193,13 @@
"one": "%(count)s reply",
"other": "%(count)s replies"
},
"empty_explainer": "Threads help keep your conversations on-topic and easy to track.",
"empty_has_threads_tip": "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.",
"empty_heading": "Keep discussions organised with threads",
"empty_tip": "<b>Tip:</b> Use “%(replyInThread)s” when hovering over a message.",
"empty_description": "Use “%(replyInThread)s” when hovering over a message.",
"empty_title": "Threads help keep your conversations on-topic and easy to track.",
"error_start_thread_existing_relation": "Can't create a thread from an event with an existing relation",
"mark_all_read": "Mark all as read",
"my_threads": "My threads",
"my_threads_description": "Shows all threads you've participated in",
"open_thread": "Open thread",
"show_all_threads": "Show all threads",
"show_thread_filter": "Show:"
},
"threads_activity_centre": {