From ea885133ac072b31ea31edfff89512f9d4d9525a Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 20 May 2023 10:47:32 -0400 Subject: [PATCH] Notifications interface (#5354) Co-authored-by: Apoorv Mishra --- app/actions/definitions/navigation.tsx | 19 +- app/actions/definitions/notifications.tsx | 16 + app/actions/root.ts | 2 + app/actions/sections.ts | 2 + app/components/ActionButton.tsx | 3 + app/components/Avatar/Avatar.tsx | 27 +- app/components/Button.tsx | 2 +- app/components/CommandBarItem.tsx | 35 +- app/components/Flex.tsx | 3 +- .../Notifications/NotificationListItem.tsx | 107 ++++ .../Notifications/Notifications.tsx | 113 ++++ .../Notifications/NotificationsButton.tsx | 42 ++ app/components/Popover.tsx | 49 +- app/components/Sidebar/Sidebar.tsx | 37 +- .../Sidebar/components/HeaderButton.tsx | 54 +- app/components/Text.ts | 2 +- app/components/WebsocketProvider.tsx | 16 + app/hooks/useFocusedComment.ts | 6 +- app/hooks/useSettingsConfig.ts | 26 +- app/models/Notification.ts | 127 ++++ app/scenes/GroupDelete.tsx | 4 +- app/stores/NotificationsStore.ts | 77 +++ app/stores/RootStore.ts | 3 + app/utils/routeHelpers.test.ts | 8 +- app/utils/routeHelpers.ts | 20 +- package.json | 2 +- plugins/slack/server/api/hooks.ts | 10 +- .../server/tasks/DeliverWebhookTask.ts | 1 + server/commands/notificationUpdater.test.ts | 187 ++++++ server/commands/notificationUpdater.ts | 56 ++ ...305-add-archivedat-col-to-notifications.js | 27 + ...0419132159-add-indexes-to-notifications.js | 36 ++ server/models/Notification.ts | 49 +- server/policies/index.ts | 3 + server/policies/notification.ts | 9 + server/presenters/notification.ts | 26 + .../queues/processors/WebsocketsProcessor.ts | 13 + server/routes/api/cron/cron.ts | 10 +- .../api/notifications/notifications.test.ts | 543 ++++++++++++++++++ .../routes/api/notifications/notifications.ts | 157 ++++- server/routes/api/notifications/schema.ts | 74 ++- server/routes/api/users/users.ts | 11 +- server/test/factories.ts | 24 + server/types.ts | 2 +- server/utils/crypto.ts | 19 + shared/editor/marks/Link.tsx | 1 + shared/i18n/locales/en_US/translation.json | 12 +- shared/types.ts | 1 + yarn.lock | 8 +- 49 files changed, 1918 insertions(+), 163 deletions(-) create mode 100644 app/actions/definitions/notifications.tsx create mode 100644 app/components/Notifications/NotificationListItem.tsx create mode 100644 app/components/Notifications/Notifications.tsx create mode 100644 app/components/Notifications/NotificationsButton.tsx create mode 100644 app/models/Notification.ts create mode 100644 app/stores/NotificationsStore.ts create mode 100644 server/commands/notificationUpdater.test.ts create mode 100644 server/commands/notificationUpdater.ts create mode 100644 server/migrations/20230419124305-add-archivedat-col-to-notifications.js create mode 100644 server/migrations/20230419132159-add-indexes-to-notifications.js create mode 100644 server/policies/notification.ts create mode 100644 server/presenters/notification.ts create mode 100644 server/routes/api/notifications/notifications.test.ts create mode 100644 server/utils/crypto.ts diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx index 4c7d85bdf..e6a57b6ec 100644 --- a/app/actions/definitions/navigation.tsx +++ b/app/actions/definitions/navigation.tsx @@ -30,15 +30,13 @@ import { isMac } from "~/utils/browser"; import history from "~/utils/history"; import isCloudHosted from "~/utils/isCloudHosted"; import { - organizationSettingsPath, - profileSettingsPath, - accountPreferencesPath, homePath, searchPath, draftsPath, templatesPath, archivePath, trashPath, + settingsPath, } from "~/utils/routeHelpers"; export const navigateToHome = createAction({ @@ -105,7 +103,7 @@ export const navigateToSettings = createAction({ icon: , visible: ({ stores }) => stores.policies.abilities(stores.auth.team?.id || "").update, - perform: () => history.push(organizationSettingsPath()), + perform: () => history.push(settingsPath("details")), }); export const navigateToProfileSettings = createAction({ @@ -114,7 +112,16 @@ export const navigateToProfileSettings = createAction({ section: NavigationSection, iconInContextMenu: false, icon: , - perform: () => history.push(profileSettingsPath()), + perform: () => history.push(settingsPath()), +}); + +export const navigateToNotificationSettings = createAction({ + name: ({ t }) => t("Notifications"), + analyticsName: "Navigate to notification settings", + section: NavigationSection, + iconInContextMenu: false, + icon: , + perform: () => history.push(settingsPath("notifications")), }); export const navigateToAccountPreferences = createAction({ @@ -123,7 +130,7 @@ export const navigateToAccountPreferences = createAction({ section: NavigationSection, iconInContextMenu: false, icon: , - perform: () => history.push(accountPreferencesPath()), + perform: () => history.push(settingsPath("preferences")), }); export const openAPIDocumentation = createAction({ diff --git a/app/actions/definitions/notifications.tsx b/app/actions/definitions/notifications.tsx new file mode 100644 index 000000000..ec77b7058 --- /dev/null +++ b/app/actions/definitions/notifications.tsx @@ -0,0 +1,16 @@ +import { MarkAsReadIcon } from "outline-icons"; +import * as React from "react"; +import { createAction } from ".."; +import { NotificationSection } from "../sections"; + +export const markNotificationsAsRead = createAction({ + name: ({ t }) => t("Mark notifications as read"), + analyticsName: "Mark notifications as read", + section: NotificationSection, + icon: , + shortcut: ["Shift+Escape"], + perform: ({ stores }) => stores.notifications.markAllAsRead(), + visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0, +}); + +export const rootNotificationActions = [markNotificationsAsRead]; diff --git a/app/actions/root.ts b/app/actions/root.ts index ec04d162a..7d64029a6 100644 --- a/app/actions/root.ts +++ b/app/actions/root.ts @@ -2,6 +2,7 @@ import { rootCollectionActions } from "./definitions/collections"; import { rootDeveloperActions } from "./definitions/developer"; import { rootDocumentActions } from "./definitions/documents"; import { rootNavigationActions } from "./definitions/navigation"; +import { rootNotificationActions } from "./definitions/notifications"; import { rootRevisionActions } from "./definitions/revisions"; import { rootSettingsActions } from "./definitions/settings"; import { rootTeamActions } from "./definitions/teams"; @@ -12,6 +13,7 @@ export default [ ...rootDocumentActions, ...rootUserActions, ...rootNavigationActions, + ...rootNotificationActions, ...rootRevisionActions, ...rootSettingsActions, ...rootDeveloperActions, diff --git a/app/actions/sections.ts b/app/actions/sections.ts index 331228096..1541637d2 100644 --- a/app/actions/sections.ts +++ b/app/actions/sections.ts @@ -12,6 +12,8 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings"); export const NavigationSection = ({ t }: ActionContext) => t("Navigation"); +export const NotificationSection = ({ t }: ActionContext) => t("Notification"); + export const UserSection = ({ t }: ActionContext) => t("People"); export const TeamSection = ({ t }: ActionContext) => t("Workspace"); diff --git a/app/components/ActionButton.tsx b/app/components/ActionButton.tsx index ae4b50bb9..f106c3f95 100644 --- a/app/components/ActionButton.tsx +++ b/app/components/ActionButton.tsx @@ -26,6 +26,9 @@ const ActionButton = React.forwardRef( const [executing, setExecuting] = React.useState(false); const disabled = rest.disabled; + if (action && !context) { + throw new Error("Context must be provided with action"); + } if (!context || !action) { return + + )} + + + + + + + ( + + )} + /> + + {isEmpty && ( + {t("No notifications yet")}. + )} + + ); +} + +const EmptyNotifications = styled(Empty)` + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +`; + +const Button = styled(NudeButton)` + color: ${s("textSecondary")}; + + &:hover, + &:active { + color: ${s("text")}; + background: ${s("sidebarControlHoverBackground")}; + } +`; + +const Header = styled(Flex)` + padding: 8px 12px 12px; + height: 44px; + + ${Button} { + opacity: 0.75; + transition: opacity 250ms ease-in-out; + } + + &:hover, + &:focus-within { + ${Button} { + opacity: 1; + } + } +`; + +export default observer(React.forwardRef(Notifications)); diff --git a/app/components/Notifications/NotificationsButton.tsx b/app/components/Notifications/NotificationsButton.tsx new file mode 100644 index 000000000..4ef21e6e1 --- /dev/null +++ b/app/components/Notifications/NotificationsButton.tsx @@ -0,0 +1,42 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; +import styled from "styled-components"; +import { depths } from "@shared/styles"; +import Popover from "~/components/Popover"; +import Notifications from "./Notifications"; + +const NotificationsButton: React.FC = ({ children }) => { + const { t } = useTranslation(); + const focusRef = React.useRef(null); + + const popover = usePopoverState({ + gutter: 0, + placement: "top-start", + unstable_fixed: true, + }); + + return ( + <> + {children} + + + + + ); +}; + +const StyledPopover = styled(Popover)` + z-index: ${depths.menu}; +`; + +export default observer(NotificationsButton); diff --git a/app/components/Popover.tsx b/app/components/Popover.tsx index 27734c3f5..8e68b520e 100644 --- a/app/components/Popover.tsx +++ b/app/components/Popover.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { Dialog } from "reakit/Dialog"; import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { depths, s } from "@shared/styles"; import useMobile from "~/hooks/useMobile"; @@ -11,13 +11,19 @@ type Props = PopoverProps & { children: React.ReactNode; width?: number; shrink?: boolean; + flex?: boolean; tabIndex?: number; + scrollable?: boolean; + mobilePosition?: "top" | "bottom"; }; const Popover: React.FC = ({ children, shrink, width = 380, + scrollable = true, + flex, + mobilePosition, ...rest }) => { const isMobile = useMobile(); @@ -25,38 +31,67 @@ const Popover: React.FC = ({ if (isMobile) { return ( - {children} + + {children} + ); } return ( - + {children} ); }; -const Contents = styled.div<{ $shrink?: boolean; $width?: number }>` +type ContentsProps = { + $shrink?: boolean; + $width?: number; + $flex?: boolean; + $scrollable: boolean; + $mobilePosition?: "top" | "bottom"; +}; + +const Contents = styled.div` + display: ${(props) => (props.$flex ? "flex" : "block")}; animation: ${fadeAndScaleIn} 200ms ease; transform-origin: 75% 0; background: ${s("menuBackground")}; border-radius: 6px; padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")}; max-height: 75vh; - overflow-x: hidden; - overflow-y: auto; box-shadow: ${s("menuShadow")}; width: ${(props) => props.$width}px; + ${(props) => + props.$scrollable && + css` + overflow-x: hidden; + overflow-y: auto; + `} + ${breakpoint("mobile", "tablet")` position: fixed; z-index: ${depths.menu}; // 50 is a magic number that positions us nicely under the top bar - top: 50px; + top: ${(props: ContentsProps) => + props.$mobilePosition === "bottom" ? "auto" : "50px"}; + bottom: ${(props: ContentsProps) => + props.$mobilePosition === "bottom" ? "0" : "auto"}; left: 8px; right: 8px; width: auto; diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx index 572ee85e5..30ee71d58 100644 --- a/app/components/Sidebar/Sidebar.tsx +++ b/app/components/Sidebar/Sidebar.tsx @@ -1,4 +1,5 @@ import { observer } from "mobx-react"; +import { SubscribeIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Portal } from "react-portal"; @@ -15,7 +16,9 @@ import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles"; import { fadeIn } from "~/styles/animations"; import Desktop from "~/utils/Desktop"; import Avatar from "../Avatar"; +import NotificationsButton from "../Notifications/NotificationsButton"; import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton"; +import Relative from "./components/Relative"; import ResizeBorder from "./components/ResizeBorder"; import Toggle, { ToggleButton, Positioner } from "./components/Toggle"; @@ -184,7 +187,16 @@ const Sidebar = React.forwardRef( showBorder={false} /> } - /> + > + + {(rest: HeaderButtonProps) => ( + } + /> + )} + + )} )} @@ -211,6 +223,29 @@ const Sidebar = React.forwardRef( } ); +const BadgedNotificationIcon = observer(() => { + const { notifications } = useStores(); + const theme = useTheme(); + const count = notifications.approximateUnreadCount; + + return ( + + + {count > 0 && } + + ); +}); + +const Badge = styled.div` + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + background: ${s("accent")}; + top: 0; + right: 0; +`; + const StyledAvatar = styled(Avatar)` margin-left: 4px; `; diff --git a/app/components/Sidebar/components/HeaderButton.tsx b/app/components/Sidebar/components/HeaderButton.tsx index fcbd056e7..999a491fb 100644 --- a/app/components/Sidebar/components/HeaderButton.tsx +++ b/app/components/Sidebar/components/HeaderButton.tsx @@ -5,7 +5,7 @@ import { s } from "@shared/styles"; import Flex from "~/components/Flex"; import { undraggableOnDesktop } from "~/styles"; -export type HeaderButtonProps = React.ComponentProps & { +export type HeaderButtonProps = React.ComponentProps & { title: React.ReactNode; image: React.ReactNode; minHeight?: number; @@ -13,6 +13,7 @@ export type HeaderButtonProps = React.ComponentProps & { showDisclosure?: boolean; showMoreMenu?: boolean; onClick: React.MouseEventHandler; + children?: React.ReactNode; }; const HeaderButton = React.forwardRef( @@ -23,44 +24,49 @@ const HeaderButton = React.forwardRef( image, title, minHeight = 0, + children, ...rest }: HeaderButtonProps, ref ) => ( - - - {image} - {title} - - {showDisclosure && } - {showMoreMenu && } - + + + {children} + ) ); const Title = styled(Flex)` color: ${s("text")}; flex-shrink: 1; + flex-grow: 1; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; `; -const Wrapper = styled(Flex)<{ minHeight: number }>` +const Button = styled(Flex)<{ minHeight: number }>` + flex: 1; + color: ${s("textTertiary")}; + align-items: center; padding: 8px 4px; font-size: 15px; font-weight: 500; border-radius: 4px; - margin: 8px; - color: ${s("textTertiary")}; + margin: 8px 0; border: 0; background: none; flex-shrink: 0; @@ -81,6 +87,14 @@ const Wrapper = styled(Flex)<{ minHeight: number }>` transition: background 100ms ease-in-out; background: ${s("sidebarActiveBackground")}; } + + &:last-child { + margin-right: 8px; + } + + &:first-child { + margin-left: 8px; + } `; export default HeaderButton; diff --git a/app/components/Text.ts b/app/components/Text.ts index af1486277..be16ca4ce 100644 --- a/app/components/Text.ts +++ b/app/components/Text.ts @@ -33,7 +33,7 @@ const Text = styled.p` : "inherit"}; font-weight: ${(props) => props.weight === "bold" - ? "bold" + ? 500 : props.weight === "normal" ? "normal" : "inherit"}; diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 3a1b39632..e9280b8e7 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -10,6 +10,7 @@ import Comment from "~/models/Comment"; import Document from "~/models/Document"; import FileOperation from "~/models/FileOperation"; import Group from "~/models/Group"; +import Notification from "~/models/Notification"; import Pin from "~/models/Pin"; import Star from "~/models/Star"; import Subscription from "~/models/Subscription"; @@ -89,6 +90,7 @@ class WebsocketProvider extends React.Component { views, subscriptions, fileOperations, + notifications, } = this.props; if (!auth.token) { return; @@ -323,6 +325,20 @@ class WebsocketProvider extends React.Component { auth.team?.updateFromJson(event); }); + this.socket.on( + "notifications.create", + (event: PartialWithId) => { + notifications.add(event); + } + ); + + this.socket.on( + "notifications.update", + (event: PartialWithId) => { + notifications.add(event); + } + ); + this.socket.on("pins.create", (event: PartialWithId) => { pins.add(event); }); diff --git a/app/hooks/useFocusedComment.ts b/app/hooks/useFocusedComment.ts index 9a6ea6b54..8ee276dec 100644 --- a/app/hooks/useFocusedComment.ts +++ b/app/hooks/useFocusedComment.ts @@ -7,5 +7,9 @@ export default function useFocusedComment() { const location = useLocation<{ commentId?: string }>(); const query = useQuery(); const focusedCommentId = location.state?.commentId || query.get("commentId"); - return focusedCommentId ? comments.get(focusedCommentId) : undefined; + const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined; + + return comment?.parentCommentId + ? comments.get(comment.parentCommentId) + : comment; } diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index c82e47369..d4b2a0e3d 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -35,7 +35,7 @@ import GoogleIcon from "~/components/Icons/GoogleIcon"; import ZapierIcon from "~/components/Icons/ZapierIcon"; import PluginLoader from "~/utils/PluginLoader"; import isCloudHosted from "~/utils/isCloudHosted"; -import { accountPreferencesPath } from "~/utils/routeHelpers"; +import { settingsPath } from "~/utils/routeHelpers"; import useCurrentTeam from "./useCurrentTeam"; import usePolicy from "./usePolicy"; @@ -57,7 +57,7 @@ const useSettingsConfig = () => { const items: ConfigItem[] = [ { name: t("Profile"), - path: "/settings", + path: settingsPath(), component: Profile, enabled: true, group: t("Account"), @@ -65,7 +65,7 @@ const useSettingsConfig = () => { }, { name: t("Preferences"), - path: accountPreferencesPath(), + path: settingsPath("preferences"), component: Preferences, enabled: true, group: t("Account"), @@ -73,7 +73,7 @@ const useSettingsConfig = () => { }, { name: t("Notifications"), - path: "/settings/notifications", + path: settingsPath("notifications"), component: Notifications, enabled: true, group: t("Account"), @@ -81,7 +81,7 @@ const useSettingsConfig = () => { }, { name: t("API Tokens"), - path: "/settings/tokens", + path: settingsPath("tokens"), component: ApiKeys, enabled: can.createApiKey, group: t("Account"), @@ -90,7 +90,7 @@ const useSettingsConfig = () => { // Team group { name: t("Details"), - path: "/settings/details", + path: settingsPath("details"), component: Details, enabled: can.update, group: t("Workspace"), @@ -98,7 +98,7 @@ const useSettingsConfig = () => { }, { name: t("Security"), - path: "/settings/security", + path: settingsPath("security"), component: Security, enabled: can.update, group: t("Workspace"), @@ -106,7 +106,7 @@ const useSettingsConfig = () => { }, { name: t("Features"), - path: "/settings/features", + path: settingsPath("features"), component: Features, enabled: can.update, group: t("Workspace"), @@ -114,7 +114,7 @@ const useSettingsConfig = () => { }, { name: t("Members"), - path: "/settings/members", + path: settingsPath("members"), component: Members, enabled: true, group: t("Workspace"), @@ -122,7 +122,7 @@ const useSettingsConfig = () => { }, { name: t("Groups"), - path: "/settings/groups", + path: settingsPath("groups"), component: Groups, enabled: true, group: t("Workspace"), @@ -130,7 +130,7 @@ const useSettingsConfig = () => { }, { name: t("Shared Links"), - path: "/settings/shares", + path: settingsPath("shares"), component: Shares, enabled: true, group: t("Workspace"), @@ -138,7 +138,7 @@ const useSettingsConfig = () => { }, { name: t("Import"), - path: "/settings/import", + path: settingsPath("import"), component: Import, enabled: can.createImport, group: t("Workspace"), @@ -146,7 +146,7 @@ const useSettingsConfig = () => { }, { name: t("Export"), - path: "/settings/export", + path: settingsPath("export"), component: Export, enabled: can.createExport, group: t("Workspace"), diff --git a/app/models/Notification.ts b/app/models/Notification.ts new file mode 100644 index 000000000..35d10c53a --- /dev/null +++ b/app/models/Notification.ts @@ -0,0 +1,127 @@ +import { TFunction } from "i18next"; +import { action, observable } from "mobx"; +import { NotificationEventType } from "@shared/types"; +import { + collectionPath, + commentPath, + documentPath, +} from "~/utils/routeHelpers"; +import BaseModel from "./BaseModel"; +import Comment from "./Comment"; +import Document from "./Document"; +import User from "./User"; +import Field from "./decorators/Field"; + +class Notification extends BaseModel { + @Field + @observable + id: string; + + @Field + @observable + viewedAt: Date | null; + + @Field + @observable + archivedAt: Date | null; + + actor: User; + + documentId?: string; + + collectionId?: string; + + document?: Document; + + comment?: Comment; + + event: NotificationEventType; + + /** + * Mark the notification as read or unread + * + * @returns A promise that resolves when the notification has been saved. + */ + @action + toggleRead() { + this.viewedAt = this.viewedAt ? null : new Date(); + return this.save(); + } + + /** + * Mark the notification as read + * + * @returns A promise that resolves when the notification has been saved. + */ + @action + markAsRead() { + if (this.viewedAt) { + return; + } + + this.viewedAt = new Date(); + return this.save(); + } + + /** + * Returns translated text that describes the notification + * + * @param t - The translation function + * @returns The event text + */ + eventText(t: TFunction): string { + switch (this.event) { + case "documents.publish": + return t("published"); + case "documents.update": + case "revisions.create": + return t("edited"); + case "collections.create": + return t("created the collection"); + case "documents.mentioned": + case "comments.mentioned": + return t("mentioned you in"); + case "comments.create": + return t("left a comment on"); + default: + return this.event; + } + } + + get subject() { + return this.document?.title; + } + + /** + * Returns the path to the model associated with the notification that can be + * used with the router. + * + * @returns The router path. + */ + get path() { + switch (this.event) { + case "documents.publish": + case "documents.update": + case "revisions.create": { + return this.document ? documentPath(this.document) : ""; + } + case "collections.create": { + const collection = this.store.rootStore.documents.get( + this.collectionId + ); + return collection ? collectionPath(collection.url) : ""; + } + case "documents.mentioned": + case "comments.mentioned": + case "comments.create": { + return this.document && this.comment + ? commentPath(this.document, this.comment) + : ""; + } + default: + return ""; + } + } +} + +export default Notification; diff --git a/app/scenes/GroupDelete.tsx b/app/scenes/GroupDelete.tsx index 0a4d0540c..6a4500669 100644 --- a/app/scenes/GroupDelete.tsx +++ b/app/scenes/GroupDelete.tsx @@ -7,7 +7,7 @@ import Button from "~/components/Button"; import Flex from "~/components/Flex"; import Text from "~/components/Text"; import useToasts from "~/hooks/useToasts"; -import { groupSettingsPath } from "~/utils/routeHelpers"; +import { settingsPath } from "~/utils/routeHelpers"; type Props = { group: Group; @@ -26,7 +26,7 @@ function GroupDelete({ group, onSubmit }: Props) { try { await group.delete(); - history.push(groupSettingsPath()); + history.push(settingsPath("groups")); onSubmit(); } catch (err) { showToast(err.message, { diff --git a/app/stores/NotificationsStore.ts b/app/stores/NotificationsStore.ts new file mode 100644 index 000000000..9eee1e542 --- /dev/null +++ b/app/stores/NotificationsStore.ts @@ -0,0 +1,77 @@ +import invariant from "invariant"; +import { orderBy, sortBy } from "lodash"; +import { action, computed, runInAction } from "mobx"; +import Notification from "~/models/Notification"; +import { PaginationParams } from "~/types"; +import { client } from "~/utils/ApiClient"; +import BaseStore, { RPCAction } from "./BaseStore"; +import RootStore from "./RootStore"; + +export default class NotificationsStore extends BaseStore { + actions = [RPCAction.List, RPCAction.Update]; + + constructor(rootStore: RootStore) { + super(rootStore, Notification); + } + + @action + fetchPage = async ( + options: PaginationParams | undefined + ): Promise => { + this.isFetching = true; + + try { + const res = await client.post("/notifications.list", options); + invariant(res?.data, "Document revisions not available"); + + let models: Notification[] = []; + runInAction("NotificationsStore#fetchPage", () => { + models = res.data.notifications.map(this.add); + this.isLoaded = true; + }); + + return models; + } finally { + this.isFetching = false; + } + }; + + /** + * Mark all notifications as read. + */ + @action + markAllAsRead = async () => { + await client.post("/notifications.update_all", { + viewedAt: new Date(), + }); + + runInAction("NotificationsStore#markAllAsRead", () => { + const viewedAt = new Date(); + this.data.forEach((notification) => { + notification.viewedAt = viewedAt; + }); + }); + }; + + /** + * Returns the approximate number of unread notifications. + */ + @computed + get approximateUnreadCount(): number { + return this.orderedData.filter((notification) => !notification.viewedAt) + .length; + } + + /** + * Returns the notifications in order of created date. + */ + @computed + get orderedData(): Notification[] { + return sortBy( + orderBy(Array.from(this.data.values()), "createdAt", "desc"), + (item) => { + item.viewedAt ? 1 : -1; + } + ); + } +} diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index ffea4bbb1..15bb17535 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -13,6 +13,7 @@ import GroupMembershipsStore from "./GroupMembershipsStore"; import GroupsStore from "./GroupsStore"; import IntegrationsStore from "./IntegrationsStore"; import MembershipsStore from "./MembershipsStore"; +import NotificationsStore from "./NotificationsStore"; import PinsStore from "./PinsStore"; import PoliciesStore from "./PoliciesStore"; import RevisionsStore from "./RevisionsStore"; @@ -40,6 +41,7 @@ export default class RootStore { groupMemberships: GroupMembershipsStore; integrations: IntegrationsStore; memberships: MembershipsStore; + notifications: NotificationsStore; presence: DocumentPresenceStore; pins: PinsStore; policies: PoliciesStore; @@ -71,6 +73,7 @@ export default class RootStore { this.groupMemberships = new GroupMembershipsStore(this); this.integrations = new IntegrationsStore(this); this.memberships = new MembershipsStore(this); + this.notifications = new NotificationsStore(this); this.pins = new PinsStore(this); this.presence = new DocumentPresenceStore(); this.revisions = new RevisionsStore(this); diff --git a/app/utils/routeHelpers.test.ts b/app/utils/routeHelpers.test.ts index f9a718755..49dd176bb 100644 --- a/app/utils/routeHelpers.test.ts +++ b/app/utils/routeHelpers.test.ts @@ -1,4 +1,4 @@ -import { sharedDocumentPath, accountPreferencesPath } from "./routeHelpers"; +import { sharedDocumentPath } from "./routeHelpers"; describe("#sharedDocumentPath", () => { test("should return share path for a document", () => { @@ -12,9 +12,3 @@ describe("#sharedDocumentPath", () => { ); }); }); - -describe("#accountPreferencesPath", () => { - test("should return account preferences path", () => { - expect(accountPreferencesPath()).toBe("/settings/preferences"); - }); -}); diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index b9dfb5726..7183b2fc8 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -23,24 +23,8 @@ export function trashPath(): string { return "/trash"; } -export function settingsPath(): string { - return "/settings"; -} - -export function organizationSettingsPath(): string { - return "/settings/details"; -} - -export function profileSettingsPath(): string { - return "/settings"; -} - -export function accountPreferencesPath(): string { - return "/settings/preferences"; -} - -export function groupSettingsPath(): string { - return "/settings/groups"; +export function settingsPath(section?: string): string { + return "/settings" + (section ? `/${section}` : ""); } export function commentPath(document: Document, comment: Comment): string { diff --git a/package.json b/package.json index f37fcce94..10831006d 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "natural-sort": "^1.0.0", "node-fetch": "2.6.7", "nodemailer": "^6.9.1", - "outline-icons": "^2.1.0", + "outline-icons": "^2.2.0", "oy-vey": "^0.12.0", "passport": "^0.6.0", "passport-google-oauth2": "^0.2.0", diff --git a/plugins/slack/server/api/hooks.ts b/plugins/slack/server/api/hooks.ts index b96d2ab6f..f77efea59 100644 --- a/plugins/slack/server/api/hooks.ts +++ b/plugins/slack/server/api/hooks.ts @@ -1,4 +1,3 @@ -import crypto from "crypto"; import { t } from "i18next"; import Router from "koa-router"; import { escapeRegExp } from "lodash"; @@ -18,6 +17,7 @@ import { } from "@server/models"; import SearchHelper from "@server/models/helpers/SearchHelper"; import { APIContext } from "@server/types"; +import { safeEqual } from "@server/utils/crypto"; import { opts } from "@server/utils/i18n"; import { assertPresent } from "@server/validation"; import presentMessageAttachment from "../presenters/messageAttachment"; @@ -32,13 +32,7 @@ function verifySlackToken(token: string) { ); } - if ( - token.length !== env.SLACK_VERIFICATION_TOKEN.length || - !crypto.timingSafeEqual( - Buffer.from(env.SLACK_VERIFICATION_TOKEN), - Buffer.from(token) - ) - ) { + if (!safeEqual(env.SLACK_VERIFICATION_TOKEN, token)) { throw AuthenticationError("Invalid token"); } } diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index 6b1c32056..c1d4740bd 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -103,6 +103,7 @@ export default class DeliverWebhookTask extends BaseTask { case "subscriptions.delete": case "authenticationProviders.update": case "notifications.create": + case "notifications.update": // Ignored return; case "users.create": diff --git a/server/commands/notificationUpdater.test.ts b/server/commands/notificationUpdater.test.ts new file mode 100644 index 000000000..c6a9672aa --- /dev/null +++ b/server/commands/notificationUpdater.test.ts @@ -0,0 +1,187 @@ +import { NotificationEventType } from "@shared/types"; +import { sequelize } from "@server/database/sequelize"; +import { Event } from "@server/models"; +import { + buildUser, + buildNotification, + buildDocument, + buildCollection, +} from "@server/test/factories"; +import { setupTestDatabase } from "@server/test/support"; +import notificationUpdater from "./notificationUpdater"; + +setupTestDatabase(); + +describe("notificationUpdater", () => { + const ip = "127.0.0.1"; + + it("should mark the notification as viewed", async () => { + const user = await buildUser(); + const actor = await buildUser({ + teamId: user.teamId, + }); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: actor.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + createdById: actor.id, + }); + const notification = await buildNotification({ + actorId: actor.id, + event: NotificationEventType.UpdateDocument, + userId: user.id, + teamId: user.teamId, + documentId: document.id, + collectionId: collection.id, + }); + + expect(notification.archivedAt).toBe(null); + expect(notification.viewedAt).toBe(null); + + await sequelize.transaction(async (transaction) => + notificationUpdater({ + notification, + viewedAt: new Date(), + ip, + transaction, + }) + ); + const event = await Event.findOne(); + + expect(notification.viewedAt).not.toBe(null); + expect(notification.archivedAt).toBe(null); + expect(event!.name).toEqual("notifications.update"); + expect(event!.modelId).toEqual(notification.id); + }); + + it("should mark the notification as unseen", async () => { + const user = await buildUser(); + const actor = await buildUser({ + teamId: user.teamId, + }); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: actor.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + createdById: actor.id, + }); + const notification = await buildNotification({ + actorId: actor.id, + event: NotificationEventType.UpdateDocument, + userId: user.id, + teamId: user.teamId, + documentId: document.id, + collectionId: collection.id, + viewedAt: new Date(), + }); + + expect(notification.archivedAt).toBe(null); + expect(notification.viewedAt).not.toBe(null); + + await sequelize.transaction(async (transaction) => + notificationUpdater({ + notification, + viewedAt: null, + ip, + transaction, + }) + ); + const event = await Event.findOne(); + + expect(notification.viewedAt).toBe(null); + expect(notification.archivedAt).toBe(null); + expect(event!.name).toEqual("notifications.update"); + expect(event!.modelId).toEqual(notification.id); + }); + + it("should archive the notification", async () => { + const user = await buildUser(); + const actor = await buildUser({ + teamId: user.teamId, + }); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: actor.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + createdById: actor.id, + }); + const notification = await buildNotification({ + actorId: actor.id, + event: NotificationEventType.UpdateDocument, + userId: user.id, + teamId: user.teamId, + documentId: document.id, + collectionId: collection.id, + }); + + expect(notification.archivedAt).toBe(null); + expect(notification.viewedAt).toBe(null); + + await sequelize.transaction(async (transaction) => + notificationUpdater({ + notification, + archivedAt: new Date(), + ip, + transaction, + }) + ); + const event = await Event.findOne(); + + expect(notification.viewedAt).toBe(null); + expect(notification.archivedAt).not.toBe(null); + expect(event!.name).toEqual("notifications.update"); + expect(event!.modelId).toEqual(notification.id); + }); + + it("should unarchive the notification", async () => { + const user = await buildUser(); + const actor = await buildUser({ + teamId: user.teamId, + }); + const collection = await buildCollection({ + teamId: user.teamId, + createdById: actor.id, + }); + const document = await buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + createdById: actor.id, + }); + const notification = await buildNotification({ + actorId: actor.id, + event: NotificationEventType.UpdateDocument, + userId: user.id, + teamId: user.teamId, + documentId: document.id, + collectionId: collection.id, + archivedAt: new Date(), + }); + + expect(notification.archivedAt).not.toBe(null); + expect(notification.viewedAt).toBe(null); + + await sequelize.transaction(async (transaction) => + notificationUpdater({ + notification, + archivedAt: null, + ip, + transaction, + }) + ); + const event = await Event.findOne(); + + expect(notification.viewedAt).toBe(null); + expect(notification.archivedAt).toBeNull(); + expect(event!.name).toEqual("notifications.update"); + expect(event!.modelId).toEqual(notification.id); + }); +}); diff --git a/server/commands/notificationUpdater.ts b/server/commands/notificationUpdater.ts new file mode 100644 index 000000000..367f4f2fa --- /dev/null +++ b/server/commands/notificationUpdater.ts @@ -0,0 +1,56 @@ +import { isUndefined } from "lodash"; +import { Transaction } from "sequelize"; +import { Event, Notification } from "@server/models"; + +type Props = { + /** Notification to be updated */ + notification: Notification; + /** Time at which notification was viewed */ + viewedAt?: Date | null; + /** Time at which notification was archived */ + archivedAt?: Date | null; + /** The IP address of the user updating the notification */ + ip: string; + /** The database transaction to run within */ + transaction: Transaction; +}; + +/** + * This command updates notification properties. + * + * @param Props The properties of the notification to update + * @returns Notification The updated notification + */ +export default async function notificationUpdater({ + notification, + viewedAt, + archivedAt, + ip, + transaction, +}: Props): Promise { + if (!isUndefined(viewedAt)) { + notification.viewedAt = viewedAt; + } + if (!isUndefined(archivedAt)) { + notification.archivedAt = archivedAt; + } + const changed = notification.changed(); + if (changed) { + await notification.save({ transaction }); + + await Event.create( + { + name: "notifications.update", + userId: notification.userId, + modelId: notification.id, + teamId: notification.teamId, + documentId: notification.documentId, + actorId: notification.actorId, + ip, + }, + { transaction } + ); + } + + return notification; +} diff --git a/server/migrations/20230419124305-add-archivedat-col-to-notifications.js b/server/migrations/20230419124305-add-archivedat-col-to-notifications.js new file mode 100644 index 000000000..ed44a7788 --- /dev/null +++ b/server/migrations/20230419124305-add-archivedat-col-to-notifications.js @@ -0,0 +1,27 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn("notifications", "archivedAt", { + type: Sequelize.DATE, + allowNull: true, + transaction, + }); + await queryInterface.addIndex("notifications", ["archivedAt"], { + transaction, + }); + }); + }, + + async down(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex("notifications", ["archivedAt"], { + transaction, + }); + await queryInterface.removeColumn("notifications", "archivedAt", { + transaction, + }); + }); + }, +}; diff --git a/server/migrations/20230419132159-add-indexes-to-notifications.js b/server/migrations/20230419132159-add-indexes-to-notifications.js new file mode 100644 index 000000000..f769b7615 --- /dev/null +++ b/server/migrations/20230419132159-add-indexes-to-notifications.js @@ -0,0 +1,36 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addIndex("notifications", ["createdAt"], { + transaction, + }); + await queryInterface.addIndex("notifications", ["event"], { + transaction, + }); + await queryInterface.addIndex("notifications", ["viewedAt"], { + where: { + viewedAt: { + [Sequelize.Op.is]: null, + }, + }, + transaction, + }); + }); + }, + + async down(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex("notifications", ["createdAt"], { + transaction, + }); + await queryInterface.removeIndex("notifications", ["event"], { + transaction, + }); + await queryInterface.removeIndex("notifications", ["viewedAt"], { + transaction, + }); + }); + }, +}; diff --git a/server/models/Notification.ts b/server/models/Notification.ts index 2d0e62b55..67da3fec1 100644 --- a/server/models/Notification.ts +++ b/server/models/Notification.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import type { SaveOptions } from "sequelize"; import { Table, @@ -11,10 +12,12 @@ import { DataType, Default, AllowNull, - AfterSave, Scopes, + AfterCreate, + DefaultScope, } from "sequelize-typescript"; import { NotificationEventType } from "@shared/types"; +import env from "@server/env"; import Collection from "./Collection"; import Comment from "./Comment"; import Document from "./Document"; @@ -32,10 +35,17 @@ import Fix from "./decorators/Fix"; }, ], }, - withUser: { + withDocument: { include: [ { - association: "user", + association: "document", + }, + ], + }, + withComment: { + include: [ + { + association: "comment", }, ], }, @@ -47,6 +57,19 @@ import Fix from "./decorators/Fix"; ], }, })) +@DefaultScope(() => ({ + include: [ + { + association: "document", + }, + { + association: "comment", + }, + { + association: "actor", + }, + ], +})) @Table({ tableName: "notifications", modelName: "notification", @@ -66,7 +89,11 @@ class Notification extends Model { @AllowNull @Column - viewedAt: Date; + viewedAt: Date | null; + + @AllowNull + @Column + archivedAt: Date | null; @CreatedAt createdAt: Date; @@ -130,7 +157,7 @@ class Notification extends Model { @Column(DataType.UUID) teamId: string; - @AfterSave + @AfterCreate static async createEvent( model: Notification, options: SaveOptions @@ -150,6 +177,18 @@ class Notification extends Model { } await Event.schedule(params); } + + /** + * Returns a token that can be used to mark this notification as read + * without being logged in. + * + * @returns A string token + */ + public get pixelToken() { + const hash = crypto.createHash("sha256"); + hash.update(`${this.id}-${env.SECRET_KEY}`); + return hash.digest("hex"); + } } export default Notification; diff --git a/server/policies/index.ts b/server/policies/index.ts index 27487669c..4437fb4ff 100644 --- a/server/policies/index.ts +++ b/server/policies/index.ts @@ -7,6 +7,7 @@ import { Comment, Document, Group, + Notification, } from "@server/models"; import { _abilities, _can, _cannot, _authorize } from "./cancan"; import "./apiKey"; @@ -26,6 +27,7 @@ import "./user"; import "./team"; import "./group"; import "./webhookSubscription"; +import "./notification"; type Policy = Record; @@ -55,6 +57,7 @@ export function serialize( | Document | User | Group + | Notification | null ): Policy { const output = {}; diff --git a/server/policies/notification.ts b/server/policies/notification.ts new file mode 100644 index 000000000..e66731b50 --- /dev/null +++ b/server/policies/notification.ts @@ -0,0 +1,9 @@ +import { Notification, User } from "@server/models"; +import { allow } from "./cancan"; + +allow(User, ["read", "update"], Notification, (user, notification) => { + if (!notification) { + return false; + } + return user?.id === notification.userId; +}); diff --git a/server/presenters/notification.ts b/server/presenters/notification.ts new file mode 100644 index 000000000..401f4344a --- /dev/null +++ b/server/presenters/notification.ts @@ -0,0 +1,26 @@ +import { Notification } from "@server/models"; +import presentUser from "./user"; +import { presentComment, presentDocument } from "."; + +export default async function presentNotification(notification: Notification) { + return { + id: notification.id, + viewedAt: notification.viewedAt, + archivedAt: notification.archivedAt, + createdAt: notification.createdAt, + event: notification.event, + userId: notification.userId, + actorId: notification.actorId, + actor: notification.actor ? presentUser(notification.actor) : undefined, + commentId: notification.commentId, + comment: notification.comment + ? presentComment(notification.comment) + : undefined, + documentId: notification.documentId, + document: notification.document + ? await presentDocument(notification.document) + : undefined, + revisionId: notification.revisionId, + collectionId: notification.collectionId, + }; +} diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index ef18c85f0..381cdebe1 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -13,6 +13,7 @@ import { Star, Team, Subscription, + Notification, } from "@server/models"; import { presentComment, @@ -25,6 +26,7 @@ import { presentSubscription, presentTeam, } from "@server/presenters"; +import presentNotification from "@server/presenters/notification"; import { Event } from "../../types"; export default class WebsocketsProcessor { @@ -390,6 +392,17 @@ export default class WebsocketsProcessor { }); } + case "notifications.create": + case "notifications.update": { + const notification = await Notification.findByPk(event.modelId); + if (!notification) { + return; + } + + const data = await presentNotification(notification); + return socketio.to(`user-${event.userId}`).emit(event.name, data); + } + case "stars.create": case "stars.update": { const star = await Star.findByPk(event.modelId); diff --git a/server/routes/api/cron/cron.ts b/server/routes/api/cron/cron.ts index ba0051591..88bb10fed 100644 --- a/server/routes/api/cron/cron.ts +++ b/server/routes/api/cron/cron.ts @@ -1,10 +1,10 @@ -import crypto from "crypto"; import Router from "koa-router"; import env from "@server/env"; import { AuthenticationError } from "@server/errors"; import validate from "@server/middlewares/validate"; import tasks from "@server/queues/tasks"; import { APIContext } from "@server/types"; +import { safeEqual } from "@server/utils/crypto"; import * as T from "./schema"; const router = new Router(); @@ -13,13 +13,7 @@ const cronHandler = async (ctx: APIContext) => { const token = (ctx.input.body.token ?? ctx.input.query.token) as string; const limit = ctx.input.body.limit ?? ctx.input.query.limit; - if ( - token.length !== env.UTILS_SECRET.length || - !crypto.timingSafeEqual( - Buffer.from(env.UTILS_SECRET), - Buffer.from(String(token)) - ) - ) { + if (!safeEqual(env.UTILS_SECRET, token)) { throw AuthenticationError("Invalid secret token"); } diff --git a/server/routes/api/notifications/notifications.test.ts b/server/routes/api/notifications/notifications.test.ts new file mode 100644 index 000000000..63a66035b --- /dev/null +++ b/server/routes/api/notifications/notifications.test.ts @@ -0,0 +1,543 @@ +import { randomElement } from "@shared/random"; +import { NotificationEventType } from "@shared/types"; +import { + buildCollection, + buildDocument, + buildNotification, + buildTeam, + buildUser, +} from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("#notifications.list", () => { + it("should return notifications in reverse chronological order", 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, + userId: user.id, + viewedAt: new Date(), + }), + buildNotification({ + actorId: actor.id, + documentId: document.id, + collectionId: collection.id, + event: NotificationEventType.CreateComment, + 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(), + }, + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.notifications.length).toBe(3); + expect(body.pagination.total).toBe(3); + expect(body.data.unseen).toBe(2); + 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.UpdateDocument); + expect(events).toContain(NotificationEventType.CreateComment); + expect(events).toContain(NotificationEventType.MentionedInComment); + }); + + it("should return notifications filtered by event type", 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, + userId: user.id, + }), + buildNotification({ + actorId: actor.id, + documentId: document.id, + collectionId: collection.id, + event: NotificationEventType.CreateComment, + 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(), + eventType: NotificationEventType.MentionedInComment, + }, + }); + 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); + }); + + it("should return 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: true, + }, + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.notifications.length).toBe(2); + expect(body.pagination.total).toBe(2); + expect(body.data.unseen).toBe(2); + 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.CreateComment); + expect(events).toContain(NotificationEventType.UpdateDocument); + }); +}); + +describe("#notifications.update", () => { + it("should mark notification as viewed", async () => { + const team = await buildTeam(); + const user = await buildUser({ + teamId: team.id, + }); + const actor = await buildUser({ + teamId: team.id, + }); + const collection = await buildCollection({ + teamId: team.id, + createdById: actor.id, + }); + const document = await buildDocument({ + teamId: team.id, + collectionId: collection.id, + createdById: actor.id, + }); + const notification = await buildNotification({ + teamId: team.id, + documentId: document.id, + collectionId: collection.id, + userId: user.id, + actorId: actor.id, + event: NotificationEventType.UpdateDocument, + }); + + expect(notification.viewedAt).toBeNull(); + + const res = await server.post("/api/notifications.update", { + body: { + token: user.getJwtToken(), + id: notification.id, + viewedAt: new Date(), + }, + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.id).toBe(notification.id); + expect(body.data.viewedAt).not.toBeNull(); + }); + + it("should archive the notification", async () => { + const team = await buildTeam(); + const user = await buildUser({ + teamId: team.id, + }); + const actor = await buildUser({ + teamId: team.id, + }); + const collection = await buildCollection({ + teamId: team.id, + createdById: actor.id, + }); + const document = await buildDocument({ + teamId: team.id, + collectionId: collection.id, + createdById: actor.id, + }); + const notification = await buildNotification({ + teamId: team.id, + documentId: document.id, + collectionId: collection.id, + userId: user.id, + actorId: actor.id, + event: NotificationEventType.UpdateDocument, + }); + + expect(notification.archivedAt).toBeNull(); + + const res = await server.post("/api/notifications.update", { + body: { + token: user.getJwtToken(), + id: notification.id, + archivedAt: new Date(), + }, + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.id).toBe(notification.id); + expect(body.data.archivedAt).not.toBeNull(); + }); +}); + +describe("#notifications.update_all", () => { + it("should perform no updates", 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, + viewedAt: new Date(), + userId: user.id, + }), + buildNotification({ + actorId: actor.id, + documentId: document.id, + collectionId: collection.id, + event: NotificationEventType.CreateComment, + 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.update_all", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.total).toBe(0); + }); + + it("should mark all notifications as viewed", 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, + viewedAt: new Date(), + userId: user.id, + }), + buildNotification({ + actorId: actor.id, + documentId: document.id, + collectionId: collection.id, + event: NotificationEventType.CreateComment, + 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.update_all", { + body: { + token: user.getJwtToken(), + viewedAt: new Date(), + }, + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.total).toBe(2); + }); + + it("should mark all seen notifications as unseen", 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, + viewedAt: new Date(), + userId: user.id, + }), + buildNotification({ + actorId: actor.id, + documentId: document.id, + collectionId: collection.id, + event: NotificationEventType.CreateComment, + viewedAt: 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.update_all", { + body: { + token: user.getJwtToken(), + viewedAt: null, + }, + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.total).toBe(2); + }); + + it("should archive all 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, + 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.update_all", { + body: { + token: user.getJwtToken(), + archivedAt: new Date(), + }, + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.total).toBe(2); + }); + + it("should unarchive all 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.update_all", { + body: { + token: user.getJwtToken(), + archivedAt: null, + }, + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.total).toBe(2); + }); +}); diff --git a/server/routes/api/notifications/notifications.ts b/server/routes/api/notifications/notifications.ts index b11c37d15..e2ef80a5b 100644 --- a/server/routes/api/notifications/notifications.ts +++ b/server/routes/api/notifications/notifications.ts @@ -1,14 +1,28 @@ import Router from "koa-router"; +import { isNull, isUndefined } from "lodash"; +import { WhereOptions, Op } from "sequelize"; import { NotificationEventType } from "@shared/types"; +import notificationUpdater from "@server/commands/notificationUpdater"; import env from "@server/env"; +import { AuthenticationError } from "@server/errors"; +import auth from "@server/middlewares/authentication"; import { transaction } from "@server/middlewares/transaction"; import validate from "@server/middlewares/validate"; -import { User } from "@server/models"; +import { Notification, User } from "@server/models"; import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper"; +import { authorize } from "@server/policies"; +import { presentPolicies } from "@server/presenters"; +import presentNotification from "@server/presenters/notification"; import { APIContext } from "@server/types"; +import { safeEqual } from "@server/utils/crypto"; +import pagination from "../middlewares/pagination"; import * as T from "./schema"; const router = new Router(); +const pixel = Buffer.from( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + "base64" +); const handleUnsubscribe = async ( ctx: APIContext @@ -49,4 +63,145 @@ router.post( handleUnsubscribe ); +router.post( + "notifications.list", + auth(), + pagination(), + validate(T.NotificationsListSchema), + transaction(), + async (ctx: APIContext) => { + const { eventType, archived } = ctx.input.body; + const user = ctx.state.auth.user; + let where: WhereOptions = { + userId: user.id, + }; + if (eventType) { + where = { ...where, event: eventType }; + } + if (archived) { + where = { + ...where, + archivedAt: { + [Op.ne]: null, + }, + }; + } + const [notifications, total, unseen] = await Promise.all([ + Notification.findAll({ + where, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + Notification.count({ + where, + }), + Notification.count({ + where: { + ...where, + viewedAt: { + [Op.is]: null, + }, + }, + }), + ]); + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: { + notifications: await Promise.all( + notifications.map(presentNotification) + ), + unseen, + }, + }; + } +); + +router.get( + "notifications.pixel", + transaction(), + async (ctx: APIContext) => { + const { id, token } = ctx.input.query; + const notification = await Notification.findByPk(id); + + if (!notification || !safeEqual(token, notification.pixelToken)) { + throw AuthenticationError(); + } + + await notificationUpdater({ + notification, + viewedAt: new Date(), + ip: ctx.request.ip, + transaction: ctx.state.transaction, + }); + + ctx.response.set("Content-Type", "image/gif"); + ctx.body = pixel; + } +); + +router.post( + "notifications.update", + auth(), + validate(T.NotificationsUpdateSchema), + transaction(), + async (ctx: APIContext) => { + const { id, viewedAt, archivedAt } = ctx.input.body; + const { user } = ctx.state.auth; + + const notification = await Notification.findByPk(id); + authorize(user, "update", notification); + + await notificationUpdater({ + notification, + viewedAt, + archivedAt, + ip: ctx.request.ip, + transaction: ctx.state.transaction, + }); + + ctx.body = { + data: await presentNotification(notification), + policies: presentPolicies(user, [notification]), + }; + } +); + +router.post( + "notifications.update_all", + auth(), + validate(T.NotificationsUpdateAllSchema), + async (ctx: APIContext) => { + const { viewedAt, archivedAt } = ctx.input.body; + const { user } = ctx.state.auth; + + const values: { [x: string]: any } = {}; + let where: WhereOptions = { + userId: user.id, + }; + if (!isUndefined(viewedAt)) { + values.viewedAt = viewedAt; + where = { + ...where, + viewedAt: !isNull(viewedAt) ? { [Op.is]: null } : { [Op.ne]: null }, + }; + } + if (!isUndefined(archivedAt)) { + values.archivedAt = archivedAt; + where = { + ...where, + archivedAt: !isNull(archivedAt) ? { [Op.is]: null } : { [Op.ne]: null }, + }; + } + + const [total] = await Notification.update(values, { where }); + + ctx.body = { + success: true, + data: { total }, + }; + } +); + export default router; diff --git a/server/routes/api/notifications/schema.ts b/server/routes/api/notifications/schema.ts index 54d7bfc93..726816bd4 100644 --- a/server/routes/api/notifications/schema.ts +++ b/server/routes/api/notifications/schema.ts @@ -1,8 +1,9 @@ import { isEmpty } from "lodash"; import { z } from "zod"; import { NotificationEventType } from "@shared/types"; +import BaseSchema from "../BaseSchema"; -export const NotificationSettingsCreateSchema = z.object({ +export const NotificationSettingsCreateSchema = BaseSchema.extend({ body: z.object({ eventType: z.nativeEnum(NotificationEventType), }), @@ -12,7 +13,7 @@ export type NotificationSettingsCreateReq = z.infer< typeof NotificationSettingsCreateSchema >; -export const NotificationSettingsDeleteSchema = z.object({ +export const NotificationSettingsDeleteSchema = BaseSchema.extend({ body: z.object({ eventType: z.nativeEnum(NotificationEventType), }), @@ -22,23 +23,60 @@ export type NotificationSettingsDeleteReq = z.infer< typeof NotificationSettingsDeleteSchema >; -export const NotificationsUnsubscribeSchema = z - .object({ - body: z.object({ - userId: z.string().uuid().optional(), - token: z.string().optional(), - eventType: z.nativeEnum(NotificationEventType).optional(), - }), - query: z.object({ - userId: z.string().uuid().optional(), - token: z.string().optional(), - eventType: z.nativeEnum(NotificationEventType).optional(), - }), - }) - .refine((req) => !(isEmpty(req.body.userId) && isEmpty(req.query.userId)), { - message: "userId is required", - }); +export const NotificationsUnsubscribeSchema = BaseSchema.extend({ + body: z.object({ + userId: z.string().uuid().optional(), + token: z.string().optional(), + eventType: z.nativeEnum(NotificationEventType).optional(), + }), + query: z.object({ + userId: z.string().uuid().optional(), + token: z.string().optional(), + eventType: z.nativeEnum(NotificationEventType).optional(), + }), +}).refine((req) => !(isEmpty(req.body.userId) && isEmpty(req.query.userId)), { + message: "userId is required", +}); export type NotificationsUnsubscribeReq = z.infer< typeof NotificationsUnsubscribeSchema >; + +export const NotificationsListSchema = BaseSchema.extend({ + body: z.object({ + eventType: z.nativeEnum(NotificationEventType).nullish(), + archived: z.boolean().nullish(), + }), +}); + +export type NotificationsListReq = z.infer; + +export const NotificationsUpdateSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + viewedAt: z.coerce.date().nullish(), + archivedAt: z.coerce.date().nullish(), + }), +}); + +export type NotificationsUpdateReq = z.infer; + +export const NotificationsUpdateAllSchema = BaseSchema.extend({ + body: z.object({ + viewedAt: z.coerce.date().nullish(), + archivedAt: z.coerce.date().nullish(), + }), +}); + +export type NotificationsUpdateAllReq = z.infer< + typeof NotificationsUpdateAllSchema +>; + +export const NotificationsPixelSchema = BaseSchema.extend({ + query: z.object({ + id: z.string(), + token: z.string(), + }), +}); + +export type NotificationsPixelReq = z.infer; diff --git a/server/routes/api/users/users.ts b/server/routes/api/users/users.ts index f312da7eb..a834c1c10 100644 --- a/server/routes/api/users/users.ts +++ b/server/routes/api/users/users.ts @@ -1,4 +1,3 @@ -import crypto from "crypto"; import Router from "koa-router"; import { Op, WhereOptions } from "sequelize"; import { UserPreference } from "@shared/types"; @@ -23,6 +22,7 @@ import { can, authorize } from "@server/policies"; import { presentUser, presentPolicies } from "@server/presenters"; import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; +import { safeEqual } from "@server/utils/crypto"; import { assertIn, assertSort, @@ -469,14 +469,7 @@ router.post( if ((!id || id === actor.id) && emailEnabled) { const deleteConfirmationCode = user.deleteConfirmationCode; - if ( - !code || - code.length !== deleteConfirmationCode.length || - !crypto.timingSafeEqual( - Buffer.from(code), - Buffer.from(deleteConfirmationCode) - ) - ) { + if (!safeEqual(code, deleteConfirmationCode)) { throw ValidationError("The confirmation code was incorrect"); } } diff --git a/server/test/factories.ts b/server/test/factories.ts index b92810be4..bb4b67318 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -6,6 +6,7 @@ import { FileOperationType, IntegrationService, IntegrationType, + NotificationEventType, } from "@shared/types"; import { Share, @@ -26,6 +27,7 @@ import { WebhookDelivery, ApiKey, Subscription, + Notification, } from "@server/models"; let count = 1; @@ -493,3 +495,25 @@ export async function buildWebhookDelivery( return WebhookDelivery.create(overrides); } + +export async function buildNotification( + overrides: Partial = {} +): Promise { + if (!overrides.event) { + overrides.event = NotificationEventType.UpdateDocument; + } + + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + + if (!overrides.userId) { + const user = await buildUser({ + teamId: overrides.teamId, + }); + overrides.userId = user.id; + } + + return Notification.create(overrides); +} diff --git a/server/types.ts b/server/types.ts index 2f0c77de5..d6c6670d9 100644 --- a/server/types.ts +++ b/server/types.ts @@ -358,7 +358,7 @@ export type WebhookSubscriptionEvent = BaseEvent & { }; export type NotificationEvent = BaseEvent & { - name: "notifications.create"; + name: "notifications.create" | "notifications.update"; modelId: string; teamId: string; userId: string; diff --git a/server/utils/crypto.ts b/server/utils/crypto.ts new file mode 100644 index 000000000..9bfebbe44 --- /dev/null +++ b/server/utils/crypto.ts @@ -0,0 +1,19 @@ +import crypto from "crypto"; + +/** + * Compare two strings in constant time to prevent timing attacks. + * + * @param a The first string to compare + * @param b The second string to compare + * @returns Whether the strings are equal + */ +export function safeEqual(a?: string, b?: string) { + if (!a || !b) { + return false; + } + if (a.length !== b.length) { + return false; + } + + return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} diff --git a/shared/editor/marks/Link.tsx b/shared/editor/marks/Link.tsx index 4a1eaf225..25a2a7926 100644 --- a/shared/editor/marks/Link.tsx +++ b/shared/editor/marks/Link.tsx @@ -209,6 +209,7 @@ export default class Link extends Mark { const target = (event.target as HTMLElement)?.closest("a"); if ( target instanceof HTMLAnchorElement && + this.editor.elementRef.current?.contains(target) && !target.className.includes("ProseMirror-widget") && (!view.editable || (view.editable && !view.hasFocus())) ) { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 36de57089..6cebe1260 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -62,6 +62,7 @@ "Trash": "Trash", "Settings": "Settings", "Profile": "Profile", + "Notifications": "Notifications", "Preferences": "Preferences", "API documentation": "API documentation", "Send us feedback": "Send us feedback", @@ -70,6 +71,7 @@ "Keyboard shortcuts": "Keyboard shortcuts", "Download {{ platform }} app": "Download {{ platform }} app", "Log out": "Log out", + "Mark notifications as read": "Mark notifications as read", "Restore revision": "Restore revision", "Copy link": "Copy link", "Link copied": "Link copied", @@ -90,6 +92,7 @@ "Document": "Document", "Revision": "Revision", "Navigation": "Navigation", + "Notification": "Notification", "People": "People", "Workspace": "Workspace", "Recent searches": "Recent searches", @@ -202,6 +205,8 @@ "Sorry, an error occurred.": "Sorry, an error occurred.", "Click to retry": "Click to retry", "Back": "Back", + "Mark all as read": "Mark all as read", + "No notifications yet": "No notifications yet", "Documents": "Documents", "Results": "Results", "No results for {{query}}": "No results for {{query}}", @@ -312,7 +317,6 @@ "Outdent": "Outdent", "Could not import file": "Could not import file", "Account": "Account", - "Notifications": "Notifications", "API Tokens": "API Tokens", "Details": "Details", "Security": "Security", @@ -370,6 +374,11 @@ "Resend invite": "Resend invite", "Revoke invite": "Revoke invite", "Activate account": "Activate account", + "published": "published", + "edited": "edited", + "created the collection": "created the collection", + "mentioned you in": "mentioned you in", + "left a comment on": "left a comment on", "API token created": "API token created", "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".", "The document archive is empty at the moment.": "The document archive is empty at the moment.", @@ -454,7 +463,6 @@ "Cancel": "Cancel", "No comments yet": "No comments yet", "Error updating comment": "Error updating comment", - "edited": "edited", "Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?", "{{ count }} comment": "{{ count }} comment", "{{ count }} comment_plural": "{{ count }} comments", diff --git a/shared/types.ts b/shared/types.ts index 5c65f6dd4..a7752acd8 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -171,6 +171,7 @@ export type CollectionSort = { export enum NotificationEventType { PublishDocument = "documents.publish", UpdateDocument = "documents.update", + CreateRevision = "revisions.create", CreateCollection = "collections.create", CreateComment = "comments.create", MentionedInDocument = "documents.mentioned", diff --git a/yarn.lock b/yarn.lock index 523c44706..9fb91d6e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10068,10 +10068,10 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== -outline-icons@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.1.0.tgz#4f920378503a4f0ec7885e09d4f9e095be56f15e" - integrity sha512-ifkCjttZZ9ugEWbVPWa/oerOCEkNGhNKsiY2LVHIr7x/KLsoaBhQyiLHT7pp9F0E00tlVXW4YuUNk/bTepavOw== +outline-icons@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.2.0.tgz#0ca59aa92da9364c1f1ed01e24858e9c034c6661" + integrity sha512-9QjFdxoCGGFz2RwsXYz2XLrHhS/qwH5tTq/tGG8hObaH4uD/0UDfK/80WY6aTBRoyGqZm3/gwRNl+lR2rELE2g== oy-vey@^0.12.0: version "0.12.0"