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 * as React from "react";
|
||||||
import { createAction } from "..";
|
import { createAction } from "..";
|
||||||
import { NotificationSection } from "../sections";
|
import { NotificationSection } from "../sections";
|
||||||
@@ -13,4 +13,17 @@ export const markNotificationsAsRead = createAction({
|
|||||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
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 & {
|
type Props = MenuStateReturn & {
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
|
/** Reference to the rendered menu div element */
|
||||||
|
menuRef?: React.RefObject<HTMLDivElement>;
|
||||||
/** The parent menu state if this is a submenu. */
|
/** The parent menu state if this is a submenu. */
|
||||||
parentMenuState?: Omit<MenuStateReturn, "items">;
|
parentMenuState?: Omit<MenuStateReturn, "items">;
|
||||||
/** Called when the context menu is opened. */
|
/** Called when the context menu is opened. */
|
||||||
@@ -52,6 +54,7 @@ type Props = MenuStateReturn & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu: React.FC<Props> = ({
|
const ContextMenu: React.FC<Props> = ({
|
||||||
|
menuRef,
|
||||||
children,
|
children,
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -105,7 +108,12 @@ const ContextMenu: React.FC<Props> = ({
|
|||||||
// trigger and the bottom of the window
|
// trigger and the bottom of the window
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}>
|
<Menu
|
||||||
|
ref={menuRef}
|
||||||
|
hideOnClickOutside={!isMobile}
|
||||||
|
preventBodyScroll={false}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<InnerContextMenu
|
<InnerContextMenu
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { MarkAsReadIcon, SettingsIcon } from "outline-icons";
|
import { MarkAsReadIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import Notification from "~/models/Notification";
|
import Notification from "~/models/Notification";
|
||||||
import { navigateToNotificationSettings } from "~/actions/definitions/navigation";
|
|
||||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import NotificationMenu from "~/menus/NotificationMenu";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import Desktop from "~/utils/Desktop";
|
import Desktop from "~/utils/Desktop";
|
||||||
import Empty from "../Empty";
|
import Empty from "../Empty";
|
||||||
@@ -56,7 +56,7 @@ function Notifications(
|
|||||||
<Text weight="bold" as="span">
|
<Text weight="bold" as="span">
|
||||||
{t("Notifications")}
|
{t("Notifications")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="textSecondary" as={Flex} gap={8}>
|
<Flex gap={8}>
|
||||||
{notifications.approximateUnreadCount > 0 && (
|
{notifications.approximateUnreadCount > 0 && (
|
||||||
<Tooltip delay={500} content={t("Mark all as read")}>
|
<Tooltip delay={500} content={t("Mark all as read")}>
|
||||||
<Button action={markNotificationsAsRead} context={context}>
|
<Button action={markNotificationsAsRead} context={context}>
|
||||||
@@ -64,17 +64,14 @@ function Notifications(
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip delay={500} content={t("Settings")}>
|
<NotificationMenu />
|
||||||
<Button action={navigateToNotificationSettings} context={context}>
|
</Flex>
|
||||||
<SettingsIcon />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Text>
|
|
||||||
</Header>
|
</Header>
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<Scrollable ref={ref} flex topShadow>
|
<Scrollable ref={ref} flex topShadow>
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
fetch={notifications.fetchPage}
|
fetch={notifications.fetchPage}
|
||||||
|
options={{ archived: false }}
|
||||||
items={notifications.orderedData}
|
items={notifications.orderedData}
|
||||||
renderItem={(item: Notification) => (
|
renderItem={(item: Notification) => (
|
||||||
<NotificationListItem
|
<NotificationListItem
|
||||||
@@ -113,7 +110,7 @@ const Button = styled(NudeButton)`
|
|||||||
|
|
||||||
const Header = styled(Flex)`
|
const Header = styled(Flex)`
|
||||||
padding: 8px 12px 12px;
|
padding: 8px 12px 12px;
|
||||||
height: 44px;
|
min-height: 44px;
|
||||||
|
|
||||||
${Button} {
|
${Button} {
|
||||||
opacity: 0.75;
|
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.
|
* Returns the approximate number of unread notifications.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe("#notifications.list", () => {
|
|||||||
event: NotificationEventType.UpdateDocument,
|
event: NotificationEventType.UpdateDocument,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
viewedAt: new Date(),
|
viewedAt: new Date(),
|
||||||
|
archivedAt: new Date(),
|
||||||
}),
|
}),
|
||||||
buildNotification({
|
buildNotification({
|
||||||
actorId: actor.id,
|
actorId: actor.id,
|
||||||
@@ -196,6 +197,68 @@ describe("#notifications.list", () => {
|
|||||||
expect(events).toContain(NotificationEventType.CreateComment);
|
expect(events).toContain(NotificationEventType.CreateComment);
|
||||||
expect(events).toContain(NotificationEventType.UpdateDocument);
|
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", () => {
|
describe("#notifications.update", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import { isNil } from "lodash";
|
||||||
import isNull from "lodash/isNull";
|
import isNull from "lodash/isNull";
|
||||||
import isUndefined from "lodash/isUndefined";
|
import isUndefined from "lodash/isUndefined";
|
||||||
import { WhereOptions, Op } from "sequelize";
|
import { WhereOptions, Op } from "sequelize";
|
||||||
@@ -80,12 +81,16 @@ router.post(
|
|||||||
if (eventType) {
|
if (eventType) {
|
||||||
where = { ...where, event: eventType };
|
where = { ...where, event: eventType };
|
||||||
}
|
}
|
||||||
if (archived) {
|
if (!isNil(archived)) {
|
||||||
where = {
|
where = {
|
||||||
...where,
|
...where,
|
||||||
archivedAt: {
|
archivedAt: archived
|
||||||
[Op.ne]: null,
|
? {
|
||||||
},
|
[Op.ne]: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
[Op.eq]: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const [notifications, total, unseen] = await Promise.all([
|
const [notifications, total, unseen] = await Promise.all([
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"Download {{ platform }} app": "Download {{ platform }} app",
|
"Download {{ platform }} app": "Download {{ platform }} app",
|
||||||
"Log out": "Log out",
|
"Log out": "Log out",
|
||||||
"Mark notifications as read": "Mark notifications as read",
|
"Mark notifications as read": "Mark notifications as read",
|
||||||
|
"Archive all notifications": "Archive all notifications",
|
||||||
"Restore revision": "Restore revision",
|
"Restore revision": "Restore revision",
|
||||||
"Link copied": "Link copied",
|
"Link copied": "Link copied",
|
||||||
"Dark": "Dark",
|
"Dark": "Dark",
|
||||||
|
|||||||
Reference in New Issue
Block a user