diff --git a/apps/web/src/components/interviews/activity/InterviewsActivityList.tsx b/apps/web/src/components/interviews/activity/InterviewsActivityList.tsx index 1f68f5914..e9456a186 100644 --- a/apps/web/src/components/interviews/activity/InterviewsActivityList.tsx +++ b/apps/web/src/components/interviews/activity/InterviewsActivityList.tsx @@ -117,7 +117,7 @@ export default function InterviewsActivityList({ )} {totalPages > 1 && ( -
+
{intl.formatMessage( { diff --git a/apps/web/src/components/profile/ProfileActivity.tsx b/apps/web/src/components/profile/ProfileActivity.tsx index 14eb7d487..4dd3f2dc4 100644 --- a/apps/web/src/components/profile/ProfileActivity.tsx +++ b/apps/web/src/components/profile/ProfileActivity.tsx @@ -8,6 +8,7 @@ import { useIntl } from '~/components/intl'; import TabsUnderline from '~/components/ui/Tabs/TabsUnderline'; import ProfileActivityBookmarkedQuestions from './activity/ProfileActivityBookmarkedQuestions'; +import ProfileActivityCommunityContributions from './activity/ProfileActivityCommunityContributions'; import ProfileActivityCompletedQuestions from './activity/ProfileActivityCompletedQuestions'; import ProfileActivityNotifications from './activity/ProfileActivityNotifications'; @@ -18,7 +19,7 @@ export default function ProfileActivity() { const unreadCount = useInterviewsNotificationUnreadCount(); const [selectedTab, setSelectedTab] = useState< - 'bookmarked' | 'completed' | 'notifications' + 'bookmarked' | 'completed' | 'contributions' | 'notifications' >(tabParam === 'notifications' ? 'notifications' : 'bookmarked'); function renderTabContent() { @@ -28,6 +29,8 @@ export default function ProfileActivity() { return ; case 'completed': return ; + case 'contributions': + return ; case 'notifications': return ; default: @@ -56,6 +59,14 @@ export default function ProfileActivity() { }), value: 'completed', }, + { + label: intl.formatMessage({ + defaultMessage: 'Community contributions', + description: 'Tab label for community contributions', + id: 'xfkHFf', + }), + value: 'contributions', + }, { label: unreadCount > 0 diff --git a/apps/web/src/components/profile/activity/ProfileActivityCommunityContributions.tsx b/apps/web/src/components/profile/activity/ProfileActivityCommunityContributions.tsx new file mode 100644 index 000000000..d163e49e6 --- /dev/null +++ b/apps/web/src/components/profile/activity/ProfileActivityCommunityContributions.tsx @@ -0,0 +1,92 @@ +import clsx from 'clsx'; +import { RiArrowRightLine } from 'react-icons/ri'; + +import { trpc } from '~/hooks/trpc'; +import usePagination from '~/hooks/usePagination'; + +import InterviewsActivityList from '~/components/interviews/activity/InterviewsActivityList'; +import { useIntl } from '~/components/intl'; +import Button from '~/components/ui/Button'; +import EmptyState from '~/components/ui/EmptyState'; +import Spinner from '~/components/ui/Spinner'; + +const ITEMS_PER_PAGE = 10; + +export default function ProfileActivityCommunityContributions() { + const intl = useIntl(); + // Pagination + const { currentPage, setCurrentPage } = usePagination({ + deps: [], + itemsPerPage: ITEMS_PER_PAGE, + page: 1, + }); + const { data, isLoading } = trpc.communityContributions.list.useQuery( + { + pagination: { + limit: ITEMS_PER_PAGE, + page: currentPage, + }, + }, + { + keepPreviousData: true, + }, + ); + + if (isLoading && data == null) { + return ( +
+ +
+ ); + } + + if (data?.contributions?.length === 0 || data == null) { + return ( +
+
+ +
+
+ ); + } + + const { contributions, totalCount } = data; + + return ( + + ); +} diff --git a/apps/web/src/server/routers/_app.ts b/apps/web/src/server/routers/_app.ts index 79a943678..10b0adc59 100644 --- a/apps/web/src/server/routers/_app.ts +++ b/apps/web/src/server/routers/_app.ts @@ -1,5 +1,6 @@ import { router } from '../trpc'; import { authRouter } from './auth'; +import { communityContributionsRouter } from './community-contributions'; import { devRouter } from './dev'; import { emailsRouter } from './emails'; import { feedbackRouter } from './feedback'; @@ -24,6 +25,7 @@ import { sponsorsRouter } from './sponsors'; export const appRouter = router({ auth: authRouter, bookmark: questionBookmarkRouter, + communityContributions: communityContributionsRouter, dev: devRouter, emails: emailsRouter, feedback: feedbackRouter, diff --git a/apps/web/src/server/routers/community-contributions.ts b/apps/web/src/server/routers/community-contributions.ts new file mode 100644 index 000000000..450bad585 --- /dev/null +++ b/apps/web/src/server/routers/community-contributions.ts @@ -0,0 +1,168 @@ +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'; + +export const communityContributionsRouter = 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, contributions] = await Promise.all([ + prisma.interviewsActivity.count({ + where: { + actorId: 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, + }, + }, + parentComment: { + include: { + author: { + select: { + id: true, + name: true, + username: true, + }, + }, + }, + }, + repliedTo: { + 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: { + actorId: viewer.id, + }, + }), + ]); + + if (contributions.length === 0) { + return { + contributions: [], + totalCount: 0, + }; + } + + const finalContributions = await Promise.all( + contributions.map(async (contribution) => { + if ( + contribution.category === 'DISCUSSION_UPVOTE' && + contribution.vote + ) { + const [format, slug] = unhashQuestion( + contribution.vote.comment.entityId, + ); + const { question: questionMetadata } = await fetchQuestion({ + format, + slug, + }); + + return { + ...contribution, + question: { + format: questionMetadata.format, + href: questionMetadata.href, + slug: questionMetadata.slug, + title: questionMetadata.title, + }, + } as InterviewsUpvoteActivity; + } + if (contribution.category === 'DISCUSSION' && contribution.comment) { + const [format, slug] = unhashQuestion( + contribution.comment.entityId, + ); + const { question: questionMetadata } = await fetchQuestion({ + format, + slug, + }); + + return { + ...contribution, + question: { + format: questionMetadata.format, + href: questionMetadata.href, + slug: questionMetadata.slug, + title: questionMetadata.title, + }, + } as InterviewsCommentActivity; + } + + return null; + }), + ); + + return { + contributions: finalContributions.flatMap((contribution) => + contribution != null ? [contribution] : [], + ), + totalCount, + }; + }), +});