From 3801daf11c53a9eefb8c75edc2a719a954265b6b Mon Sep 17 00:00:00 2001 From: Nitesh Seram Date: Mon, 15 Sep 2025 08:33:25 +0530 Subject: [PATCH] [web] workspace/discussions: add notifications popover with API integration (#1693) --- .../activity/InterviewsActivityItem.tsx | 337 ++++++++++++++++++ .../components/interviews/activity/types.ts | 80 +++++ .../notifications/InterviewsNotification.tsx | 6 +- .../InterviewsNotificationItem.tsx | 44 +++ .../InterviewsNotificationPopoverContent.tsx | 62 +++- apps/web/src/server/routers/notifications.ts | 156 ++++++++ 6 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/interviews/activity/InterviewsActivityItem.tsx create mode 100644 apps/web/src/components/interviews/activity/types.ts create mode 100644 apps/web/src/components/interviews/notifications/InterviewsNotificationItem.tsx diff --git a/apps/web/src/components/interviews/activity/InterviewsActivityItem.tsx b/apps/web/src/components/interviews/activity/InterviewsActivityItem.tsx new file mode 100644 index 000000000..f0fb4b37b --- /dev/null +++ b/apps/web/src/components/interviews/activity/InterviewsActivityItem.tsx @@ -0,0 +1,337 @@ +import { InterviewsDiscussionCommentDomain } from '@prisma/client'; +import { useUser } from '@supabase/auth-helpers-react'; +import clsx from 'clsx'; + +import RelativeTimestamp from '~/components/common/datetime/RelativeTimestamp'; +import { FormattedMessage } from '~/components/intl'; +import Anchor from '~/components/ui/Anchor'; +import Avatar from '~/components/ui/Avatar'; +import RichText from '~/components/ui/RichTextEditor/RichText'; +import Text from '~/components/ui/Text'; +import { themeTextColor } from '~/components/ui/theme'; + +import type { + InterviewsActivityExtended, + InterviewsCommentActivity, + InterviewsUpvoteActivity, +} from './types'; + +type Props = Readonly<{ + activity: InterviewsActivityExtended; + variant?: 'full' | 'minimal'; +}>; + +export default function InterviewsActivityItem({ + activity, + variant = 'full', +}: Props) { + const { actor, createdAt, question } = activity; + + return ( +
+ + +
+ + {activity.category === 'DISCUSSION_UPVOTE' ? ( + + ) : ( + + )} + {` • `} + + + +
+
+ ); +} + +function CommentActivityMessage({ + activity, +}: Readonly<{ + activity: InterviewsCommentActivity; +}>) { + const user = useUser(); + const { comment, question } = activity; + const bold = (chunks: React.ReactNode) => ( + + {chunks} + + ); + const questionLink = (chunks: React.ReactNode) => ( + + {chunks} + + ); + + // Check if the current user is the actor of the activity + if (user?.id === activity.actorId) { + if (comment.parentComment != null) { + // User commented on their own reply + if ( + comment.parentComment.author.id === activity.actorId || + comment.repliedTo?.author.id === activity.actorId + ) { + return ( + + ); + } + + // User commented on others reply + return ( + + ); + } + + // User left a comment + return ( + + ); + } + + // Other commented on user's reply + if (comment.parentCommentId != null) { + return ( + + ); + } + + // Other commented on user's comment + return ( + + ); +} + +function UpvoteActivityMessage({ + activity, +}: Readonly<{ activity: InterviewsUpvoteActivity }>) { + const user = useUser(); + const { question, vote } = activity; + const { comment } = vote; + const bold = (chunks: React.ReactNode) => ( + + {chunks} + + ); + const questionLink = (chunks: React.ReactNode) => ( + + {chunks} + + ); + + // Check if the current user is the actor of the activity + if (user?.id === activity.actorId) { + if (comment.parentCommentId != null) { + // User upvoted their own reply + if (comment.author.id === activity.actorId) { + return ( + + ); + } + + // User upvoted to others reply + return ( + + ); + } + + // User upvoted their own comment + if (comment.author.id === activity.actorId) { + return ( + + ); + } + + return ( + + ); + } + // Other upvoted user's reply + if (activity.vote.comment.parentCommentId != null) { + return ( + + ); + } + + // Other upvoted user's comment + return ( + + ); +} + +function getQuestionTitle( + title: string, + domain: InterviewsDiscussionCommentDomain, +) { + if (domain === InterviewsDiscussionCommentDomain.OFFICIAL_SOLUTION) { + return `${title} (Official solution)`; + } + + return title; +} diff --git a/apps/web/src/components/interviews/activity/types.ts b/apps/web/src/components/interviews/activity/types.ts new file mode 100644 index 000000000..d7b7e63c5 --- /dev/null +++ b/apps/web/src/components/interviews/activity/types.ts @@ -0,0 +1,80 @@ +import type { + InterviewsActivity, + InterviewsDiscussionComment, +} from '@prisma/client'; + +import type { QuestionFormat } from '../questions/common/QuestionsTypes'; + +type InterviewsCommonActivity = Readonly<{ + actor: { + avatarUrl: string | null; + id: string; + name: string | null; + username: string; + }; + question: { + format: QuestionFormat; + href: string; + slug: string; + title: string; + }; + recipient?: { + avatarUrl: string | null; + id: string; + name: string | null; + username: string; + }; +}>; + +export type InterviewsCommentActivity = InterviewsCommonActivity & + Omit & + Readonly<{ + category: 'DISCUSSION'; + comment: InterviewsDiscussionComment & { + author: { + id: string; + name: string | null; + username: string; + }; + parentComment?: { + author: { + id: string; + name: string | null; + username: string; + }; + }; + repliedTo?: { + author: { + id: string; + name: string | null; + username: string; + }; + }; + }; + }>; + +export type InterviewsUpvoteActivity = InterviewsCommonActivity & + InterviewsCommonActivity & + Omit & + Readonly<{ + actor: { + avatarUrl: string | null; + id: string; + name: string | null; + username: string; + }; + category: 'DISCUSSION_UPVOTE'; + vote: { + comment: InterviewsDiscussionComment & { + author: { + id: string; + name: string | null; + username: string; + }; + }; + }; + }>; + +export type InterviewsActivityExtended = + | InterviewsCommentActivity + | InterviewsUpvoteActivity; diff --git a/apps/web/src/components/interviews/notifications/InterviewsNotification.tsx b/apps/web/src/components/interviews/notifications/InterviewsNotification.tsx index 909d12632..0b3f144fb 100644 --- a/apps/web/src/components/interviews/notifications/InterviewsNotification.tsx +++ b/apps/web/src/components/interviews/notifications/InterviewsNotification.tsx @@ -26,7 +26,11 @@ export default function InterviewsNotification() { return ( diff --git a/apps/web/src/components/interviews/notifications/InterviewsNotificationItem.tsx b/apps/web/src/components/interviews/notifications/InterviewsNotificationItem.tsx new file mode 100644 index 000000000..e85c7023f --- /dev/null +++ b/apps/web/src/components/interviews/notifications/InterviewsNotificationItem.tsx @@ -0,0 +1,44 @@ +import { trpc } from '~/hooks/trpc'; + +import InterviewsActivityItem from '../activity/InterviewsActivityItem'; +import type { InterviewsActivityExtended } from '../activity/types'; +import InterviewsNotificationUnreadIndicator from './InterviewsNotificationUnreadIndicator'; + +type Props = Readonly<{ + activity: InterviewsActivityExtended; + closeNotification: () => void; + variant?: 'full' | 'minimal'; +}>; + +export default function InterviewsNotificationItem({ + activity, + closeNotification, + variant = 'full', +}: Props) { + const trcUtils = trpc.useUtils(); + const { read } = activity; + const markAsRead = trpc.notifications.markAsRead.useMutation({ + onSuccess: () => { + trcUtils.notifications.list.invalidate(); + trcUtils.notifications.getUnreadCount.invalidate(); + }, + }); + + function onClick() { + if (!read) { + markAsRead.mutate({ + id: activity.id, + }); + } + closeNotification(); + } + + return ( +
+ + {!read && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/interviews/notifications/InterviewsNotificationPopoverContent.tsx b/apps/web/src/components/interviews/notifications/InterviewsNotificationPopoverContent.tsx index 163390e79..4e93ed589 100644 --- a/apps/web/src/components/interviews/notifications/InterviewsNotificationPopoverContent.tsx +++ b/apps/web/src/components/interviews/notifications/InterviewsNotificationPopoverContent.tsx @@ -1,19 +1,36 @@ import clsx from 'clsx'; -import { RiNotification3Line } from 'react-icons/ri'; +import { RiArrowRightLine, RiNotification3Line } from 'react-icons/ri'; +import url from 'url'; + +import { trpc } from '~/hooks/trpc'; import { FormattedMessage } from '~/components/intl'; +import { useIntl } from '~/components/intl'; +import Button from '~/components/ui/Button'; import Spinner from '~/components/ui/Spinner'; import Text from '~/components/ui/Text'; import { - themeDivideEmphasizeColor, + themeDivideColor, themeTextSubtitleColor, } from '~/components/ui/theme'; +import InterviewsNotificationItem from './InterviewsNotificationItem'; + +const MAX_ITEMS_TO_SHOW = 4; + type Props = Readonly<{ closeNotification: () => void }>; -export default function InterviewsNotificationPopoverContent(_: Props) { - const isLoading = false; - const notifications = []; +export default function InterviewsNotificationPopoverContent({ + closeNotification, +}: Props) { + const intl = useIntl(); + const { data, isLoading } = trpc.notifications.list.useQuery({ + pagination: { + limit: MAX_ITEMS_TO_SHOW + 1, + page: 1, + }, + }); + const { notifications } = data ?? {}; return (
@@ -48,8 +65,39 @@ export default function InterviewsNotificationPopoverContent(_: Props) {
) : ( -
- Notifications +
+ {notifications + ?.slice(0, MAX_ITEMS_TO_SHOW) + .map((notification) => ( + + ))} + + {(notifications?.length ?? 0) > MAX_ITEMS_TO_SHOW && ( +
+
+ )}
)}
diff --git a/apps/web/src/server/routers/notifications.ts b/apps/web/src/server/routers/notifications.ts index aa8321682..453c57bfd 100644 --- a/apps/web/src/server/routers/notifications.ts +++ b/apps/web/src/server/routers/notifications.ts @@ -1,3 +1,12 @@ +import { z } from 'zod'; + +import type { + InterviewsCommentActivity, + InterviewsUpvoteActivity, +} from '~/components/interviews/activity/types'; + +import { fetchQuestion } from '~/db/QuestionsListReader'; +import { unhashQuestion } from '~/db/QuestionsUtils'; import prisma from '~/server/prisma'; import { router, userProcedure } from '../trpc'; @@ -11,4 +20,151 @@ export const notificationsRouter = router({ }, }); }), + list: userProcedure + .input( + z.object({ + pagination: z.object({ + limit: z + .number() + .int() + .positive() + .transform((val) => Math.min(30, val)), + page: z.number().int().positive(), + }), + }), + ) + .query(async ({ ctx: { viewer }, input: { pagination } }) => { + const { limit, page } = pagination; + const [totalCount, notifications] = await Promise.all([ + prisma.interviewsActivity.count({ + where: { + recipientId: viewer.id, + }, + }), + prisma.interviewsActivity.findMany({ + include: { + actor: { + select: { + avatarUrl: true, + id: true, + name: true, + username: true, + }, + }, + comment: { + include: { + author: { + select: { + id: true, + name: true, + username: true, + }, + }, + }, + }, + vote: { + include: { + comment: { + include: { + author: { + select: { + id: true, + name: true, + username: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip: (page - 1) * limit, + take: limit, + where: { + recipientId: viewer.id, + }, + }), + ]); + + if (notifications.length === 0) { + return { + notifications: [], + totalCount: 0, + }; + } + + const finalNotifications = await Promise.all( + notifications.map(async (notification) => { + if ( + notification.category === 'DISCUSSION_UPVOTE' && + notification.vote + ) { + const [format, slug] = unhashQuestion( + notification.vote.comment.entityId, + ); + const { question: questionMetadata } = await fetchQuestion({ + format, + slug, + }); + + return { + ...notification, + question: { + format: questionMetadata.format, + href: questionMetadata.href, + slug: questionMetadata.slug, + title: questionMetadata.title, + }, + } as InterviewsUpvoteActivity; + } + if (notification.category === 'DISCUSSION' && notification.comment) { + const [format, slug] = unhashQuestion( + notification.comment.entityId, + ); + const { question: questionMetadata } = await fetchQuestion({ + format, + slug, + }); + + return { + ...notification, + question: { + format: questionMetadata.format, + href: questionMetadata.href, + slug: questionMetadata.slug, + title: questionMetadata.title, + }, + } as InterviewsCommentActivity; + } + + return null; + }), + ); + + return { + notifications: finalNotifications.flatMap((notification) => + notification != null ? [notification] : [], + ), + totalCount, + }; + }), + markAsRead: userProcedure + .input( + z.object({ + id: z.string().uuid(), + }), + ) + .mutation(async ({ input: { id } }) => { + await prisma.interviewsActivity.update({ + data: { + read: true, + }, + where: { + id, + }, + }); + }), });