feat: Archive all notifications (#6599)

* feat: Archive all notifications

* use non-modal notification menu

* don't show icons in context menu
This commit is contained in:
Hemachandar
2024-02-28 07:34:33 +05:30
committed by GitHub
parent 60e52d0423
commit 0f7bae13e2
8 changed files with 191 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import { MarkAsReadIcon } from "outline-icons";
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "..";
import { NotificationSection } from "../sections";
@@ -13,4 +13,17 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const rootNotificationActions = [markNotificationsAsRead];
export const markNotificationsAsArchived = createAction({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
icon: <ArchiveIcon />,
iconInContextMenu: false,
perform: ({ stores }) => stores.notifications.markAllAsArchived(),
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
});
export const rootNotificationActions = [
markNotificationsAsRead,
markNotificationsAsArchived,
];

View File

@@ -38,6 +38,8 @@ export type Placement =
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
@@ -52,6 +54,7 @@ type Props = MenuStateReturn & {
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
@@ -105,7 +108,12 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,14 +1,14 @@
import { observer } from "mobx-react";
import { MarkAsReadIcon, SettingsIcon } from "outline-icons";
import { MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import Notification from "~/models/Notification";
import { navigateToNotificationSettings } from "~/actions/definitions/navigation";
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import NotificationMenu from "~/menus/NotificationMenu";
import { hover } from "~/styles";
import Desktop from "~/utils/Desktop";
import Empty from "../Empty";
@@ -56,7 +56,7 @@ function Notifications(
<Text weight="bold" as="span">
{t("Notifications")}
</Text>
<Text color="textSecondary" as={Flex} gap={8}>
<Flex gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip delay={500} content={t("Mark all as read")}>
<Button action={markNotificationsAsRead} context={context}>
@@ -64,17 +64,14 @@ function Notifications(
</Button>
</Tooltip>
)}
<Tooltip delay={500} content={t("Settings")}>
<Button action={navigateToNotificationSettings} context={context}>
<SettingsIcon />
</Button>
</Tooltip>
</Text>
<NotificationMenu />
</Flex>
</Header>
<React.Suspense fallback={null}>
<Scrollable ref={ref} flex topShadow>
<PaginatedList
fetch={notifications.fetchPage}
options={{ archived: false }}
items={notifications.orderedData}
renderItem={(item: Notification) => (
<NotificationListItem
@@ -113,7 +110,7 @@ const Button = styled(NudeButton)`
const Header = styled(Flex)`
padding: 8px 12px 12px;
height: 44px;
min-height: 44px;
${Button} {
opacity: 0.75;

View File

@@ -0,0 +1,73 @@
import { t } from "i18next";
import { MoreIcon } from "outline-icons";
import React from "react";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import ContextMenu from "~/components/ContextMenu";
import Template from "~/components/ContextMenu/Template";
import NudeButton from "~/components/NudeButton";
import { actionToMenuItem, performAction } from "~/actions";
import { navigateToNotificationSettings } from "~/actions/definitions/navigation";
import { markNotificationsAsArchived } from "~/actions/definitions/notifications";
import useActionContext from "~/hooks/useActionContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { hover } from "~/styles";
import { MenuItem } from "~/types";
const NotificationMenu: React.FC = () => {
const menuRef = React.useRef<HTMLDivElement>(null);
const menu = useMenuState();
const context = useActionContext();
const items: MenuItem[] = React.useMemo(
() => [
actionToMenuItem(markNotificationsAsArchived, context),
{
type: "button",
title: "Notification settings",
visible: true,
onClick: () => performAction(navigateToNotificationSettings, context),
},
],
[context]
);
useOnClickOutside(
menuRef,
(event) => {
if (menu.visible) {
event.stopPropagation();
event.preventDefault();
menu.hide();
}
},
{ capture: true }
);
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button {...props}>
<MoreIcon />
</Button>
)}
</MenuButton>
<ContextMenu {...menu} menuRef={menuRef} aria-label={t("Notification")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
};
const Button = styled(NudeButton)`
color: ${s("textSecondary")};
&:${hover},
&:active {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
export default NotificationMenu;

View File

@@ -54,6 +54,20 @@ export default class NotificationsStore extends Store<Notification> {
});
};
/**
* Mark all notifications as archived.
*/
@action
markAllAsArchived = async () => {
await client.post("/notifications.update_all", {
archivedAt: new Date().toISOString(),
});
runInAction("NotificationsStore#markAllAsArchived", () => {
this.clear();
});
};
/**
* Returns the approximate number of unread notifications.
*/

View File

@@ -34,6 +34,7 @@ describe("#notifications.list", () => {
event: NotificationEventType.UpdateDocument,
userId: user.id,
viewedAt: new Date(),
archivedAt: new Date(),
}),
buildNotification({
actorId: actor.id,
@@ -196,6 +197,68 @@ describe("#notifications.list", () => {
expect(events).toContain(NotificationEventType.CreateComment);
expect(events).toContain(NotificationEventType.UpdateDocument);
});
it("should return non-archived notifications", async () => {
const actor = await buildUser();
const user = await buildUser({
teamId: actor.teamId,
});
const collection = await buildCollection({
teamId: actor.teamId,
createdById: actor.id,
});
const document = await buildDocument({
teamId: actor.teamId,
createdById: actor.id,
collectionId: collection.id,
});
await Promise.all([
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.UpdateDocument,
archivedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.CreateComment,
archivedAt: new Date(),
userId: user.id,
}),
buildNotification({
actorId: actor.id,
documentId: document.id,
collectionId: collection.id,
event: NotificationEventType.MentionedInComment,
userId: user.id,
}),
]);
const res = await server.post("/api/notifications.list", {
body: {
token: user.getJwtToken(),
archived: false,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.notifications.length).toBe(1);
expect(body.pagination.total).toBe(1);
expect(body.data.unseen).toBe(1);
expect((randomElement(body.data.notifications) as any).actor.id).toBe(
actor.id
);
expect((randomElement(body.data.notifications) as any).userId).toBe(
user.id
);
const events = body.data.notifications.map((n: any) => n.event);
expect(events).toContain(NotificationEventType.MentionedInComment);
});
});
describe("#notifications.update", () => {

View File

@@ -1,4 +1,5 @@
import Router from "koa-router";
import { isNil } from "lodash";
import isNull from "lodash/isNull";
import isUndefined from "lodash/isUndefined";
import { WhereOptions, Op } from "sequelize";
@@ -80,12 +81,16 @@ router.post(
if (eventType) {
where = { ...where, event: eventType };
}
if (archived) {
if (!isNil(archived)) {
where = {
...where,
archivedAt: {
[Op.ne]: null,
},
archivedAt: archived
? {
[Op.ne]: null,
}
: {
[Op.eq]: null,
},
};
}
const [notifications, total, unseen] = await Promise.all([

View File

@@ -86,6 +86,7 @@
"Download {{ platform }} app": "Download {{ platform }} app",
"Log out": "Log out",
"Mark notifications as read": "Mark notifications as read",
"Archive all notifications": "Archive all notifications",
"Restore revision": "Restore revision",
"Link copied": "Link copied",
"Dark": "Dark",