feat: notifications (#1947)

* feat: notifications
* feat: watchers

* improvements

* handle page move for watchers

* make watchers non-blocking

* more
This commit is contained in:
Philip Okugbe
2026-02-14 20:00:38 -08:00
committed by GitHub
parent e0ab9d9b5e
commit 05b3c65b0f
80 changed files with 3071 additions and 238 deletions
@@ -590,5 +590,21 @@
"No answer available": "No answer available",
"Background color": "Background color",
"Highlight color": "Highlight color",
"Remove color": "Remove color"
"Remove color": "Remove color",
"Notifications": "Notifications",
"No notifications": "No notifications",
"No unread notifications": "No unread notifications",
"All notifications": "All notifications",
"Unread only": "Unread only",
"Mark all as read": "Mark all as read",
"Mark as read": "Mark as read",
"More options": "More options",
"mentioned you in a comment": "mentioned you in a comment",
"commented on a page": "commented on a page",
"resolved a comment": "resolved a comment",
"mentioned you on a page": "mentioned you on a page",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
"Older": "Older"
}
@@ -22,6 +22,7 @@ import {
searchSpotlight,
shareSearchSpotlight,
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@@ -97,6 +98,7 @@ export function AppHeader() {
</div>
<Group px={"xl"} wrap="nowrap">
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
variant="light"
@@ -31,6 +31,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => {
if (document.querySelector("#mention")) return;
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
@@ -105,6 +106,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
position={{ bottom: 500, right: 50 }}
withCloseButton
withBorder
data-comment-dialog
>
<Stack gap={2}>
<Group>
@@ -1,14 +1,15 @@
import { EditorContent, useEditor } from "@tiptap/react";
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
interface CommentEditorProps {
defaultContent?: any;
@@ -39,13 +40,29 @@ const CommentEditor = forwardRef(
StarterKit.configure({
gapcursor: false,
dropcursor: false,
link: false,
}),
Placeholder.configure({
placeholder: placeholder || t("Reply..."),
}),
Underline,
Link,
LinkExtension,
EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
],
editorProps: {
handleDOMEvents: {
@@ -60,7 +77,8 @@ const CommentEditor = forwardRef(
].includes(event.key)
) {
const emojiCommand = document.querySelector("#emoji-command");
if (emojiCommand) {
const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
return true;
}
}
@@ -108,7 +126,11 @@ const CommentEditor = forwardRef(
}));
return (
<div ref={focusRef} className={classes.commentEditor}>
<div
ref={focusRef}
className={classes.commentEditor}
data-editable={editable || undefined}
>
<EditorContent
editor={commentEditor}
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
@@ -32,11 +32,14 @@
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
max-height: 20vh;
padding-left: 6px;
padding-right: 6px;
margin-top: 10px;
margin-bottom: 2px;
}
&[data-editable] .ProseMirror :global(.ProseMirror){
max-height: 50vh;
overflow: hidden auto;
}
@@ -10,6 +10,7 @@ import React, {
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import {
ActionIcon,
Divider,
Group,
Paper,
ScrollArea,
@@ -51,6 +52,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query,
@@ -58,6 +60,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
includePages: true,
spaceId: space.id,
limit: 10,
preload: true,
});
const createPageItem = (label: string) : MentionSuggestionItem => {
@@ -102,7 +105,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})),
);
}
items.push(createPageItem(props.query));
if (!isInCommentContext && props.query) {
items.push(createPageItem(props.query));
}
setRenderItems(items);
// update editor storage
@@ -250,35 +255,51 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
}
}
// if no results and enter what to do?
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const popupWidth = isInCommentContext ? 280 : 320;
if (renderItems.length === 0) {
return (
<Paper shadow="md" p="xs" withBorder>
{ t("No results") }
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
<Text c="dimmed" size="sm" px="sm">
{ t("No results") }
</Text>
</Paper>
);
}
const hasUsers = renderItems.some((item) => item.entityType === "user");
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
return (
<Paper id="mention" shadow="md" p="xs" withBorder>
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
w={320}
scrollbarSize={8}
w={popupWidth}
scrollbarSize={6}
>
{renderItems?.map((item, index) => {
if (item.entityType === "header") {
const isFirst = index === 0;
return (
<div key={`${item.label}-${index}`}>
<Text c="dimmed" mb={4} tt="uppercase">
{!isFirst && <Divider my={6} />}
<Text
c="dimmed"
size="xs"
fw={500}
px="sm"
pt={isFirst ? 2 : 4}
pb={4}
tt="uppercase"
>
{item.label}
</Text>
</div>
@@ -292,8 +313,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
px="sm"
>
<Group>
<Group gap="sm">
<CustomAvatar
size={"sm"}
avatarUrl={item.avatarUrl}
@@ -308,7 +330,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
</Group>
</UnstyledButton>
);
} else if (item.entityType === "page") {
} else if (item.entityType === "page" && item.id !== null) {
return (
<UnstyledButton
data-item-index={index}
@@ -317,28 +339,24 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
px="sm"
>
<Group>
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="default"
variant="subtle"
component="div"
aria-label={item.label}
color="gray"
size="sm"
>
{item.icon || (
<ActionIcon
component="span"
variant="transparent"
color="gray"
size={18}
>
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
</ActionIcon>
<IconFileDescription size={18} stroke={1.5} />
)}
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{item.label}
</Text>
</div>
</Group>
@@ -348,6 +366,37 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
return null;
}
})}
{createPageItemData && !isInCommentContext && (
<>
{(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)}
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
})}
px="sm"
>
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="subtle"
component="div"
color="gray"
size="sm"
>
<IconPlus size={16} stroke={1.5} />
</ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{t("Create page")}: {createPageItemData.label}
</Text>
</div>
</Group>
</UnstyledButton>
</>
)}
</ScrollArea.Autosize>
</Paper>
);
@@ -17,8 +17,13 @@ const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
const destroy = () => {
if (outsideClickHandler) {
document.removeEventListener("pointerdown", outsideClickHandler);
outsideClickHandler = null;
}
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
@@ -45,8 +50,14 @@ const mentionRenderItems = () => {
return;
}
const editorDom = props.editor?.view?.dom;
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
const dialogEl = editorDom?.closest("[data-comment-dialog]");
const isInCommentContext = !!(asideEl || dialogEl);
// const isInCommentContext = !!asideEl;
component = new ReactRenderer(MentionList, {
props,
props: { ...props, isInCommentContext },
editor: props.editor,
});
@@ -59,6 +70,18 @@ const mentionRenderItems = () => {
const { element } = component;
document.body.appendChild(element);
outsideClickHandler = (e: MouseEvent) => {
const target = e.target as Node;
if (element && !element.contains(target)) {
destroy();
}
};
document.addEventListener("pointerdown", outsideClickHandler);
const shiftMiddleware = asideEl
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
: shift();
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
@@ -76,7 +99,7 @@ const mentionRenderItems = () => {
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
middleware: [offset(4), flip(), shiftMiddleware],
},
).then(({ x, y }) => {
Object.assign(element.style, {
@@ -31,14 +31,14 @@
.menuBtn {
width: 100%;
padding: 4px;
margin-bottom: 2px;
padding: 6px 4px;
margin-bottom: 1px;
color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-2);
background: var(--mantine-color-gray-1);
}
@mixin dark {
@@ -49,7 +49,7 @@
.selectedItem {
@mixin light {
background: var(--mantine-color-gray-2);
background: var(--mantine-color-gray-1);
}
@mixin dark {
@@ -7,6 +7,7 @@ export interface MentionListProps {
range: Range;
text: string;
editor: Editor;
isInCommentContext?: boolean;
}
export type MentionSuggestionItem =
@@ -0,0 +1,148 @@
import {
ActionIcon,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
IconCheck,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { formatRelativeTime } from "../notification.utils";
import classes from "../notification.module.css";
type NotificationItemProps = {
notification: INotification;
onNavigate: () => void;
};
export function NotificationItem({
notification,
onNavigate,
}: NotificationItemProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const markRead = useMarkReadMutation();
const [hovered, setHovered] = useState(false);
const isUnread = !notification.readAt;
const getNotificationMessage = (): string => {
switch (notification.type) {
case "comment.user_mention":
return t("mentioned you in a comment");
case "comment.created":
return t("commented on a page");
case "comment.resolved":
return t("resolved a comment");
case "page.user_mention":
return t("mentioned you on a page");
default:
return "";
}
};
const handleClick = () => {
if (notification.page && notification.space) {
if (isUnread) {
markRead.mutate([notification.id]);
}
navigate(
buildPageUrl(
notification.space.slug,
notification.page.slugId,
notification.page.title,
),
);
onNavigate();
}
};
const handleMarkRead = (e: React.MouseEvent) => {
e.stopPropagation();
if (isUnread) {
markRead.mutate([notification.id]);
}
};
return (
<UnstyledButton
onClick={handleClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
w="100%"
className={classes.notificationItem}
>
<Group wrap="nowrap" align="flex-start" gap="sm">
<CustomAvatar
avatarUrl={notification.actor?.avatarUrl}
name={notification.actor?.name || "?"}
size="sm"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={2}>
<Text span fw={600}>
{notification.actor?.name}
</Text>{" "}
{getNotificationMessage()}
</Text>
{notification.page && (
<Group gap={4} mt={2} wrap="nowrap">
{notification.page.icon ? (
<Text size="xs" style={{ flexShrink: 0 }}>
{notification.page.icon}
</Text>
) : (
<IconFileDescription
size={14}
stroke={1.5}
style={{ flexShrink: 0, color: "var(--mantine-color-dimmed)" }}
/>
)}
<Text size="xs" c="dimmed" lineClamp={1}>
{notification.page.title || t("Untitled")}
</Text>
</Group>
)}
</div>
<Group gap={4} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
{hovered && isUnread ? (
<Tooltip label={t("Mark as read")} withArrow>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleMarkRead}
>
<IconCheck size={14} />
</ActionIcon>
</Tooltip>
) : (
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{formatRelativeTime(notification.createdAt)}
</Text>
)}
{isUnread && (
<IconPointFilled
size={12}
color="var(--mantine-color-blue-filled)"
style={{ flexShrink: 0 }}
/>
)}
</Group>
</Group>
</UnstyledButton>
);
}
@@ -0,0 +1,115 @@
import { Center, Divider, Loader, Stack, Text } from "@mantine/core";
import { IconBellOff } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useEffect, useRef } from "react";
import { NotificationItem } from "./notification-item";
import { INotification, NotificationFilter } from "../types/notification.types";
import { groupNotificationsByTime } from "../notification.utils";
import { useNotificationsQuery } from "../queries/notification-query";
import classes from "../notification.module.css";
type NotificationListProps = {
filter: NotificationFilter;
onNavigate: () => void;
};
export function NotificationList({
filter,
onNavigate,
}: NotificationListProps) {
const { t } = useTranslation();
const {
data,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useNotificationsQuery();
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
const allNotifications =
data?.pages.flatMap((page) => page.items) ?? [];
const filtered =
filter === "unread"
? allNotifications.filter((n) => !n.readAt)
: allNotifications;
if (filtered.length === 0) {
return (
<Center py="xl">
<Stack align="center" gap="xs">
<IconBellOff size={32} stroke={1.5} color="var(--mantine-color-dimmed)" />
<Text size="sm" c="dimmed">
{filter === "unread"
? t("No unread notifications")
: t("No notifications")}
</Text>
</Stack>
</Center>
);
}
const timeGroupLabels = {
today: t("Today"),
yesterday: t("Yesterday"),
this_week: t("This week"),
older: t("Older"),
};
const groups = groupNotificationsByTime(filtered, timeGroupLabels);
return (
<Stack gap={0}>
{groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && <Divider className={classes.divider} />}
<Text size="xs" fw={600} c="dimmed" px="md" pt="sm" pb={4}>
{group.label}
</Text>
{group.notifications.map((notification: INotification) => (
<NotificationItem
key={notification.id}
notification={notification}
onNavigate={onNavigate}
/>
))}
</div>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</Stack>
);
}
@@ -0,0 +1,142 @@
import { useState } from "react";
import {
ActionIcon,
Group,
Indicator,
Menu,
Popover,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import {
IconBell,
IconCheck,
IconChecks,
IconDots,
IconFilter,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { NotificationList } from "./notification-list";
import { NotificationFilter } from "../types/notification.types";
import {
useMarkAllReadMutation,
useUnreadCountQuery,
} from "../queries/notification-query";
export function NotificationPopover() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const { data: unreadData } = useUnreadCountQuery();
const markAllRead = useMarkAllReadMutation();
const unreadCount = unreadData?.count ?? 0;
const handleMarkAllRead = () => {
markAllRead.mutate();
};
return (
<Popover
position="bottom-end"
shadow="lg"
opened={opened}
onChange={setOpened}
withArrow
>
<Popover.Target>
<Tooltip label={t("Notifications")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="sm"
onClick={() => setOpened((o) => !o)}
>
<Indicator
offset={5}
color="red"
withBorder
disabled={unreadCount === 0}
>
<IconBell size={20} />
</Indicator>
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown
p={0}
style={{ width: "min(420px, calc(100vw - 24px))" }}
>
<Group justify="space-between" px="md" py="sm">
<Text fw={600} size="sm">
{t("Notifications")}
</Text>
<Group gap={4}>
<Menu position="bottom-end" withArrow withinPortal={false}>
<Menu.Target>
<Tooltip label={t("Filter")} withArrow>
<ActionIcon variant="subtle" color="dark" size="sm">
<IconFilter size={16} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Filter")}</Menu.Label>
<Menu.Item
onClick={() => setFilter("all")}
rightSection={
filter === "all" ? <IconCheck size={14} /> : null
}
>
{t("All notifications")}
</Menu.Item>
<Menu.Item
onClick={() => setFilter("unread")}
rightSection={
filter === "unread" ? <IconCheck size={14} /> : null
}
>
{t("Unread only")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu position="bottom-end" withArrow withinPortal={false}>
<Menu.Target>
<Tooltip label={t("More options")} withArrow>
<ActionIcon variant="subtle" color="dark" size="sm">
<IconDots size={16} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconChecks size={16} />}
onClick={handleMarkAllRead}
disabled={unreadCount === 0}
>
{t("Mark all as read")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<ScrollArea.Autosize
mah={500}
type="auto"
offsetScrollbars
scrollbarSize={6}
>
<NotificationList
filter={filter}
onNavigate={() => setOpened(false)}
/>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useAtom } from "jotai";
import { useQueryClient } from "@tanstack/react-query";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import { NOTIFICATION_KEY } from "../queries/notification-query";
export function useNotificationSocket() {
const queryClient = useQueryClient();
const [socket] = useAtom(socketAtom);
useEffect(() => {
if (!socket) return;
const handler = () => {
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
};
socket.on("notification", handler);
return () => {
socket.off("notification", handler);
};
}, [socket, queryClient]);
}
@@ -0,0 +1,13 @@
.notificationItem {
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
}
.notificationItem:hover {
background-color: var(--mantine-color-default-hover);
}
.divider {
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
@@ -0,0 +1,75 @@
import { INotification } from "./types/notification.types";
export function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "now";
if (diffMin < 60) return `${diffMin}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
export function getTimeGroup(dateStr: string): TimeGroup {
const date = new Date(dateStr);
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const startOfYesterday = new Date(startOfToday);
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
const startOfWeek = new Date(startOfToday);
startOfWeek.setDate(startOfWeek.getDate() - 7);
if (date >= startOfToday) return "today";
if (date >= startOfYesterday) return "yesterday";
if (date >= startOfWeek) return "this_week";
return "older";
}
export type GroupedNotifications = {
key: TimeGroup;
label: string;
notifications: INotification[];
};
export function groupNotificationsByTime(
notifications: INotification[],
labels: Record<TimeGroup, string>,
): GroupedNotifications[] {
const groups: Record<TimeGroup, INotification[]> = {
today: [],
yesterday: [],
this_week: [],
older: [],
};
for (const notification of notifications) {
const group = getTimeGroup(notification.createdAt);
groups[group].push(notification);
}
const order: TimeGroup[] = ["today", "yesterday", "this_week", "older"];
return order
.filter((key) => groups[key].length > 0)
.map((key) => ({
key,
label: labels[key],
notifications: groups[key],
}));
}
@@ -0,0 +1,59 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getNotifications,
getUnreadCount,
markNotificationsRead,
markAllNotificationsRead,
} from "../services/notification-service";
export const NOTIFICATION_KEY = ["notifications"];
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
export function useNotificationsQuery() {
return useInfiniteQuery({
queryKey: NOTIFICATION_KEY,
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
staleTime: 0,
gcTime: 0,
placeholderData: keepPreviousData,
});
}
export function useUnreadCountQuery() {
return useQuery({
queryKey: UNREAD_COUNT_KEY,
queryFn: getUnreadCount,
});
}
export function useMarkReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (notificationIds: string[]) =>
markNotificationsRead(notificationIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
},
});
}
export function useMarkAllReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: markAllNotificationsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
},
});
}
@@ -0,0 +1,31 @@
import api from "@/lib/api-client";
import { INotification } from "../types/notification.types";
import { IPagination } from "@/lib/types";
export async function getNotifications(params: {
limit?: number;
cursor?: string;
}): Promise<IPagination<INotification>> {
const req = await api.post<IPagination<INotification>>(
"/notifications",
params,
);
return req.data;
}
export async function getUnreadCount(): Promise<{ count: number }> {
const req = await api.post<{ count: number }>(
"/notifications/unread-count",
);
return req.data;
}
export async function markNotificationsRead(
notificationIds: string[],
): Promise<void> {
await api.post("/notifications/mark-read", { notificationIds });
}
export async function markAllNotificationsRead(): Promise<void> {
await api.post("/notifications/mark-all-read");
}
@@ -0,0 +1,39 @@
export type NotificationType =
| "comment.user_mention"
| "comment.created"
| "comment.resolved"
| "page.user_mention";
export type INotification = {
id: string;
userId: string;
workspaceId: string;
type: NotificationType;
actorId: string | null;
pageId: string | null;
spaceId: string | null;
commentId: string | null;
data: Record<string, unknown> | null;
readAt: string | null;
emailedAt: string | null;
archivedAt: string | null;
createdAt: string;
actor: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
page: {
id: string;
title: string;
slugId: string;
icon: string | null;
} | null;
space: {
id: string;
name: string;
slug: string;
} | null;
};
export type NotificationFilter = "all" | "unread";
@@ -106,10 +106,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}
}, sizeRef);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId;
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
useEffect(() => {
setIsDataLoaded(false);
}, [spaceId]);
useEffect(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
@@ -130,12 +136,15 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
}
// same space; append only missing roots
setIsDataLoaded(true);
return mergeRootTrees(prev, treeData);
});
}
}, [pagesData, hasNextPage]);
}, [pagesData, hasNextPage, spaceId]);
useEffect(() => {
const effectSpaceId = spaceId;
const fetchData = async () => {
if (isDataLoaded && currentPage) {
// check if pageId node is present in the tree
@@ -149,6 +158,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
if (!currentPage.id) return;
const ancestors = await getPageBreadcrumbs(currentPage.id);
if (spaceIdRef.current !== effectSpaceId) return;
if (ancestors && ancestors?.length > 1) {
let flatTreeItems = [...buildTree(ancestors)];
@@ -176,22 +187,22 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
// Wait for all fetch operations to complete
Promise.all(fetchPromises).then(() => {
if (spaceIdRef.current !== effectSpaceId) return;
// build tree with children
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
// child of root page we're attaching the built ancestors to
const rootChild = ancestorsTree[0];
// attach built ancestors to tree
const updatedTree = appendNodeChildren(
data,
rootChild.id,
rootChild.children,
// attach built ancestors to tree using functional updater
// to avoid stale closure overwriting the current tree data
setData((currentData) =>
appendNodeChildren(currentData, rootChild.id, rootChild.children),
);
setData(updatedTree);
setTimeout(() => {
// focus on node and open all parents
treeApiRef.current.select(currentPage.id);
treeApiRef.current?.select(currentPage.id);
}, 100);
});
}
@@ -44,8 +44,8 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
return (
<Tooltip label={t("Search")} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
variant="subtle"
color="dark"
onClick={onSearch}
size="sm"
>
@@ -24,13 +24,14 @@ export function usePageSearchQuery(
}
export function useSearchSuggestionsQuery(
params: SearchSuggestionParams,
params: SearchSuggestionParams & { preload?: boolean },
): UseQueryResult<ISuggestionResult, Error> {
const { preload, ...queryParams } = params;
return useQuery({
queryKey: ["search-suggestion", params.query],
staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(params),
enabled: !!params.query,
queryFn: () => searchSuggestions(queryParams),
enabled: preload || !!params.query,
});
}
@@ -8,6 +8,7 @@ import { io } from "socket.io-client";
import { SOCKET_URL } from "@/features/websocket/types";
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
@@ -44,6 +45,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
useQuerySubscription();
useTreeSocket();
useNotificationSocket();
useEffect(() => {
if (data && data.user && data.workspace) {
+1 -1
View File
@@ -59,7 +59,7 @@ export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
"--input-error-size": theme.fontSizes.sm,
},
light: {
"--mantine-color-dark-light-color": "var(--mantine-color-default-color)",
"--mantine-color-dark-light-color": "#4e5359",
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
},
dark: {