mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: group notifications
This commit is contained in:
@@ -690,6 +690,8 @@
|
|||||||
"Get notified when your comment is resolved.": "Get notified when your comment is resolved.",
|
"Get notified when your comment is resolved.": "Get notified when your comment is resolved.",
|
||||||
"You are now watching this page": "You are now watching this page",
|
"You are now watching this page": "You are now watching this page",
|
||||||
"You are no longer watching this page": "You are no longer watching this page",
|
"You are no longer watching this page": "You are no longer watching this page",
|
||||||
|
"Direct": "Direct",
|
||||||
|
"Updates": "Updates",
|
||||||
"Today": "Today",
|
"Today": "Today",
|
||||||
"Yesterday": "Yesterday",
|
"Yesterday": "Yesterday",
|
||||||
"This week": "This week",
|
"This week": "This week",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
w={popupWidth}
|
w={popupWidth}
|
||||||
scrollbars={"y"}
|
scrollbars={"y"}
|
||||||
scrollbarSize={6}
|
scrollbarSize={6}
|
||||||
|
overscrollBehavior={"contain"}
|
||||||
styles={{ content: { minWidth: 0 } }}
|
styles={{ content: { minWidth: 0 } }}
|
||||||
>
|
>
|
||||||
{renderItems?.map((item, index) => {
|
{renderItems?.map((item, index) => {
|
||||||
|
|||||||
@@ -87,7 +87,13 @@ const CommandList = ({
|
|||||||
|
|
||||||
return flatItems.length > 0 ? (
|
return flatItems.length > 0 ? (
|
||||||
<Paper id="slash-command" shadow="md" p="xs" withBorder>
|
<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]) => (
|
{Object.entries(items).map(([category, categoryItems]) => (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
||||||
@@ -103,10 +109,7 @@ const CommandList = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<ActionIcon
|
<ActionIcon variant="default" component="div">
|
||||||
variant="default"
|
|
||||||
component="div"
|
|
||||||
>
|
|
||||||
<item.icon size={18} />
|
<item.icon size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const renderItems = () => {
|
|||||||
getReferenceClientRect = props.clientRect;
|
getReferenceClientRect = props.clientRect;
|
||||||
|
|
||||||
popup = document.createElement("div");
|
popup = document.createElement("div");
|
||||||
popup.style.zIndex = "9999";
|
popup.style.zIndex = "199";
|
||||||
popup.style.position = "absolute";
|
popup.style.position = "absolute";
|
||||||
popup.style.top = "0";
|
popup.style.top = "0";
|
||||||
popup.style.left = "0";
|
popup.style.left = "0";
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function NotificationItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkRead = (e: React.MouseEvent) => {
|
const handleMarkRead = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
markReadIfNeeded();
|
markReadIfNeeded();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,17 +3,23 @@ import { IconBellOff } from "@tabler/icons-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { NotificationItem } from "./notification-item";
|
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 { groupNotificationsByTime } from "../notification.utils";
|
||||||
import { useNotificationsQuery } from "../queries/notification-query";
|
import { useNotificationsQuery } from "../queries/notification-query";
|
||||||
import classes from "../notification.module.css";
|
import classes from "../notification.module.css";
|
||||||
|
|
||||||
type NotificationListProps = {
|
type NotificationListProps = {
|
||||||
|
tab: NotificationTab;
|
||||||
filter: NotificationFilter;
|
filter: NotificationFilter;
|
||||||
onNavigate: () => void;
|
onNavigate: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NotificationList({
|
export function NotificationList({
|
||||||
|
tab,
|
||||||
filter,
|
filter,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: NotificationListProps) {
|
}: NotificationListProps) {
|
||||||
@@ -24,7 +30,7 @@ export function NotificationList({
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useNotificationsQuery();
|
} = useNotificationsQuery(tab as string);
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Popover,
|
Popover,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -18,15 +19,20 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NotificationList } from "./notification-list";
|
import { NotificationList } from "./notification-list";
|
||||||
import { NotificationFilter } from "../types/notification.types";
|
import {
|
||||||
|
NotificationFilter,
|
||||||
|
NotificationTab,
|
||||||
|
} from "../types/notification.types";
|
||||||
import {
|
import {
|
||||||
useMarkAllReadMutation,
|
useMarkAllReadMutation,
|
||||||
useUnreadCountQuery,
|
useUnreadCountQuery,
|
||||||
} from "../queries/notification-query";
|
} from "../queries/notification-query";
|
||||||
|
import classes from "../notification.module.css";
|
||||||
|
|
||||||
export function NotificationPopover() {
|
export function NotificationPopover() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [tab, setTab] = useState<NotificationTab>("direct");
|
||||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||||
|
|
||||||
const { data: unreadData } = useUnreadCountQuery();
|
const { data: unreadData } = useUnreadCountQuery();
|
||||||
@@ -125,13 +131,27 @@ export function NotificationPopover() {
|
|||||||
</Group>
|
</Group>
|
||||||
</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
|
<ScrollArea.Autosize
|
||||||
mah={500}
|
mah={500}
|
||||||
type="auto"
|
type="auto"
|
||||||
offsetScrollbars
|
offsetScrollbars
|
||||||
scrollbarSize={6}
|
scrollbarSize={6}
|
||||||
|
style={{ overscrollBehavior: "contain" }}
|
||||||
>
|
>
|
||||||
<NotificationList
|
<NotificationList
|
||||||
|
tab={tab}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onNavigate={() => setOpened(false)}
|
onNavigate={() => setOpened(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
.divider {
|
.divider {
|
||||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
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 NOTIFICATION_KEY = ["notifications"];
|
||||||
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
|
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
|
||||||
|
|
||||||
export function useNotificationsQuery() {
|
export function useNotificationsQuery(type?: string) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: NOTIFICATION_KEY,
|
queryKey: [...NOTIFICATION_KEY, type],
|
||||||
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
|
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam, type }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IPagination } from "@/lib/types";
|
|||||||
export async function getNotifications(params: {
|
export async function getNotifications(params: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
type?: string;
|
||||||
}): Promise<IPagination<INotification>> {
|
}): Promise<IPagination<INotification>> {
|
||||||
const req = await api.post<IPagination<INotification>>(
|
const req = await api.post<IPagination<INotification>>(
|
||||||
"/notifications",
|
"/notifications",
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ export type INotification = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationFilter = "all" | "unread";
|
export type NotificationFilter = "all" | "unread";
|
||||||
|
|
||||||
|
export type NotificationTab = "direct" | "updates" | "all";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
|
|
||||||
export class NotificationIdDto {
|
export class NotificationIdDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -11,3 +12,10 @@ export class MarkNotificationsReadDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
notificationIds?: string[];
|
notificationIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ListNotificationsDto extends PaginationOptions {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['direct', 'updates', 'all'])
|
||||||
|
type?: 'direct' | 'updates' | 'all' = 'all';
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,3 +26,23 @@ export const NotificationTypeToSettingKey: Partial<
|
|||||||
[NotificationType.COMMENT_CREATED]: 'comment.created',
|
[NotificationType.COMMENT_CREATED]: 'comment.created',
|
||||||
[NotificationType.COMMENT_RESOLVED]: 'comment.resolved',
|
[NotificationType.COMMENT_RESOLVED]: 'comment.resolved',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NotificationTab = 'direct' | 'updates' | 'all';
|
||||||
|
|
||||||
|
export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
|
||||||
|
NotificationType.COMMENT_USER_MENTION,
|
||||||
|
NotificationType.COMMENT_CREATED,
|
||||||
|
NotificationType.COMMENT_RESOLVED,
|
||||||
|
NotificationType.PAGE_USER_MENTION,
|
||||||
|
NotificationType.PAGE_PERMISSION_GRANTED,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
|
||||||
|
NotificationType.PAGE_UPDATED,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTypesForTab(tab: NotificationTab): NotificationType[] | undefined {
|
||||||
|
if (tab === 'direct') return DIRECT_NOTIFICATION_TYPES;
|
||||||
|
if (tab === 'updates') return UPDATES_NOTIFICATION_TYPES;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
import { NotificationService } from './notification.service';
|
import { NotificationService } from './notification.service';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
import { MarkNotificationsReadDto } from './dto/notification.dto';
|
import { ListNotificationsDto, MarkNotificationsReadDto } from './dto/notification.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
@@ -21,10 +20,10 @@ export class NotificationController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/')
|
@Post('/')
|
||||||
async getNotifications(
|
async getNotifications(
|
||||||
@Body() pagination: PaginationOptions,
|
@Body() dto: ListNotificationsDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
) {
|
) {
|
||||||
return this.notificationService.findByUserId(user.id, pagination);
|
return this.notificationService.findByUserId(user.id, dto, dto.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
|
|||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { WsGateway } from '../../ws/ws.gateway';
|
import { WsGateway } from '../../ws/ws.gateway';
|
||||||
import { MailService } from '../../integrations/mail/mail.service';
|
import { MailService } from '../../integrations/mail/mail.service';
|
||||||
import { NotificationType, NotificationTypeToSettingKey } from './notification.constants';
|
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
@@ -39,8 +39,12 @@ export class NotificationService {
|
|||||||
return notification;
|
return notification;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string, pagination: PaginationOptions) {
|
async findByUserId(
|
||||||
return this.notificationRepo.findByUserId(userId, pagination);
|
userId: string,
|
||||||
|
pagination: PaginationOptions,
|
||||||
|
type: NotificationTab = 'all',
|
||||||
|
) {
|
||||||
|
return this.notificationRepo.findByUserId(userId, pagination, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnreadCount(userId: string) {
|
async getUnreadCount(userId: string) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ExpressionBuilder } from 'kysely';
|
|||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationRepo {
|
export class NotificationRepo {
|
||||||
@@ -27,8 +28,12 @@ export class NotificationRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string, pagination: PaginationOptions) {
|
async findByUserId(
|
||||||
const query = this.db
|
userId: string,
|
||||||
|
pagination: PaginationOptions,
|
||||||
|
type: NotificationTab = 'all',
|
||||||
|
) {
|
||||||
|
let query = this.db
|
||||||
.selectFrom('notifications')
|
.selectFrom('notifications')
|
||||||
.selectAll('notifications')
|
.selectAll('notifications')
|
||||||
.select((eb) => this.withActor(eb))
|
.select((eb) => this.withActor(eb))
|
||||||
@@ -42,6 +47,12 @@ export class NotificationRepo {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (type === 'direct') {
|
||||||
|
query = query.where('type', '!=', NotificationType.PAGE_UPDATED);
|
||||||
|
} else if (type === 'updates') {
|
||||||
|
query = query.where('type', '=', NotificationType.PAGE_UPDATED);
|
||||||
|
}
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
cursor: pagination.cursor,
|
cursor: pagination.cursor,
|
||||||
|
|||||||
Reference in New Issue
Block a user