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:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
73
app/menus/NotificationMenu.tsx
Normal file
73
app/menus/NotificationMenu.tsx
Normal 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;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user