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.",
"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",
"Direct": "Direct",
"Updates": "Updates",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
@@ -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";
@@ -77,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",
@@ -39,3 +39,5 @@ export type INotification = {
};
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 {
@IsUUID()
@@ -11,3 +12,10 @@ export class MarkNotificationsReadDto {
@IsOptional()
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_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 { AuthUser } from '../../common/decorators/auth-user.decorator';
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 { MarkNotificationsReadDto } from './dto/notification.dto';
import { ListNotificationsDto, MarkNotificationsReadDto } from './dto/notification.dto';
@UseGuards(JwtAuthGuard)
@Controller('notifications')
@@ -21,10 +20,10 @@ export class NotificationController {
@HttpCode(HttpStatus.OK)
@Post('/')
async getNotifications(
@Body() pagination: PaginationOptions,
@Body() dto: ListNotificationsDto,
@AuthUser() user: User,
) {
return this.notificationService.findByUserId(user.id, pagination);
return this.notificationService.findByUserId(user.id, dto, dto.type);
}
@HttpCode(HttpStatus.OK)
@@ -6,7 +6,7 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { WsGateway } from '../../ws/ws.gateway';
import { MailService } from '../../integrations/mail/mail.service';
import { NotificationType, NotificationTypeToSettingKey } from './notification.constants';
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
@Injectable()
export class NotificationService {
@@ -39,8 +39,12 @@ export class NotificationService {
return notification;
}
async findByUserId(userId: string, pagination: PaginationOptions) {
return this.notificationRepo.findByUserId(userId, pagination);
async findByUserId(
userId: string,
pagination: PaginationOptions,
type: NotificationTab = 'all',
) {
return this.notificationRepo.findByUserId(userId, pagination, type);
}
async getUnreadCount(userId: string) {
@@ -11,6 +11,7 @@ import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants';
@Injectable()
export class NotificationRepo {
@@ -27,8 +28,12 @@ export class NotificationRepo {
.executeTakeFirst();
}
async findByUserId(userId: string, pagination: PaginationOptions) {
const query = this.db
async findByUserId(
userId: string,
pagination: PaginationOptions,
type: NotificationTab = 'all',
) {
let query = this.db
.selectFrom('notifications')
.selectAll('notifications')
.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, {
perPage: pagination.limit,
cursor: pagination.cursor,