feat: group notifications

This commit is contained in:
Philipinho
2026-03-31 15:38:06 +01:00
parent bd42dec6be
commit 1912ef5b1c
16 changed files with 101 additions and 22 deletions
@@ -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,