feat: page update notifications (#2074)

* feat: watchers notification and email preferences

* fix: email copy

* digests

* clean up

* fix

* clean up

* move backlinks queue-up to history processor

* fix

* fix keys

* feat: group notifications

* filter

* adjust email digest window
This commit is contained in:
Philip Okugbe
2026-03-31 16:03:59 +01:00
committed by GitHub
parent c180d0e487
commit 879aa2c3d8
39 changed files with 983 additions and 73 deletions
@@ -294,6 +294,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
w={popupWidth}
scrollbars={"y"}
scrollbarSize={6}
overscrollBehavior={"contain"}
styles={{ content: { minWidth: 0 } }}
>
{renderItems?.map((item, index) => {
@@ -87,7 +87,13 @@ const CommandList = ({
return flatItems.length > 0 ? (
<Paper id="slash-command" shadow="md" p="xs" withBorder>
<ScrollArea viewportRef={viewportRef} h={350} w={270} scrollbarSize={8}>
<ScrollArea
viewportRef={viewportRef}
h={350}
w={270}
scrollbarSize={8}
overscrollBehavior="contain"
>
{Object.entries(items).map(([category, categoryItems]) => (
<div key={category}>
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
@@ -103,10 +109,7 @@ const CommandList = ({
})}
>
<Group>
<ActionIcon
variant="default"
component="div"
>
<ActionIcon variant="default" component="div">
<item.icon size={18} />
</ActionIcon>
@@ -49,7 +49,7 @@ const renderItems = () => {
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.zIndex = "199";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
@@ -49,6 +49,8 @@ export function NotificationItem({
return notification.data?.role === "writer"
? "<bold>{{name}}</bold> gave you edit access to a page"
: "<bold>{{name}}</bold> gave you view access to a page";
case "page.updated":
return "<bold>{{name}}</bold> updated a page";
default:
return "";
}
@@ -75,6 +77,7 @@ export function NotificationItem({
};
const handleMarkRead = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markReadIfNeeded();
};
@@ -3,17 +3,23 @@ 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 {
INotification,
NotificationFilter,
NotificationTab,
} from "../types/notification.types";
import { groupNotificationsByTime } from "../notification.utils";
import { useNotificationsQuery } from "../queries/notification-query";
import classes from "../notification.module.css";
type NotificationListProps = {
tab: NotificationTab;
filter: NotificationFilter;
onNavigate: () => void;
};
export function NotificationList({
tab,
filter,
onNavigate,
}: NotificationListProps) {
@@ -24,7 +30,7 @@ export function NotificationList({
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useNotificationsQuery();
} = useNotificationsQuery(tab as string);
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -6,6 +6,7 @@ import {
Menu,
Popover,
ScrollArea,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
@@ -18,15 +19,20 @@ import {
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { NotificationList } from "./notification-list";
import { NotificationFilter } from "../types/notification.types";
import {
NotificationFilter,
NotificationTab,
} from "../types/notification.types";
import {
useMarkAllReadMutation,
useUnreadCountQuery,
} from "../queries/notification-query";
import classes from "../notification.module.css";
export function NotificationPopover() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [tab, setTab] = useState<NotificationTab>("direct");
const [filter, setFilter] = useState<NotificationFilter>("all");
const { data: unreadData } = useUnreadCountQuery();
@@ -125,13 +131,27 @@ export function NotificationPopover() {
</Group>
</Group>
<Tabs
value={tab}
onChange={(value) => setTab(value as NotificationTab)}
variant="default"
color="dark"
>
<Tabs.List px="md">
<Tabs.Tab value="direct">{t("Direct")}</Tabs.Tab>
<Tabs.Tab value="updates">{t("Updates")}</Tabs.Tab>
</Tabs.List>
</Tabs>
<ScrollArea.Autosize
mah={500}
type="auto"
offsetScrollbars
scrollbarSize={6}
style={{ overscrollBehavior: "contain" }}
>
<NotificationList
tab={tab}
filter={filter}
onNavigate={() => setOpened(false)}
/>
@@ -13,3 +13,4 @@
.divider {
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
@@ -15,10 +15,10 @@ import {
export const NOTIFICATION_KEY = ["notifications"];
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
export function useNotificationsQuery() {
export function useNotificationsQuery(type?: string) {
return useInfiniteQuery({
queryKey: NOTIFICATION_KEY,
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
queryKey: [...NOTIFICATION_KEY, type],
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam, type }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
@@ -5,6 +5,7 @@ import { IPagination } from "@/lib/types";
export async function getNotifications(params: {
limit?: number;
cursor?: string;
type?: string;
}): Promise<IPagination<INotification>> {
const req = await api.post<IPagination<INotification>>(
"/notifications",
@@ -3,7 +3,8 @@ export type NotificationType =
| "comment.created"
| "comment.resolved"
| "page.user_mention"
| "page.permission_granted";
| "page.permission_granted"
| "page.updated";
export type INotification = {
id: string;
@@ -38,3 +39,5 @@ export type INotification = {
};
export type NotificationFilter = "all" | "unread";
export type NotificationTab = "direct" | "updates" | "all";
@@ -3,6 +3,8 @@ import {
IconArrowRight,
IconArrowsHorizontal,
IconDots,
IconEye,
IconEyeOff,
IconFileExport,
IconHistory,
IconLink,
@@ -40,6 +42,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
useWatchStatusQuery,
useWatchPageMutation,
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -123,6 +130,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const { data: watchStatus } = useWatchStatusQuery(page?.id);
const watchPage = useWatchPageMutation();
const unwatchPage = useUnwatchPageMutation();
const handleCopyLink = () => {
const pageUrl =
@@ -185,6 +195,23 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
>
{t("Copy as Markdown")}
</Menu.Item>
{watchStatus?.watching ? (
<Menu.Item
leftSection={<IconEyeOff size={16} />}
onClick={() => unwatchPage.mutate(page.id)}
>
{t("Stop watching")}
</Menu.Item>
) : (
<Menu.Item
leftSection={<IconEye size={16} />}
onClick={() => watchPage.mutate(page.id)}
>
{t("Watch page")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
@@ -0,0 +1,43 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
watchPage,
unwatchPage,
getWatchStatus,
} from "@/features/page/services/watcher-service";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const WATCHER_KEY = "watcher";
export function useWatchStatusQuery(pageId: string) {
return useQuery({
queryKey: [WATCHER_KEY, pageId],
queryFn: () => getWatchStatus(pageId),
enabled: !!pageId,
staleTime: 60_000,
});
}
export function useWatchPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => watchPage(pageId),
onSuccess: (_data, pageId) => {
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: true });
notifications.show({ message: t("You are now watching this page") });
},
});
}
export function useUnwatchPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => unwatchPage(pageId),
onSuccess: (_data, pageId) => {
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: false });
notifications.show({ message: t("You are no longer watching this page") });
},
});
}
@@ -0,0 +1,16 @@
import api from "@/lib/api-client";
export async function watchPage(pageId: string): Promise<{ watching: boolean }> {
const req = await api.post<{ watching: boolean }>("/pages/watch", { pageId });
return req.data;
}
export async function unwatchPage(pageId: string): Promise<{ watching: boolean }> {
const req = await api.post<{ watching: boolean }>("/pages/unwatch", { pageId });
return req.data;
}
export async function getWatchStatus(pageId: string): Promise<{ watching: boolean }> {
const req = await api.post<{ watching: boolean }>("/pages/watch-status", { pageId });
return req.data;
}
@@ -0,0 +1,117 @@
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import { IUser, IUserSettings } from "@/features/user/types/user.types.ts";
import { Switch, Text, Title, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
type NotificationKey = keyof NonNullable<IUserSettings["notifications"]>;
const notificationItems: {
key: NotificationKey;
dtoField: keyof IUser;
label: string;
description: string;
}[] = [
{
key: "page.updated",
dtoField: "notificationPageUpdates",
label: "Page updates",
description: "Get notified when pages you watch are updated.",
},
{
key: "page.userMention",
dtoField: "notificationPageUserMention",
label: "Page mentions",
description: "Get notified when someone mentions you on a page.",
},
{
key: "comment.userMention",
dtoField: "notificationCommentUserMention",
label: "Comment mentions",
description: "Get notified when someone mentions you in a comment.",
},
{
key: "comment.created",
dtoField: "notificationCommentCreated",
label: "New comments",
description:
"Get notified about new comments on threads you participate in.",
},
{
key: "comment.resolved",
dtoField: "notificationCommentResolved",
label: "Resolved comments",
description: "Get notified when your comment is resolved.",
},
];
function NotificationToggle({
settingKey,
dtoField,
label,
description,
}: {
settingKey: NotificationKey;
dtoField: keyof IUser;
label: string;
description: string;
}) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [checked, setChecked] = useState(
user.settings?.notifications?.[settingKey] !== false,
);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
setChecked(value);
try {
const updatedUser = await updateUser({ [dtoField]: value } as any);
setUser(updatedUser);
} catch {
setChecked(!value);
}
};
return (
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t(label)}</Text>
<Text size="sm" c="dimmed">
{t(description)}
</Text>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Switch checked={checked} onChange={handleChange} />
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
);
}
export default function NotificationPref() {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Title order={5}>{t("Email notifications")}</Title>
{notificationItems.map((item) => (
<NotificationToggle
key={item.key}
settingKey={item.key}
dtoField={item.dtoField}
label={item.label}
description={item.description}
/>
))}
</Stack>
);
}
@@ -20,6 +20,11 @@ export interface IUser {
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
notificationPageUpdates: boolean; // used for update
notificationPageUserMention: boolean; // used for update
notificationCommentUserMention: boolean; // used for update
notificationCommentCreated: boolean; // used for update
notificationCommentResolved: boolean; // used for update
hasGeneratedPassword?: boolean;
}
@@ -33,6 +38,13 @@ export interface IUserSettings {
fullPageWidth: boolean;
pageEditMode: string;
};
notifications?: {
"page.updated"?: boolean;
"page.userMention"?: boolean;
"comment.userMention"?: boolean;
"comment.created"?: boolean;
"comment.resolved"?: boolean;
};
}
export enum PageEditMode {
@@ -3,6 +3,7 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
@@ -33,6 +34,10 @@ export default function AccountPreferences() {
<Divider my={"md"} />
<PageEditPref />
<Divider my={"md"} />
<NotificationPref />
</>
);
}