From 6fae13c7bc7122d1172c6d1d2699b2ecb607ca73 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 14 Sep 2026 22:57:16 +0700 Subject: [PATCH 1/3] feat(preview): inline comments, replies, resolve and anonymous commenting Anchored text comments with highlights, one-level replies, org-scoped resolve/reopen, named anonymous comments behind optional invisible reCAPTCHA v2 and the IP throttler, and a dashboard restyle of /p/:id. --- .env.example | 4 + .../src/api/routes/posts.controller.ts | 25 +- .../src/api/routes/public.controller.ts | 11 +- .../src/app/(app)/(preview)/p/[id]/layout.tsx | 2 +- .../src/app/(app)/(preview)/p/[id]/page.tsx | 185 +++--- apps/frontend/src/app/(app)/layout.tsx | 1 + .../preview/comments.components.tsx | 607 +++++++++++++++--- .../preview/post.content.client.tsx | 324 ++++++++++ .../preview/preview.comments.context.tsx | 105 +++ .../src/utils/sanitize.post.content.ts | 15 + .../database/prisma/posts/posts.repository.ts | 86 ++- .../database/prisma/posts/posts.service.ts | 197 +++++- .../src/database/prisma/schema.prisma | 14 +- .../src/dtos/comments/add.comment.dto.ts | 54 +- .../src/throttler/throttler.provider.ts | 15 +- .../src/helpers/variable.context.tsx | 2 + 16 files changed, 1420 insertions(+), 227 deletions(-) create mode 100644 apps/frontend/src/components/preview/post.content.client.tsx create mode 100644 apps/frontend/src/components/preview/preview.comments.context.tsx diff --git a/.env.example b/.env.example index cd8c85c7c6..4b55c05865 100644 --- a/.env.example +++ b/.env.example @@ -166,6 +166,10 @@ POSTIZ_OAUTH_CLIENT_SECRET="" # LINK_DRIP_API_ENDPOINT="https://api.linkdrip.com/v1/" # Your self-hosted LinkDrip API endpoint # LINK_DRIP_SHORT_LINK_DOMAIN="dripl.ink" # Your self-hosted LinkDrip domain +# reCAPTCHA Settings (invisible v2 on anonymous preview comments, optional; unset = no captcha) +# RECAPTCHA_SITE_KEY="" # Your reCAPTCHA v2 invisible site key +# RECAPTCHA_SECRET_KEY="" # Your reCAPTCHA v2 invisible secret key + # Provider visibility / migration # HIDDEN_PROVIDERS="tiktok" # comma-separated identifiers hidden from the add-channel screen # MIGRATE_PROVIDERS="tiktok:tiktok-business" # comma-separated "old:new" pairs, a reconnect of "old" goes through "new" and the channel is migrated in place diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 91c1b44e12..230d2cef3e 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -29,6 +29,11 @@ import { Sections, } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; import { PostValidationException } from '@gitroom/backend/api/routes/posts.validation.exception'; +import { + CreatePublicCommentDto, + ResolveCommentDto, +} from '@gitroom/nestjs-libraries/dtos/comments/add.comment.dto'; +import { RealIP } from 'nestjs-real-ip'; @ApiTags('Posts') @Controller('/posts') @@ -71,12 +76,21 @@ export class PostsController { @Post('/:id/comments') async createComment( - @GetOrgFromRequest() org: Organization, @GetUserFromRequest() user: User, @Param('id') id: string, - @Body() body: { comment: string } + @Body() body: CreatePublicCommentDto, + @RealIP() ip: string ) { - return this._postsService.createComment(org.id, user.id, id, body.comment); + return this._postsService.createPublicComment(id, body, user.id, ip); + } + + @Put('/comments/:commentId/resolve') + async resolveComment( + @GetOrgFromRequest() org: Organization, + @Param('commentId') commentId: string, + @Body() body: ResolveCommentDto + ) { + return this._postsService.resolveComment(org.id, commentId, body.resolved); } @Get('/tags') @@ -159,7 +173,10 @@ export class PostsController { } @Get('/group/:group') - getPostsByGroup(@GetOrgFromRequest() org: Organization, @Param('group') group: string) { + getPostsByGroup( + @GetOrgFromRequest() org: Organization, + @Param('group') group: string + ) { return this._postsService.getPostsByGroup(org.id, group); } diff --git a/apps/backend/src/api/routes/public.controller.ts b/apps/backend/src/api/routes/public.controller.ts index fc20695934..40cf3542cc 100644 --- a/apps/backend/src/api/routes/public.controller.ts +++ b/apps/backend/src/api/routes/public.controller.ts @@ -27,6 +27,7 @@ import { promisify } from 'util'; import { OnlyURL } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto'; import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { CreatePublicCommentDto } from '@gitroom/nestjs-libraries/dtos/comments/add.comment.dto'; const pump = promisify(pipeline); @@ -76,6 +77,15 @@ export class PublicController { return { comments: await this._postsService.getComments(postId) }; } + @Post(`/posts/:id/comments`) + async createComment( + @Param('id') postId: string, + @Body() body: CreatePublicCommentDto, + @RealIP() ip: string + ) { + return this._postsService.createPublicComment(postId, body, null, ip); + } + @Post('/t') async trackEvent( @Res() res: Response, @@ -154,7 +164,6 @@ export class PublicController { } } - @Get('/stream') async streamFile( @Query() query: OnlyURL, diff --git a/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx b/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx index aed33f16a8..8c6e48dcdf 100644 --- a/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx +++ b/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx @@ -3,7 +3,7 @@ import { PreviewWrapper } from '@gitroom/frontend/components/preview/preview.wra export default async function AppLayout({ children }: { children: ReactNode }) { return ( -
+
{children}
); diff --git a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx index f5dc7b463a..2b90644a91 100644 --- a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx +++ b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx @@ -6,6 +6,8 @@ import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.si import SafeImage from '@gitroom/react/helpers/safe.image'; import Link from 'next/link'; import { CommentsComponents } from '@gitroom/frontend/components/preview/comments.components'; +import { PreviewCommentsProvider } from '@gitroom/frontend/components/preview/preview.comments.context'; +import { PostContentClient } from '@gitroom/frontend/components/preview/post.content.client'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; @@ -40,107 +42,102 @@ export default async function Auth( const t = await getT(); if (!post.length) { return ( -
+
{t('post_not_found', 'Post not found')}
); } return ( -
-
-
-
-
-
- -
- -
-
- - - - - - -
- -
+ p.id)} + organizationId={post[0].organizationId} + > +
+
+ +
+ +
+
+ + + + + + +
+ +
+
+
+ {t('publication_date', 'Publication Date:')}{' '} + + +
-
-
{!!searchParams?.share && ( -
+ <> +
-
+ )} -
- {t('publication_date', 'Publication Date:')}{' '} - -
-
-
-
-
+
+
{post.map((p: any, index: number) => (
-
+
-
+
{post[0].integration.name}
-
+
{post[0].integration.providerIdentifier}
-
-
-

+
+
+

{post[0].integration.name}

- + @{post[0].integration.profile} {index === 0 && ( @@ -150,40 +147,40 @@ export default async function Auth( /> )}
-
-
+ -
- {JSON.parse(p?.image || '[]').map((p: any) => ( -
- -
- ))} -
+ {!!JSON.parse(p?.image || '[]').length && ( +
+ {JSON.parse(p?.image || '[]').map((p: any) => ( +
+ +
+ ))} +
+ )}

))}
-
-
-
- +
+
+ +
-
+ ); } diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index ae83325be3..27eea4bf3c 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -87,6 +87,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { googleAdsId={process.env.NEXT_PUBLIC_GTM_ID} googleAdsTrialTracking={process.env.NEXT_PUBLIC_TRACKING_TRIAL} language={language} + recaptchaSiteKey={process.env.RECAPTCHA_SITE_KEY || ''} transloadit={ process.env.TRANSLOADIT_AUTH && process.env.TRANSLOADIT_TEMPLATE ? [ diff --git a/apps/frontend/src/components/preview/comments.components.tsx b/apps/frontend/src/components/preview/comments.components.tsx index e4b658b6fc..630ad20b2d 100644 --- a/apps/frontend/src/components/preview/comments.components.tsx +++ b/apps/frontend/src/components/preview/comments.components.tsx @@ -2,127 +2,524 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { Button } from '@gitroom/react/form/button'; -import { FC, useCallback, useMemo, useState } from 'react'; +import { Input } from '@gitroom/react/form/input'; +import { Textarea } from '@gitroom/react/form/textarea'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; -import useSWR from 'swr'; -import { FieldValues, SubmitHandler, useForm } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; -export const RenderComponents: FC<{ - postId: string; -}> = (props) => { - const { postId } = props; - const fetch = useFetch(); - const comments = useCallback(async () => { - return (await fetch(`/public/posts/${postId}/comments`)).json(); - }, [postId]); - const { data, mutate, isLoading } = useSWR('comments', comments); - const mapUsers = useMemo(() => { - return (data?.comments || []).reduce( - (all: any, current: any) => { - all.users[current.userId] = all.users[current.userId] || all.counter++; - return all; - }, - { - users: {}, - counter: 1, +import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { + PreviewComment, + usePreviewComments, +} from '@gitroom/frontend/components/preview/preview.comments.context'; + +const REVIEWER_NAME_KEY = 'preview-reviewer-name'; + +// Invisible reCAPTCHA v2: one widget for the page. Google only shows a puzzle +// when it considers the visitor risky; otherwise the token comes back at once. +// Google does not report a dismissed puzzle, so an attempt stays pending until +// it gets a token, expires, errors, or the reviewer clicks Post again (which +// abandons the previous attempt with null). +let recaptchaWidget: { + id: number; + resolve?: (token: string | null) => void; +} | null = null; + +const loadRecaptchaToken = async (siteKey: string) => { + if (!(window as any).grecaptcha?.render) { + await new Promise((resolve, reject) => { + (window as any).onPreviewRecaptchaLoad = () => resolve(); + const script = document.createElement('script'); + script.src = + 'https://www.google.com/recaptcha/api.js?onload=onPreviewRecaptchaLoad&render=explicit'; + script.async = true; + script.onerror = () => reject(new Error('recaptcha failed to load')); + document.head.appendChild(script); + }); + } + const grecaptcha = (window as any).grecaptcha; + if (!recaptchaWidget) { + const container = document.createElement('div'); + document.body.appendChild(container); + const widget: NonNullable = { id: 0 }; + const settle = (token: string | null) => { + const resolve = widget.resolve; + widget.resolve = undefined; + resolve?.(token); + }; + widget.id = grecaptcha.render(container, { + sitekey: siteKey, + size: 'invisible', + callback: (token: string) => settle(token), + 'expired-callback': () => settle(null), + 'error-callback': () => settle(null), + }); + recaptchaWidget = widget; + } + const widget = recaptchaWidget; + widget.resolve?.(null); + grecaptcha.reset(widget.id); + return new Promise((resolve) => { + widget.resolve = resolve; + grecaptcha.execute(widget.id); + }); +}; + +const ReviewerNameForm: FC<{ onConfirm: (name: string) => void }> = ({ + onConfirm, +}) => { + const t = useT(); + const form = useForm({ + values: { + name: + (typeof window !== 'undefined' && + localStorage.getItem(REVIEWER_NAME_KEY)) || + '', + }, + mode: 'onChange', + }); + const submit = useCallback( + (values: { name: string }) => { + const name = values.name.trim(); + if (!name) { + return; } - ).users; - }, [data]); - const { handleSubmit, register, setValue } = useForm(); - const submit: SubmitHandler = useCallback( - async (e) => { - setValue('comment', ''); - await fetch(`/posts/${postId}/comments`, { - method: 'POST', - body: JSON.stringify(e), - }); - mutate(); + localStorage.setItem(REVIEWER_NAME_KEY, name); + onConfirm(name); }, - [postId, mutate] + [onConfirm] ); + return ( + +
+
+
+ {t( + 'preview_comment_name_required', + 'We can only post your comment if you provide your name.' + )} +
+ + +
+
+
+ ); +}; + +const CommentComposer: FC<{ + parentId?: string; + onDone?: () => void; +}> = ({ parentId, onDone }) => { const t = useT(); + const user = useUser(); + const fetch = useFetch(); + const toast = useToaster(); + const modals = useModals(); + const { recaptchaSiteKey } = useVariables(); + const { previewId, pending, setPending, mutate } = usePreviewComments(); + const [loading, setLoading] = useState(false); + const form = useForm({ values: { content: '' } }); + const anchor = parentId ? null : pending; + + const askForName = useCallback( + () => + new Promise((resolve) => { + modals.openModal({ + classNames: { + modal: 'bg-transparent text-textColor', + }, + title: t('preview_comment_your_name', 'Your name'), + withCloseButton: true, + onClose: () => resolve(null), + children: (close) => ( + { + resolve(name); + close(); + }} + /> + ), + }); + }), + [t] + ); + + const submit = useCallback( + async (values: { content: string }) => { + const content = values.content.trim(); + if (!content) { + return; + } + + let displayName: string | undefined; + let recaptchaToken: string | undefined; + if (!user?.id) { + const name = await askForName(); + if (!name) { + return; + } + displayName = name; + + if (recaptchaSiteKey) { + const token = await loadRecaptchaToken(recaptchaSiteKey).catch(() => { + toast.show( + t('preview_comment_failed', 'Could not post the comment'), + 'warning' + ); + return null; + }); + if (!token) { + return; + } + recaptchaToken = token; + } + } + + setLoading(true); + try { + const response = await fetch( + user?.id + ? `/posts/${previewId}/comments` + : `/public/posts/${previewId}/comments`, + { + method: 'POST', + body: JSON.stringify({ + content, + ...(parentId ? { parentId } : {}), + ...(anchor + ? { + postId: anchor.postId, + anchorStart: anchor.start, + anchorEnd: anchor.end, + anchorQuote: anchor.quote, + } + : {}), + ...(displayName ? { displayName } : {}), + ...(recaptchaToken ? { recaptchaToken } : {}), + }), + } + ); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + toast.show( + body?.message || + t('preview_comment_failed', 'Could not post the comment'), + 'warning' + ); + return; + } + + form.reset({ content: '' }); + if (anchor) { + setPending(null); + } + await mutate(); + onDone?.(); + } finally { + setLoading(false); + } + }, + [user?.id, recaptchaSiteKey, previewId, parentId, anchor, askForName] + ); - if (isLoading) { - return <>; - } return ( - <> -
-
-