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 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: <SettingsIcon />,
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: <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({
@@ -123,7 +130,7 @@ export const navigateToAccountPreferences = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(accountPreferencesPath()),
perform: () => history.push(settingsPath("preferences")),
});
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 { 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,

View File

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

View File

@@ -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 <button {...rest} ref={ref} />;
}

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,8 @@ const Flex = styled.div<{
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
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")};
min-height: 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 { 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<Props> = ({
children,
shrink,
width = 380,
scrollable = true,
flex,
mobilePosition,
...rest
}) => {
const isMobile = useMobile();
@@ -25,38 +31,67 @@ const Popover: React.FC<Props> = ({
if (isMobile) {
return (
<Dialog {...rest} modal>
<Contents $shrink={shrink}>{children}</Contents>
<Contents
$shrink={shrink}
$scrollable={scrollable}
$flex={flex}
$mobilePosition={mobilePosition}
>
{children}
</Contents>
</Dialog>
);
}
return (
<ReakitPopover {...rest}>
<Contents $shrink={shrink} $width={width}>
<Contents
$shrink={shrink}
$width={width}
$scrollable={scrollable}
$flex={flex}
>
{children}
</Contents>
</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;
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;

View File

@@ -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<HTMLDivElement, Props>(
showBorder={false}
/>
}
/>
>
<NotificationsButton>
{(rest: HeaderButtonProps) => (
<HeaderButton
{...rest}
image={<BadgedNotificationIcon />}
/>
)}
</NotificationsButton>
</HeaderButton>
)}
</AccountMenu>
)}
@@ -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)`
margin-left: 4px;
`;

View File

@@ -5,7 +5,7 @@ import { s } from "@shared/styles";
import Flex from "~/components/Flex";
import { undraggableOnDesktop } from "~/styles";
export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & {
export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
title: React.ReactNode;
image: React.ReactNode;
minHeight?: number;
@@ -13,6 +13,7 @@ export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & {
showDisclosure?: boolean;
showMoreMenu?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
children?: React.ReactNode;
};
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
@@ -23,44 +24,49 @@ const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
image,
title,
minHeight = 0,
children,
...rest
}: HeaderButtonProps,
ref
) => (
<Wrapper
role="button"
justify="space-between"
align="center"
as="button"
minHeight={minHeight}
{...rest}
ref={ref}
>
<Title gap={6} align="center">
{image}
{title}
</Title>
{showDisclosure && <ExpandedIcon />}
{showMoreMenu && <MoreIcon />}
</Wrapper>
<Flex justify="space-between" align="center" shrink={false}>
<Button
{...rest}
minHeight={minHeight}
as="button"
ref={ref}
role="button"
>
<Title gap={8} align="center">
{image}
{title}
</Title>
{showDisclosure && <ExpandedIcon />}
{showMoreMenu && <MoreIcon />}
</Button>
{children}
</Flex>
)
);
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;

View File

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

View File

@@ -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<Props> {
views,
subscriptions,
fileOperations,
notifications,
} = this.props;
if (!auth.token) {
return;
@@ -323,6 +325,20 @@ class WebsocketProvider extends React.Component<Props> {
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>) => {
pins.add(event);
});

View File

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

View File

@@ -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"),

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 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, {

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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<string, boolean>;
@@ -55,6 +57,7 @@ export function serialize(
| Document
| User
| Group
| Notification
| null
): Policy {
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,
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);

View File

@@ -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<T.CronSchemaReq>) => {
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");
}

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 { 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<T.NotificationsUnsubscribeReq>
@@ -49,4 +63,145 @@ router.post(
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;

View File

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

View File

@@ -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<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 & {
name: "notifications.create";
name: "notifications.create" | "notifications.update";
modelId: string;
teamId: 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");
if (
target instanceof HTMLAnchorElement &&
this.editor.elementRef.current?.contains(target) &&
!target.className.includes("ProseMirror-widget") &&
(!view.editable || (view.editable && !view.hasFocus()))
) {

View File

@@ -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",

View File

@@ -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",

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"
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"