[web] workspace/discussions: add notifications popover with API integration (#1693)

This commit is contained in:
Nitesh Seram 2025-09-15 08:33:25 +05:30 committed by GitHub
parent d99fb9643b
commit 3801daf11c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 677 additions and 8 deletions

View File

@ -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 (
<div
className={clsx(
'relative flex gap-x-3',
'px-4 py-5',
'transition-colors',
'hover:bg-neutral-50 dark:hover:bg-neutral-800/40',
)}>
<Anchor className="absolute inset-0" href={question.href} />
<Avatar
alt={actor.name ?? actor.username}
size="xs"
src={actor.avatarUrl ?? ''}
/>
<div className="space-y-4">
<Text color="secondary" size={variant === 'full' ? 'body2' : 'body3'}>
{activity.category === 'DISCUSSION_UPVOTE' ? (
<UpvoteActivityMessage activity={activity} />
) : (
<CommentActivityMessage activity={activity} />
)}
{``}
<RelativeTimestamp timestamp={createdAt} />
</Text>
<RichText
className={clsx(variant === 'minimal' && 'text-xs')}
color="body"
size={variant === 'full' ? 'sm' : 'custom'}
value={
activity.category === 'DISCUSSION_UPVOTE'
? activity.vote.comment.body
: activity.comment.body
}
/>
</div>
</div>
);
}
function CommentActivityMessage({
activity,
}: Readonly<{
activity: InterviewsCommentActivity;
}>) {
const user = useUser();
const { comment, question } = activity;
const bold = (chunks: React.ReactNode) => (
<Text size="inherit" weight="medium">
{chunks}
</Text>
);
const questionLink = (chunks: React.ReactNode) => (
<Anchor
className={clsx('relative', themeTextColor)}
href={question.href}
variant="flat">
{chunks}
</Anchor>
);
// 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 (
<FormattedMessage
defaultMessage="<bold>You</bold> replied to your comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for commenting on own reply"
id="GVJ/Mh"
values={{
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.comment.domain,
),
}}
/>
);
}
// User commented on others reply
return (
<FormattedMessage
defaultMessage="<bold>You</bold> replied to <bold>{recipientName}</bold>'s comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for commenting on others reply"
id="PDrcW2"
values={{
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.comment.domain,
),
recipientName: comment.repliedTo
? comment.repliedTo.author.name ??
comment.repliedTo.author.username
: comment.parentComment.author.name ??
comment.parentComment.author.username,
}}
/>
);
}
// User left a comment
return (
<FormattedMessage
defaultMessage="<bold>You</bold> left a comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for commenting on own comment"
id="IKmNeN"
values={{
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.comment.domain,
),
}}
/>
);
}
// Other commented on user's reply
if (comment.parentCommentId != null) {
return (
<FormattedMessage
defaultMessage="<bold>{actorName}</bold> replied to your comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for replying to someone else's reply"
id="tRaX9t"
values={{
actorName: activity.actor.name ?? activity.actor.username,
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.comment.domain,
),
}}
/>
);
}
// Other commented on user's comment
return (
<FormattedMessage
defaultMessage="<bold>{actorName}</bold> replied to your comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for replying to someone else's comment"
id="mO2nTD"
values={{
actorName: activity.actor.name ?? activity.actor.username,
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.comment.domain,
),
}}
/>
);
}
function UpvoteActivityMessage({
activity,
}: Readonly<{ activity: InterviewsUpvoteActivity }>) {
const user = useUser();
const { question, vote } = activity;
const { comment } = vote;
const bold = (chunks: React.ReactNode) => (
<Text size="inherit" weight="medium">
{chunks}
</Text>
);
const questionLink = (chunks: React.ReactNode) => (
<Anchor className={themeTextColor} href={question.href} variant="flat">
{chunks}
</Anchor>
);
// 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 (
<FormattedMessage
defaultMessage="<bold>You</bold> upvoted your reply on <questionLink>{questionTitle}</questionLink>"
description="Activity message for upvoting own reply"
id="1Gm4Mr"
values={{
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.vote.comment.domain,
),
}}
/>
);
}
// User upvoted to others reply
return (
<FormattedMessage
defaultMessage="<bold>You</bold> upvoted <bold>{recipientName}</bold>'s reply on <questionLink>{questionTitle}</questionLink>"
description="Activity message for upvoting others reply"
id="ZvWM/t"
values={{
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.vote.comment.domain,
),
recipientName: comment.author.name ?? comment.author.username,
}}
/>
);
}
// User upvoted their own comment
if (comment.author.id === activity.actorId) {
return (
<FormattedMessage
defaultMessage="<bold>You</bold> upvoted your comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for upvoting own comment"
id="CxKzGg"
values={{
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.vote.comment.domain,
),
}}
/>
);
}
return (
<FormattedMessage
defaultMessage="<bold>You</bold> upvoted <bold>{recipientName}</bold>'s comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for upvoting others comment"
id="ulVzMu"
values={{
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.vote.comment.domain,
),
recipientName: comment.author.name ?? comment.author.username,
}}
/>
);
}
// Other upvoted user's reply
if (activity.vote.comment.parentCommentId != null) {
return (
<FormattedMessage
defaultMessage="<bold>{actorName}</bold> upvoted your reply on <questionLink>{questionTitle}</questionLink>"
description="Activity message for upvoting someone else's reply"
id="bErRk/"
values={{
actorName: activity.actor.name ?? activity.actor.username,
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.vote.comment.domain,
),
}}
/>
);
}
// Other upvoted user's comment
return (
<FormattedMessage
defaultMessage="<bold>{actorName}</bold> upvoted your comment on <questionLink>{questionTitle}</questionLink>"
description="Activity message for upvoting someone else's comment"
id="QfATOE"
values={{
actorName: activity.actor.name ?? activity.actor.username,
bold,
questionLink,
questionTitle: getQuestionTitle(
activity.question.title,
activity.vote.comment.domain,
),
}}
/>
);
}
function getQuestionTitle(
title: string,
domain: InterviewsDiscussionCommentDomain,
) {
if (domain === InterviewsDiscussionCommentDomain.OFFICIAL_SOLUTION) {
return `${title} (Official solution)`;
}
return title;
}

View File

@ -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<InterviewsActivity, 'category'> &
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<InterviewsActivity, 'category'> &
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;

View File

@ -26,7 +26,11 @@ export default function InterviewsNotification() {
return (
<Popover
align="start"
className={clsx('overflow-y-auto', 'mr-6 w-[464px]')}
className={clsx(
'thin-scrollbar max-h-[50vh] overflow-y-auto',
'mr-6 w-[464px]',
'!p-3',
)}
open={showNotification}
trigger={
<div className="relative">

View File

@ -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 (
<div className="relative" onClick={onClick}>
<InterviewsActivityItem activity={activity} variant={variant} />
{!read && (
<InterviewsNotificationUnreadIndicator className="absolute right-1.5 top-1.5" />
)}
</div>
);
}

View File

@ -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 (
<div>
@ -48,8 +65,39 @@ export default function InterviewsNotificationPopoverContent(_: Props) {
</div>
</div>
) : (
<div className={clsx('divide-y', themeDivideEmphasizeColor)}>
Notifications
<div className={clsx('divide-y', themeDivideColor)}>
{notifications
?.slice(0, MAX_ITEMS_TO_SHOW)
.map((notification) => (
<InterviewsNotificationItem
key={notification.id}
activity={notification}
closeNotification={closeNotification}
variant="minimal"
/>
))}
{(notifications?.length ?? 0) > MAX_ITEMS_TO_SHOW && (
<div className={clsx('w-full py-4', 'flex justify-center')}>
<Button
href={url.format({
pathname: '/profile',
query: {
tab: 'notifications',
},
})}
icon={RiArrowRightLine}
label={intl.formatMessage({
defaultMessage: 'See all notifications',
description: 'Label for view all notifications button',
id: 'yvKbxT',
})}
size="xs"
variant="secondary"
onClick={closeNotification}
/>
</div>
)}
</div>
)}
</div>

View File

@ -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,
},
});
}),
});