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 {