mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 15:34:05 +08:00
feat: watchers notification and email preferences
This commit is contained in:
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,116 @@
|
||||
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/index";
|
||||
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.user_mention",
|
||||
dtoField: "notificationPageUserMention",
|
||||
label: "Page mentions",
|
||||
description: "Get notified when someone mentions you on a page.",
|
||||
},
|
||||
{
|
||||
key: "comment.user_mention",
|
||||
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.user_mention'?: boolean;
|
||||
'comment.user_mention'?: 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user