Notifications interface (#5354)

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
This commit is contained in:
Tom Moor
2023-05-20 10:47:32 -04:00
committed by GitHub
parent b1e2ff0713
commit ea885133ac
49 changed files with 1918 additions and 163 deletions

View File

@@ -30,15 +30,13 @@ import { isMac } from "~/utils/browser";
import history from "~/utils/history"; import history from "~/utils/history";
import isCloudHosted from "~/utils/isCloudHosted"; import isCloudHosted from "~/utils/isCloudHosted";
import { import {
organizationSettingsPath,
profileSettingsPath,
accountPreferencesPath,
homePath, homePath,
searchPath, searchPath,
draftsPath, draftsPath,
templatesPath, templatesPath,
archivePath, archivePath,
trashPath, trashPath,
settingsPath,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
export const navigateToHome = createAction({ export const navigateToHome = createAction({
@@ -105,7 +103,7 @@ export const navigateToSettings = createAction({
icon: <SettingsIcon />, icon: <SettingsIcon />,
visible: ({ stores }) => visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").update, stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(organizationSettingsPath()), perform: () => history.push(settingsPath("details")),
}); });
export const navigateToProfileSettings = createAction({ export const navigateToProfileSettings = createAction({
@@ -114,7 +112,16 @@ export const navigateToProfileSettings = createAction({
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <ProfileIcon />, icon: <ProfileIcon />,
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: <EmailIcon />,
perform: () => history.push(settingsPath("notifications")),
}); });
export const navigateToAccountPreferences = createAction({ export const navigateToAccountPreferences = createAction({
@@ -123,7 +130,7 @@ export const navigateToAccountPreferences = createAction({
section: NavigationSection, section: NavigationSection,
iconInContextMenu: false, iconInContextMenu: false,
icon: <SettingsIcon />, icon: <SettingsIcon />,
perform: () => history.push(accountPreferencesPath()), perform: () => history.push(settingsPath("preferences")),
}); });
export const openAPIDocumentation = createAction({ export const openAPIDocumentation = createAction({

View File

@@ -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: <MarkAsReadIcon />,
shortcut: ["Shift+Escape"],
perform: ({ stores }) => stores.notifications.markAllAsRead(),
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const rootNotificationActions = [markNotificationsAsRead];

View File

@@ -2,6 +2,7 @@ import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer"; import { rootDeveloperActions } from "./definitions/developer";
import { rootDocumentActions } from "./definitions/documents"; import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation"; import { rootNavigationActions } from "./definitions/navigation";
import { rootNotificationActions } from "./definitions/notifications";
import { rootRevisionActions } from "./definitions/revisions"; import { rootRevisionActions } from "./definitions/revisions";
import { rootSettingsActions } from "./definitions/settings"; import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams"; import { rootTeamActions } from "./definitions/teams";
@@ -12,6 +13,7 @@ export default [
...rootDocumentActions, ...rootDocumentActions,
...rootUserActions, ...rootUserActions,
...rootNavigationActions, ...rootNavigationActions,
...rootNotificationActions,
...rootRevisionActions, ...rootRevisionActions,
...rootSettingsActions, ...rootSettingsActions,
...rootDeveloperActions, ...rootDeveloperActions,

View File

@@ -12,6 +12,8 @@ export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation"); export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const UserSection = ({ t }: ActionContext) => t("People"); export const UserSection = ({ t }: ActionContext) => t("People");
export const TeamSection = ({ t }: ActionContext) => t("Workspace"); export const TeamSection = ({ t }: ActionContext) => t("Workspace");

View File

@@ -26,6 +26,9 @@ const ActionButton = React.forwardRef(
const [executing, setExecuting] = React.useState(false); const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled; const disabled = rest.disabled;
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) { if (!context || !action) {
return <button {...rest} ref={ref} />; return <button {...rest} ref={ref} />;
} }

View File

@@ -1,9 +1,14 @@
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles";
import useBoolean from "~/hooks/useBoolean"; import useBoolean from "~/hooks/useBoolean";
import Initials from "./Initials"; import Initials from "./Initials";
export enum AvatarSize {
Small = 18,
Medium = 24,
Large = 32,
}
export interface IAvatar { export interface IAvatar {
avatarUrl: string | null; avatarUrl: string | null;
color?: string; color?: string;
@@ -12,9 +17,8 @@ export interface IAvatar {
} }
type Props = { type Props = {
size: number; size: AvatarSize;
src?: string; src?: string;
icon?: React.ReactNode;
model?: IAvatar; model?: IAvatar;
alt?: string; alt?: string;
showBorder?: boolean; showBorder?: boolean;
@@ -24,7 +28,7 @@ type Props = {
}; };
function Avatar(props: Props) { function Avatar(props: Props) {
const { icon, showBorder, model, style, ...rest } = props; const { showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl; const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false); const [error, handleError] = useBoolean(false);
@@ -44,13 +48,12 @@ function Avatar(props: Props) {
) : ( ) : (
<Initials $showBorder={showBorder} {...rest} /> <Initials $showBorder={showBorder} {...rest} />
)} )}
{icon && <IconWrapper>{icon}</IconWrapper>}
</Relative> </Relative>
); );
} }
Avatar.defaultProps = { Avatar.defaultProps = {
size: 24, size: AvatarSize.Medium,
}; };
const Relative = styled.div` const Relative = styled.div`
@@ -59,18 +62,6 @@ const Relative = styled.div`
flex-shrink: 0; flex-shrink: 0;
`; `;
const IconWrapper = styled.div`
display: flex;
position: absolute;
bottom: -2px;
right: -2px;
background: ${s("accent")};
border: 2px solid ${s("background")};
border-radius: 100%;
width: 20px;
height: 20px;
`;
const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>` const CircleImg = styled.img<{ size: number; $showBorder?: boolean }>`
display: block; display: block;
width: ${(props) => props.size}px; width: ${(props) => props.size}px;

View File

@@ -60,7 +60,7 @@ const RealButton = styled(ActionButton)<RealProps>`
${(props) => ${(props) =>
props.$neutral && props.$neutral &&
` `
background: ${props.theme.buttonNeutralBackground}; background: inherit;
color: ${props.theme.buttonNeutralText}; color: ${props.theme.buttonNeutralText};
box-shadow: ${ box-shadow: ${
props.$borderOnHover props.$borderOnHover

View File

@@ -5,6 +5,7 @@ import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles"; import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Key from "~/components/Key"; import Key from "~/components/Key";
import Text from "./Text";
type Props = { type Props = {
action: ActionImpl; action: ActionImpl;
@@ -55,22 +56,36 @@ function CommandBarItem(
{action.children?.length ? "…" : ""} {action.children?.length ? "…" : ""}
</Content> </Content>
{action.shortcut?.length ? ( {action.shortcut?.length ? (
<div <Shortcut>
style={{ {action.shortcut.map((sc: string, index) => (
display: "grid", <React.Fragment key={sc}>
gridAutoFlow: "column", {index > 0 ? (
gap: "4px", <>
}} {" "}
> <Text size="xsmall" as="span" type="secondary">
{action.shortcut.map((sc: string) => ( then
<Key key={sc}>{sc}</Key> </Text>{" "}
</>
) : (
""
)}
{sc.split("+").map((s) => (
<Key key={s}>{s}</Key>
))} ))}
</div> </React.Fragment>
))}
</Shortcut>
) : null} ) : null}
</Item> </Item>
); );
} }
const Shortcut = styled.div`
display: grid;
grid-auto-flow: column;
gap: 4px;
`;
const Icon = styled(Flex)` const Icon = styled(Flex)`
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -28,7 +28,8 @@ const Flex = styled.div<{
align-items: ${({ align }) => align}; align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify}; justify-content: ${({ justify }) => justify};
flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")}; flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")}; flex-shrink: ${({ shrink }) =>
shrink === true ? 1 : shrink === false ? 0 : "initial"};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")}; gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;

View File

@@ -0,0 +1,107 @@
import { toJS } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";
type Props = {
notification: Notification;
onNavigate: () => void;
};
function NotificationListItem({ notification, onNavigate }: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const collectionId = notification.document?.collectionId;
const collection = collectionId ? collections.get(collectionId) : undefined;
const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
if (event.altKey) {
event.preventDefault();
event.stopPropagation();
notification.toggleRead();
return;
}
notification.markAsRead();
onNavigate();
};
return (
<Link to={notification.path} onClick={handleClick}>
<Container gap={8} $unread={!notification.viewedAt}>
<StyledAvatar model={notification.actor} size={AvatarSize.Large} />
<Flex column>
<Text as="div" size="small">
<Text as="span" weight="bold">
{notification.actor.name}
</Text>{" "}
{notification.eventText(t)}{" "}
<Text as="span" weight="bold">
{notification.subject}
</Text>
</Text>
<Text as="span" type="tertiary" size="xsmall">
<Time
dateTime={notification.createdAt}
tooltipDelay={1000}
addSuffix
/>{" "}
{collection && <>&middot; {collection.name}</>}
</Text>
{notification.comment && (
<StyledCommentEditor
defaultValue={toJS(notification.comment.data)}
/>
)}
</Flex>
{notification.viewedAt ? null : <Unread />}
</Container>
</Link>
);
}
const StyledCommentEditor = styled(CommentEditor)`
font-size: 0.9em;
margin-top: 4px;
`;
const StyledAvatar = styled(Avatar)`
margin-top: 4px;
`;
const Container = styled(Flex)<{ $unread: boolean }>`
position: relative;
padding: 8px 12px;
margin: 0 8px;
border-radius: 4px;
&:hover,
&:active {
background: ${s("listItemHoverBackground")};
cursor: var(--pointer);
}
`;
const Unread = styled.div`
width: 8px;
height: 8px;
background: ${s("accent")};
border-radius: 8px;
align-self: center;
position: absolute;
right: 20px;
`;
export default observer(NotificationListItem);

View File

@@ -0,0 +1,113 @@
import { observer } from "mobx-react";
import { MarkAsReadIcon, SettingsIcon } 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 Empty from "../Empty";
import Flex from "../Flex";
import NudeButton from "../NudeButton";
import PaginatedList from "../PaginatedList";
import Scrollable from "../Scrollable";
import Text from "../Text";
import Tooltip from "../Tooltip";
import NotificationListItem from "./NotificationListItem";
type Props = {
/* Callback when the notification panel wants to close. */
onRequestClose: () => void;
};
/**
* A panel containing a list of notifications and controls to manage them.
*/
function Notifications(
{ onRequestClose }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.orderedData.length === 0;
return (
<Flex style={{ width: "100%" }} ref={ref} column>
<Header justify="space-between">
<Text weight="bold" as="span">
{t("Notifications")}
</Text>
<Text color="textSecondary" as={Flex} gap={8}>
{notifications.approximateUnreadCount > 0 && (
<Tooltip delay={500} tooltip={t("Mark all as read")}>
<Button action={markNotificationsAsRead} context={context}>
<MarkAsReadIcon />
</Button>
</Tooltip>
)}
<Tooltip delay={500} tooltip={t("Settings")}>
<Button action={navigateToNotificationSettings} context={context}>
<SettingsIcon />
</Button>
</Tooltip>
</Text>
</Header>
<Scrollable flex topShadow>
<PaginatedList
fetch={notifications.fetchPage}
items={notifications.orderedData}
renderItem={(item: Notification) => (
<NotificationListItem
key={item.id}
notification={item}
onNavigate={onRequestClose}
/>
)}
/>
</Scrollable>
{isEmpty && (
<EmptyNotifications>{t("No notifications yet")}.</EmptyNotifications>
)}
</Flex>
);
}
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));

View File

@@ -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<HTMLDivElement>(null);
const popover = usePopoverState({
gutter: 0,
placement: "top-start",
unstable_fixed: true,
});
return (
<>
<PopoverDisclosure {...popover}>{children}</PopoverDisclosure>
<StyledPopover
{...popover}
scrollable={false}
mobilePosition="bottom"
aria-label={t("Notifications")}
unstable_initialFocusRef={focusRef}
shrink
flex
>
<Notifications onRequestClose={popover.hide} ref={focusRef} />
</StyledPopover>
</>
);
};
const StyledPopover = styled(Popover)`
z-index: ${depths.menu};
`;
export default observer(NotificationsButton);

View File

@@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import { Dialog } from "reakit/Dialog"; import { Dialog } from "reakit/Dialog";
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover"; 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 breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles"; import { depths, s } from "@shared/styles";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
@@ -11,13 +11,19 @@ type Props = PopoverProps & {
children: React.ReactNode; children: React.ReactNode;
width?: number; width?: number;
shrink?: boolean; shrink?: boolean;
flex?: boolean;
tabIndex?: number; tabIndex?: number;
scrollable?: boolean;
mobilePosition?: "top" | "bottom";
}; };
const Popover: React.FC<Props> = ({ const Popover: React.FC<Props> = ({
children, children,
shrink, shrink,
width = 380, width = 380,
scrollable = true,
flex,
mobilePosition,
...rest ...rest
}) => { }) => {
const isMobile = useMobile(); const isMobile = useMobile();
@@ -25,38 +31,67 @@ const Popover: React.FC<Props> = ({
if (isMobile) { if (isMobile) {
return ( return (
<Dialog {...rest} modal> <Dialog {...rest} modal>
<Contents $shrink={shrink}>{children}</Contents> <Contents
$shrink={shrink}
$scrollable={scrollable}
$flex={flex}
$mobilePosition={mobilePosition}
>
{children}
</Contents>
</Dialog> </Dialog>
); );
} }
return ( return (
<ReakitPopover {...rest}> <ReakitPopover {...rest}>
<Contents $shrink={shrink} $width={width}> <Contents
$shrink={shrink}
$width={width}
$scrollable={scrollable}
$flex={flex}
>
{children} {children}
</Contents> </Contents>
</ReakitPopover> </ReakitPopover>
); );
}; };
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<ContentsProps>`
display: ${(props) => (props.$flex ? "flex" : "block")};
animation: ${fadeAndScaleIn} 200ms ease; animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0; transform-origin: 75% 0;
background: ${s("menuBackground")}; background: ${s("menuBackground")};
border-radius: 6px; border-radius: 6px;
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")}; padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 75vh; max-height: 75vh;
overflow-x: hidden;
overflow-y: auto;
box-shadow: ${s("menuShadow")}; box-shadow: ${s("menuShadow")};
width: ${(props) => props.$width}px; width: ${(props) => props.$width}px;
${(props) =>
props.$scrollable &&
css`
overflow-x: hidden;
overflow-y: auto;
`}
${breakpoint("mobile", "tablet")` ${breakpoint("mobile", "tablet")`
position: fixed; position: fixed;
z-index: ${depths.menu}; z-index: ${depths.menu};
// 50 is a magic number that positions us nicely under the top bar // 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; left: 8px;
right: 8px; right: 8px;
width: auto; width: auto;

View File

@@ -1,4 +1,5 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { SubscribeIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Portal } from "react-portal"; import { Portal } from "react-portal";
@@ -15,7 +16,9 @@ import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import { fadeIn } from "~/styles/animations"; import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop"; import Desktop from "~/utils/Desktop";
import Avatar from "../Avatar"; import Avatar from "../Avatar";
import NotificationsButton from "../Notifications/NotificationsButton";
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton"; import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
import Relative from "./components/Relative";
import ResizeBorder from "./components/ResizeBorder"; import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle"; import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
@@ -184,8 +187,17 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
showBorder={false} showBorder={false}
/> />
} }
>
<NotificationsButton>
{(rest: HeaderButtonProps) => (
<HeaderButton
{...rest}
image={<BadgedNotificationIcon />}
/> />
)} )}
</NotificationsButton>
</HeaderButton>
)}
</AccountMenu> </AccountMenu>
)} )}
<ResizeBorder <ResizeBorder
@@ -211,6 +223,29 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
} }
); );
const BadgedNotificationIcon = observer(() => {
const { notifications } = useStores();
const theme = useTheme();
const count = notifications.approximateUnreadCount;
return (
<Relative style={{ height: 24 }}>
<SubscribeIcon color={theme.textTertiary} />
{count > 0 && <Badge />}
</Relative>
);
});
const Badge = styled.div`
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: ${s("accent")};
top: 0;
right: 0;
`;
const StyledAvatar = styled(Avatar)` const StyledAvatar = styled(Avatar)`
margin-left: 4px; margin-left: 4px;
`; `;

View File

@@ -5,7 +5,7 @@ import { s } from "@shared/styles";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import { undraggableOnDesktop } from "~/styles"; import { undraggableOnDesktop } from "~/styles";
export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & { export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
title: React.ReactNode; title: React.ReactNode;
image: React.ReactNode; image: React.ReactNode;
minHeight?: number; minHeight?: number;
@@ -13,6 +13,7 @@ export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & {
showDisclosure?: boolean; showDisclosure?: boolean;
showMoreMenu?: boolean; showMoreMenu?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>; onClick: React.MouseEventHandler<HTMLButtonElement>;
children?: React.ReactNode;
}; };
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>( const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
@@ -23,44 +24,49 @@ const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
image, image,
title, title,
minHeight = 0, minHeight = 0,
children,
...rest ...rest
}: HeaderButtonProps, }: HeaderButtonProps,
ref ref
) => ( ) => (
<Wrapper <Flex justify="space-between" align="center" shrink={false}>
role="button" <Button
justify="space-between"
align="center"
as="button"
minHeight={minHeight}
{...rest} {...rest}
minHeight={minHeight}
as="button"
ref={ref} ref={ref}
role="button"
> >
<Title gap={6} align="center"> <Title gap={8} align="center">
{image} {image}
{title} {title}
</Title> </Title>
{showDisclosure && <ExpandedIcon />} {showDisclosure && <ExpandedIcon />}
{showMoreMenu && <MoreIcon />} {showMoreMenu && <MoreIcon />}
</Wrapper> </Button>
{children}
</Flex>
) )
); );
const Title = styled(Flex)` const Title = styled(Flex)`
color: ${s("text")}; color: ${s("text")};
flex-shrink: 1; flex-shrink: 1;
flex-grow: 1;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; 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; padding: 8px 4px;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
border-radius: 4px; border-radius: 4px;
margin: 8px; margin: 8px 0;
color: ${s("textTertiary")};
border: 0; border: 0;
background: none; background: none;
flex-shrink: 0; flex-shrink: 0;
@@ -81,6 +87,14 @@ const Wrapper = styled(Flex)<{ minHeight: number }>`
transition: background 100ms ease-in-out; transition: background 100ms ease-in-out;
background: ${s("sidebarActiveBackground")}; background: ${s("sidebarActiveBackground")};
} }
&:last-child {
margin-right: 8px;
}
&:first-child {
margin-left: 8px;
}
`; `;
export default HeaderButton; export default HeaderButton;

View File

@@ -33,7 +33,7 @@ const Text = styled.p<Props>`
: "inherit"}; : "inherit"};
font-weight: ${(props) => font-weight: ${(props) =>
props.weight === "bold" props.weight === "bold"
? "bold" ? 500
: props.weight === "normal" : props.weight === "normal"
? "normal" ? "normal"
: "inherit"}; : "inherit"};

View File

@@ -10,6 +10,7 @@ import Comment from "~/models/Comment";
import Document from "~/models/Document"; import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation"; import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group"; import Group from "~/models/Group";
import Notification from "~/models/Notification";
import Pin from "~/models/Pin"; import Pin from "~/models/Pin";
import Star from "~/models/Star"; import Star from "~/models/Star";
import Subscription from "~/models/Subscription"; import Subscription from "~/models/Subscription";
@@ -89,6 +90,7 @@ class WebsocketProvider extends React.Component<Props> {
views, views,
subscriptions, subscriptions,
fileOperations, fileOperations,
notifications,
} = this.props; } = this.props;
if (!auth.token) { if (!auth.token) {
return; return;
@@ -323,6 +325,20 @@ class WebsocketProvider extends React.Component<Props> {
auth.team?.updateFromJson(event); auth.team?.updateFromJson(event);
}); });
this.socket.on(
"notifications.create",
(event: PartialWithId<Notification>) => {
notifications.add(event);
}
);
this.socket.on(
"notifications.update",
(event: PartialWithId<Notification>) => {
notifications.add(event);
}
);
this.socket.on("pins.create", (event: PartialWithId<Pin>) => { this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
pins.add(event); pins.add(event);
}); });

View File

@@ -7,5 +7,9 @@ export default function useFocusedComment() {
const location = useLocation<{ commentId?: string }>(); const location = useLocation<{ commentId?: string }>();
const query = useQuery(); const query = useQuery();
const focusedCommentId = location.state?.commentId || query.get("commentId"); 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;
} }

View File

@@ -35,7 +35,7 @@ import GoogleIcon from "~/components/Icons/GoogleIcon";
import ZapierIcon from "~/components/Icons/ZapierIcon"; import ZapierIcon from "~/components/Icons/ZapierIcon";
import PluginLoader from "~/utils/PluginLoader"; import PluginLoader from "~/utils/PluginLoader";
import isCloudHosted from "~/utils/isCloudHosted"; import isCloudHosted from "~/utils/isCloudHosted";
import { accountPreferencesPath } from "~/utils/routeHelpers"; import { settingsPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam"; import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy"; import usePolicy from "./usePolicy";
@@ -57,7 +57,7 @@ const useSettingsConfig = () => {
const items: ConfigItem[] = [ const items: ConfigItem[] = [
{ {
name: t("Profile"), name: t("Profile"),
path: "/settings", path: settingsPath(),
component: Profile, component: Profile,
enabled: true, enabled: true,
group: t("Account"), group: t("Account"),
@@ -65,7 +65,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Preferences"), name: t("Preferences"),
path: accountPreferencesPath(), path: settingsPath("preferences"),
component: Preferences, component: Preferences,
enabled: true, enabled: true,
group: t("Account"), group: t("Account"),
@@ -73,7 +73,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Notifications"), name: t("Notifications"),
path: "/settings/notifications", path: settingsPath("notifications"),
component: Notifications, component: Notifications,
enabled: true, enabled: true,
group: t("Account"), group: t("Account"),
@@ -81,7 +81,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("API Tokens"), name: t("API Tokens"),
path: "/settings/tokens", path: settingsPath("tokens"),
component: ApiKeys, component: ApiKeys,
enabled: can.createApiKey, enabled: can.createApiKey,
group: t("Account"), group: t("Account"),
@@ -90,7 +90,7 @@ const useSettingsConfig = () => {
// Team group // Team group
{ {
name: t("Details"), name: t("Details"),
path: "/settings/details", path: settingsPath("details"),
component: Details, component: Details,
enabled: can.update, enabled: can.update,
group: t("Workspace"), group: t("Workspace"),
@@ -98,7 +98,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Security"), name: t("Security"),
path: "/settings/security", path: settingsPath("security"),
component: Security, component: Security,
enabled: can.update, enabled: can.update,
group: t("Workspace"), group: t("Workspace"),
@@ -106,7 +106,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Features"), name: t("Features"),
path: "/settings/features", path: settingsPath("features"),
component: Features, component: Features,
enabled: can.update, enabled: can.update,
group: t("Workspace"), group: t("Workspace"),
@@ -114,7 +114,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Members"), name: t("Members"),
path: "/settings/members", path: settingsPath("members"),
component: Members, component: Members,
enabled: true, enabled: true,
group: t("Workspace"), group: t("Workspace"),
@@ -122,7 +122,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Groups"), name: t("Groups"),
path: "/settings/groups", path: settingsPath("groups"),
component: Groups, component: Groups,
enabled: true, enabled: true,
group: t("Workspace"), group: t("Workspace"),
@@ -130,7 +130,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Shared Links"), name: t("Shared Links"),
path: "/settings/shares", path: settingsPath("shares"),
component: Shares, component: Shares,
enabled: true, enabled: true,
group: t("Workspace"), group: t("Workspace"),
@@ -138,7 +138,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Import"), name: t("Import"),
path: "/settings/import", path: settingsPath("import"),
component: Import, component: Import,
enabled: can.createImport, enabled: can.createImport,
group: t("Workspace"), group: t("Workspace"),
@@ -146,7 +146,7 @@ const useSettingsConfig = () => {
}, },
{ {
name: t("Export"), name: t("Export"),
path: "/settings/export", path: settingsPath("export"),
component: Export, component: Export,
enabled: can.createExport, enabled: can.createExport,
group: t("Workspace"), group: t("Workspace"),

127
app/models/Notification.ts Normal file
View File

@@ -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;

View File

@@ -7,7 +7,7 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Text from "~/components/Text"; import Text from "~/components/Text";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
import { groupSettingsPath } from "~/utils/routeHelpers"; import { settingsPath } from "~/utils/routeHelpers";
type Props = { type Props = {
group: Group; group: Group;
@@ -26,7 +26,7 @@ function GroupDelete({ group, onSubmit }: Props) {
try { try {
await group.delete(); await group.delete();
history.push(groupSettingsPath()); history.push(settingsPath("groups"));
onSubmit(); onSubmit();
} catch (err) { } catch (err) {
showToast(err.message, { showToast(err.message, {

View File

@@ -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<Notification> {
actions = [RPCAction.List, RPCAction.Update];
constructor(rootStore: RootStore) {
super(rootStore, Notification);
}
@action
fetchPage = async (
options: PaginationParams | undefined
): Promise<Notification[]> => {
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;
}
);
}
}

View File

@@ -13,6 +13,7 @@ import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupsStore from "./GroupsStore"; import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore"; import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore"; import MembershipsStore from "./MembershipsStore";
import NotificationsStore from "./NotificationsStore";
import PinsStore from "./PinsStore"; import PinsStore from "./PinsStore";
import PoliciesStore from "./PoliciesStore"; import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore"; import RevisionsStore from "./RevisionsStore";
@@ -40,6 +41,7 @@ export default class RootStore {
groupMemberships: GroupMembershipsStore; groupMemberships: GroupMembershipsStore;
integrations: IntegrationsStore; integrations: IntegrationsStore;
memberships: MembershipsStore; memberships: MembershipsStore;
notifications: NotificationsStore;
presence: DocumentPresenceStore; presence: DocumentPresenceStore;
pins: PinsStore; pins: PinsStore;
policies: PoliciesStore; policies: PoliciesStore;
@@ -71,6 +73,7 @@ export default class RootStore {
this.groupMemberships = new GroupMembershipsStore(this); this.groupMemberships = new GroupMembershipsStore(this);
this.integrations = new IntegrationsStore(this); this.integrations = new IntegrationsStore(this);
this.memberships = new MembershipsStore(this); this.memberships = new MembershipsStore(this);
this.notifications = new NotificationsStore(this);
this.pins = new PinsStore(this); this.pins = new PinsStore(this);
this.presence = new DocumentPresenceStore(); this.presence = new DocumentPresenceStore();
this.revisions = new RevisionsStore(this); this.revisions = new RevisionsStore(this);

View File

@@ -1,4 +1,4 @@
import { sharedDocumentPath, accountPreferencesPath } from "./routeHelpers"; import { sharedDocumentPath } from "./routeHelpers";
describe("#sharedDocumentPath", () => { describe("#sharedDocumentPath", () => {
test("should return share path for a document", () => { 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");
});
});

View File

@@ -23,24 +23,8 @@ export function trashPath(): string {
return "/trash"; return "/trash";
} }
export function settingsPath(): string { export function settingsPath(section?: string): string {
return "/settings"; return "/settings" + (section ? `/${section}` : "");
}
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 commentPath(document: Document, comment: Comment): string { export function commentPath(document: Document, comment: Comment): string {

View File

@@ -139,7 +139,7 @@
"natural-sort": "^1.0.0", "natural-sort": "^1.0.0",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"nodemailer": "^6.9.1", "nodemailer": "^6.9.1",
"outline-icons": "^2.1.0", "outline-icons": "^2.2.0",
"oy-vey": "^0.12.0", "oy-vey": "^0.12.0",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0", "passport-google-oauth2": "^0.2.0",

View File

@@ -1,4 +1,3 @@
import crypto from "crypto";
import { t } from "i18next"; import { t } from "i18next";
import Router from "koa-router"; import Router from "koa-router";
import { escapeRegExp } from "lodash"; import { escapeRegExp } from "lodash";
@@ -18,6 +17,7 @@ import {
} from "@server/models"; } from "@server/models";
import SearchHelper from "@server/models/helpers/SearchHelper"; import SearchHelper from "@server/models/helpers/SearchHelper";
import { APIContext } from "@server/types"; import { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto";
import { opts } from "@server/utils/i18n"; import { opts } from "@server/utils/i18n";
import { assertPresent } from "@server/validation"; import { assertPresent } from "@server/validation";
import presentMessageAttachment from "../presenters/messageAttachment"; import presentMessageAttachment from "../presenters/messageAttachment";
@@ -32,13 +32,7 @@ function verifySlackToken(token: string) {
); );
} }
if ( if (!safeEqual(env.SLACK_VERIFICATION_TOKEN, token)) {
token.length !== env.SLACK_VERIFICATION_TOKEN.length ||
!crypto.timingSafeEqual(
Buffer.from(env.SLACK_VERIFICATION_TOKEN),
Buffer.from(token)
)
) {
throw AuthenticationError("Invalid token"); throw AuthenticationError("Invalid token");
} }
} }

View File

@@ -103,6 +103,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "subscriptions.delete": case "subscriptions.delete":
case "authenticationProviders.update": case "authenticationProviders.update":
case "notifications.create": case "notifications.create":
case "notifications.update":
// Ignored // Ignored
return; return;
case "users.create": case "users.create":

View File

@@ -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);
});
});

View File

@@ -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<Notification> {
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;
}

View File

@@ -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,
});
});
},
};

View File

@@ -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,
});
});
},
};

View File

@@ -1,3 +1,4 @@
import crypto from "crypto";
import type { SaveOptions } from "sequelize"; import type { SaveOptions } from "sequelize";
import { import {
Table, Table,
@@ -11,10 +12,12 @@ import {
DataType, DataType,
Default, Default,
AllowNull, AllowNull,
AfterSave,
Scopes, Scopes,
AfterCreate,
DefaultScope,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { NotificationEventType } from "@shared/types"; import { NotificationEventType } from "@shared/types";
import env from "@server/env";
import Collection from "./Collection"; import Collection from "./Collection";
import Comment from "./Comment"; import Comment from "./Comment";
import Document from "./Document"; import Document from "./Document";
@@ -32,10 +35,17 @@ import Fix from "./decorators/Fix";
}, },
], ],
}, },
withUser: { withDocument: {
include: [ 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({ @Table({
tableName: "notifications", tableName: "notifications",
modelName: "notification", modelName: "notification",
@@ -66,7 +89,11 @@ class Notification extends Model {
@AllowNull @AllowNull
@Column @Column
viewedAt: Date; viewedAt: Date | null;
@AllowNull
@Column
archivedAt: Date | null;
@CreatedAt @CreatedAt
createdAt: Date; createdAt: Date;
@@ -130,7 +157,7 @@ class Notification extends Model {
@Column(DataType.UUID) @Column(DataType.UUID)
teamId: string; teamId: string;
@AfterSave @AfterCreate
static async createEvent( static async createEvent(
model: Notification, model: Notification,
options: SaveOptions<Notification> options: SaveOptions<Notification>
@@ -150,6 +177,18 @@ class Notification extends Model {
} }
await Event.schedule(params); 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; export default Notification;

View File

@@ -7,6 +7,7 @@ import {
Comment, Comment,
Document, Document,
Group, Group,
Notification,
} from "@server/models"; } from "@server/models";
import { _abilities, _can, _cannot, _authorize } from "./cancan"; import { _abilities, _can, _cannot, _authorize } from "./cancan";
import "./apiKey"; import "./apiKey";
@@ -26,6 +27,7 @@ import "./user";
import "./team"; import "./team";
import "./group"; import "./group";
import "./webhookSubscription"; import "./webhookSubscription";
import "./notification";
type Policy = Record<string, boolean>; type Policy = Record<string, boolean>;
@@ -55,6 +57,7 @@ export function serialize(
| Document | Document
| User | User
| Group | Group
| Notification
| null | null
): Policy { ): Policy {
const output = {}; const output = {};

View File

@@ -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;
});

View File

@@ -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,
};
}

View File

@@ -13,6 +13,7 @@ import {
Star, Star,
Team, Team,
Subscription, Subscription,
Notification,
} from "@server/models"; } from "@server/models";
import { import {
presentComment, presentComment,
@@ -25,6 +26,7 @@ import {
presentSubscription, presentSubscription,
presentTeam, presentTeam,
} from "@server/presenters"; } from "@server/presenters";
import presentNotification from "@server/presenters/notification";
import { Event } from "../../types"; import { Event } from "../../types";
export default class WebsocketsProcessor { 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.create":
case "stars.update": { case "stars.update": {
const star = await Star.findByPk(event.modelId); const star = await Star.findByPk(event.modelId);

View File

@@ -1,10 +1,10 @@
import crypto from "crypto";
import Router from "koa-router"; import Router from "koa-router";
import env from "@server/env"; import env from "@server/env";
import { AuthenticationError } from "@server/errors"; import { AuthenticationError } from "@server/errors";
import validate from "@server/middlewares/validate"; import validate from "@server/middlewares/validate";
import tasks from "@server/queues/tasks"; import tasks from "@server/queues/tasks";
import { APIContext } from "@server/types"; import { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto";
import * as T from "./schema"; import * as T from "./schema";
const router = new Router(); const router = new Router();
@@ -13,13 +13,7 @@ const cronHandler = async (ctx: APIContext<T.CronSchemaReq>) => {
const token = (ctx.input.body.token ?? ctx.input.query.token) as string; const token = (ctx.input.body.token ?? ctx.input.query.token) as string;
const limit = ctx.input.body.limit ?? ctx.input.query.limit; const limit = ctx.input.body.limit ?? ctx.input.query.limit;
if ( if (!safeEqual(env.UTILS_SECRET, token)) {
token.length !== env.UTILS_SECRET.length ||
!crypto.timingSafeEqual(
Buffer.from(env.UTILS_SECRET),
Buffer.from(String(token))
)
) {
throw AuthenticationError("Invalid secret token"); throw AuthenticationError("Invalid secret token");
} }

View File

@@ -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);
});
});

View File

@@ -1,14 +1,28 @@
import Router from "koa-router"; import Router from "koa-router";
import { isNull, isUndefined } from "lodash";
import { WhereOptions, Op } from "sequelize";
import { NotificationEventType } from "@shared/types"; import { NotificationEventType } from "@shared/types";
import notificationUpdater from "@server/commands/notificationUpdater";
import env from "@server/env"; import env from "@server/env";
import { AuthenticationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction"; import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate"; 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 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 { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto";
import pagination from "../middlewares/pagination";
import * as T from "./schema"; import * as T from "./schema";
const router = new Router(); const router = new Router();
const pixel = Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
"base64"
);
const handleUnsubscribe = async ( const handleUnsubscribe = async (
ctx: APIContext<T.NotificationsUnsubscribeReq> ctx: APIContext<T.NotificationsUnsubscribeReq>
@@ -49,4 +63,145 @@ router.post(
handleUnsubscribe handleUnsubscribe
); );
router.post(
"notifications.list",
auth(),
pagination(),
validate(T.NotificationsListSchema),
transaction(),
async (ctx: APIContext<T.NotificationsListReq>) => {
const { eventType, archived } = ctx.input.body;
const user = ctx.state.auth.user;
let where: WhereOptions<Notification> = {
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<T.NotificationsPixelReq>) => {
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<T.NotificationsUpdateReq>) => {
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<T.NotificationsUpdateAllReq>) => {
const { viewedAt, archivedAt } = ctx.input.body;
const { user } = ctx.state.auth;
const values: { [x: string]: any } = {};
let where: WhereOptions<Notification> = {
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; export default router;

View File

@@ -1,8 +1,9 @@
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { z } from "zod"; import { z } from "zod";
import { NotificationEventType } from "@shared/types"; import { NotificationEventType } from "@shared/types";
import BaseSchema from "../BaseSchema";
export const NotificationSettingsCreateSchema = z.object({ export const NotificationSettingsCreateSchema = BaseSchema.extend({
body: z.object({ body: z.object({
eventType: z.nativeEnum(NotificationEventType), eventType: z.nativeEnum(NotificationEventType),
}), }),
@@ -12,7 +13,7 @@ export type NotificationSettingsCreateReq = z.infer<
typeof NotificationSettingsCreateSchema typeof NotificationSettingsCreateSchema
>; >;
export const NotificationSettingsDeleteSchema = z.object({ export const NotificationSettingsDeleteSchema = BaseSchema.extend({
body: z.object({ body: z.object({
eventType: z.nativeEnum(NotificationEventType), eventType: z.nativeEnum(NotificationEventType),
}), }),
@@ -22,8 +23,7 @@ export type NotificationSettingsDeleteReq = z.infer<
typeof NotificationSettingsDeleteSchema typeof NotificationSettingsDeleteSchema
>; >;
export const NotificationsUnsubscribeSchema = z export const NotificationsUnsubscribeSchema = BaseSchema.extend({
.object({
body: z.object({ body: z.object({
userId: z.string().uuid().optional(), userId: z.string().uuid().optional(),
token: z.string().optional(), token: z.string().optional(),
@@ -34,11 +34,49 @@ export const NotificationsUnsubscribeSchema = z
token: z.string().optional(), token: z.string().optional(),
eventType: z.nativeEnum(NotificationEventType).optional(), eventType: z.nativeEnum(NotificationEventType).optional(),
}), }),
}) }).refine((req) => !(isEmpty(req.body.userId) && isEmpty(req.query.userId)), {
.refine((req) => !(isEmpty(req.body.userId) && isEmpty(req.query.userId)), {
message: "userId is required", message: "userId is required",
}); });
export type NotificationsUnsubscribeReq = z.infer< export type NotificationsUnsubscribeReq = z.infer<
typeof NotificationsUnsubscribeSchema typeof NotificationsUnsubscribeSchema
>; >;
export const NotificationsListSchema = BaseSchema.extend({
body: z.object({
eventType: z.nativeEnum(NotificationEventType).nullish(),
archived: z.boolean().nullish(),
}),
});
export type NotificationsListReq = z.infer<typeof NotificationsListSchema>;
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<typeof NotificationsUpdateSchema>;
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<typeof NotificationsPixelSchema>;

View File

@@ -1,4 +1,3 @@
import crypto from "crypto";
import Router from "koa-router"; import Router from "koa-router";
import { Op, WhereOptions } from "sequelize"; import { Op, WhereOptions } from "sequelize";
import { UserPreference } from "@shared/types"; import { UserPreference } from "@shared/types";
@@ -23,6 +22,7 @@ import { can, authorize } from "@server/policies";
import { presentUser, presentPolicies } from "@server/presenters"; import { presentUser, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types"; import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { safeEqual } from "@server/utils/crypto";
import { import {
assertIn, assertIn,
assertSort, assertSort,
@@ -469,14 +469,7 @@ router.post(
if ((!id || id === actor.id) && emailEnabled) { if ((!id || id === actor.id) && emailEnabled) {
const deleteConfirmationCode = user.deleteConfirmationCode; const deleteConfirmationCode = user.deleteConfirmationCode;
if ( if (!safeEqual(code, deleteConfirmationCode)) {
!code ||
code.length !== deleteConfirmationCode.length ||
!crypto.timingSafeEqual(
Buffer.from(code),
Buffer.from(deleteConfirmationCode)
)
) {
throw ValidationError("The confirmation code was incorrect"); throw ValidationError("The confirmation code was incorrect");
} }
} }

View File

@@ -6,6 +6,7 @@ import {
FileOperationType, FileOperationType,
IntegrationService, IntegrationService,
IntegrationType, IntegrationType,
NotificationEventType,
} from "@shared/types"; } from "@shared/types";
import { import {
Share, Share,
@@ -26,6 +27,7 @@ import {
WebhookDelivery, WebhookDelivery,
ApiKey, ApiKey,
Subscription, Subscription,
Notification,
} from "@server/models"; } from "@server/models";
let count = 1; let count = 1;
@@ -493,3 +495,25 @@ export async function buildWebhookDelivery(
return WebhookDelivery.create(overrides); return WebhookDelivery.create(overrides);
} }
export async function buildNotification(
overrides: Partial<Notification> = {}
): Promise<Notification> {
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);
}

View File

@@ -358,7 +358,7 @@ export type WebhookSubscriptionEvent = BaseEvent & {
}; };
export type NotificationEvent = BaseEvent & { export type NotificationEvent = BaseEvent & {
name: "notifications.create"; name: "notifications.create" | "notifications.update";
modelId: string; modelId: string;
teamId: string; teamId: string;
userId: string; userId: string;

19
server/utils/crypto.ts Normal file
View File

@@ -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));
}

View File

@@ -209,6 +209,7 @@ export default class Link extends Mark {
const target = (event.target as HTMLElement)?.closest("a"); const target = (event.target as HTMLElement)?.closest("a");
if ( if (
target instanceof HTMLAnchorElement && target instanceof HTMLAnchorElement &&
this.editor.elementRef.current?.contains(target) &&
!target.className.includes("ProseMirror-widget") && !target.className.includes("ProseMirror-widget") &&
(!view.editable || (view.editable && !view.hasFocus())) (!view.editable || (view.editable && !view.hasFocus()))
) { ) {

View File

@@ -62,6 +62,7 @@
"Trash": "Trash", "Trash": "Trash",
"Settings": "Settings", "Settings": "Settings",
"Profile": "Profile", "Profile": "Profile",
"Notifications": "Notifications",
"Preferences": "Preferences", "Preferences": "Preferences",
"API documentation": "API documentation", "API documentation": "API documentation",
"Send us feedback": "Send us feedback", "Send us feedback": "Send us feedback",
@@ -70,6 +71,7 @@
"Keyboard shortcuts": "Keyboard shortcuts", "Keyboard shortcuts": "Keyboard shortcuts",
"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",
"Restore revision": "Restore revision", "Restore revision": "Restore revision",
"Copy link": "Copy link", "Copy link": "Copy link",
"Link copied": "Link copied", "Link copied": "Link copied",
@@ -90,6 +92,7 @@
"Document": "Document", "Document": "Document",
"Revision": "Revision", "Revision": "Revision",
"Navigation": "Navigation", "Navigation": "Navigation",
"Notification": "Notification",
"People": "People", "People": "People",
"Workspace": "Workspace", "Workspace": "Workspace",
"Recent searches": "Recent searches", "Recent searches": "Recent searches",
@@ -202,6 +205,8 @@
"Sorry, an error occurred.": "Sorry, an error occurred.", "Sorry, an error occurred.": "Sorry, an error occurred.",
"Click to retry": "Click to retry", "Click to retry": "Click to retry",
"Back": "Back", "Back": "Back",
"Mark all as read": "Mark all as read",
"No notifications yet": "No notifications yet",
"Documents": "Documents", "Documents": "Documents",
"Results": "Results", "Results": "Results",
"No results for {{query}}": "No results for {{query}}", "No results for {{query}}": "No results for {{query}}",
@@ -312,7 +317,6 @@
"Outdent": "Outdent", "Outdent": "Outdent",
"Could not import file": "Could not import file", "Could not import file": "Could not import file",
"Account": "Account", "Account": "Account",
"Notifications": "Notifications",
"API Tokens": "API Tokens", "API Tokens": "API Tokens",
"Details": "Details", "Details": "Details",
"Security": "Security", "Security": "Security",
@@ -370,6 +374,11 @@
"Resend invite": "Resend invite", "Resend invite": "Resend invite",
"Revoke invite": "Revoke invite", "Revoke invite": "Revoke invite",
"Activate account": "Activate account", "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", "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\".", "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.", "The document archive is empty at the moment.": "The document archive is empty at the moment.",
@@ -454,7 +463,6 @@
"Cancel": "Cancel", "Cancel": "Cancel",
"No comments yet": "No comments yet", "No comments yet": "No comments yet",
"Error updating comment": "Error updating comment", "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?", "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": "{{ count }} comment",
"{{ count }} comment_plural": "{{ count }} comments", "{{ count }} comment_plural": "{{ count }} comments",

View File

@@ -171,6 +171,7 @@ export type CollectionSort = {
export enum NotificationEventType { export enum NotificationEventType {
PublishDocument = "documents.publish", PublishDocument = "documents.publish",
UpdateDocument = "documents.update", UpdateDocument = "documents.update",
CreateRevision = "revisions.create",
CreateCollection = "collections.create", CreateCollection = "collections.create",
CreateComment = "comments.create", CreateComment = "comments.create",
MentionedInDocument = "documents.mentioned", MentionedInDocument = "documents.mentioned",

View File

@@ -10068,10 +10068,10 @@ os-tmpdir@~1.0.2:
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
outline-icons@^2.1.0: outline-icons@^2.2.0:
version "2.1.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.1.0.tgz#4f920378503a4f0ec7885e09d4f9e095be56f15e" resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-2.2.0.tgz#0ca59aa92da9364c1f1ed01e24858e9c034c6661"
integrity sha512-ifkCjttZZ9ugEWbVPWa/oerOCEkNGhNKsiY2LVHIr7x/KLsoaBhQyiLHT7pp9F0E00tlVXW4YuUNk/bTepavOw== integrity sha512-9QjFdxoCGGFz2RwsXYz2XLrHhS/qwH5tTq/tGG8hObaH4uD/0UDfK/80WY6aTBRoyGqZm3/gwRNl+lR2rELE2g==
oy-vey@^0.12.0: oy-vey@^0.12.0:
version "0.12.0" version "0.12.0"