diff --git a/.env.example b/.env.example
index 689eb4dfa0..ee1246cb99 100644
--- a/.env.example
+++ b/.env.example
@@ -489,8 +489,7 @@ REVENUECAT_WEBHOOK_SECRET="sample_revenuecat_webhook_secret"
# Reject sandbox in-app purchases in production mode (true/false)
IN_APP_PURCHASE_REJECT_SANDBOX="false"
-# ==============================================================================
-# 15. Telemetry, Observability & Analytics
+# =======================================================================# 15. Telemetry, Observability & Analytics
# ==============================================================================
# Sentry DSN for error reporting
NEXT_PUBLIC_SENTRY_DSN="https://sample_sentry_key@o0.ingest.sentry.io/0000000"
@@ -516,3 +515,10 @@ NEXT_PUBLIC_FACEBOOK_PIXEL="123456789012345"
FACEBOOK_PIXEL_ACCESS_TOKEN="sample_facebook_pixel_access_token"
# Discord Support Server Widget URL
NEXT_PUBLIC_DISCORD_SUPPORT="https://discord.gg/example"
+# 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/.zcodeignore b/.zcodeignore
new file mode 100644
index 0000000000..cfb301772d
--- /dev/null
+++ b/.zcodeignore
@@ -0,0 +1,104 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# compiled output
+dist
+tmp
+/out-tsc
+.env
+.apple
+# dependencies
+node_modules
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+.vscode/*
+
+# IDE - VSCode
+.vscode/
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# misc
+/.sass-cache
+/connect.lock
+/coverage
+/libpeerconnection.log
+npm-debug.log
+yarn-error.log
+testem.log
+/typings
+
+# System Files
+.DS_Store
+Thumbs.db
+
+.nx/cache
+.nx/workspace-data
+
+# Next.js
+.next
+
+# Vim files
+**/*.swp
+**/*.swo
+
+# Temporary files
+*.orig
+*.~*~
+*.tsbuildinfo
+
+# ignore Secrets folder
+.secrets/
+libraries/plugins/src/plugins.ts
+i18n.cache
+
+# Generated by apps/frontend/scripts/fetch-gtm.mjs on install
+apps/frontend/public/g.js
+.vercel
+.env*
+*.env
+scripts/*.env
+.artifacts/
+.codex-artifacts/
+.playwright-mcp/
+.cloudflared/
+
+
+# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
+.git/
+.hg/
+.svn/
+bower_components/
+jspm_packages/
+__pycache__/
+site-packages/
+venv/
+coverage/
+htmlcov/
+lcov-report/
+cmakefiles/
+cmake-build-*/
+bazel-*/
+pods/
+deriveddata/
+storybook-static/
+playwright-report/
+test-results/
+allure-results/
+allure-report/
+cdk.out/
+*.egg-info/
+*.dist-info/
+eggs/
+pip-wheel-metadata/
+wheels/
+# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
+# 自定义规则写在下方(本行提示可删除)
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 (
-
+
);
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 17a6b60719..64c0fbda9c 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 { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server
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.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 5e4852d884..2279aa1360 100644
--- a/apps/frontend/src/app/(app)/layout.tsx
+++ b/apps/frontend/src/app/(app)/layout.tsx
@@ -92,6 +92,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 || ''}
mediaProcessing={
process.env.STORAGE_PROVIDER === 'cloudflare' &&
!!process.env.RUNPOD_API_KEY &&
diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss
index a900a44d4f..0c6d5cc031 100644
--- a/apps/frontend/src/app/global.scss
+++ b/apps/frontend/src/app/global.scss
@@ -822,3 +822,8 @@ html[dir='rtl'] [dir='ltr'] {
text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black,
1px 1px 0 black;
}
+
+/* Preview page: text that can be selected to leave an inline comment */
+.preview-comment-cursor {
+ cursor: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2228%22 height%3D%2228%22 viewBox%3D%220 0 28 28%22%3E%3Cg fill%3D%22none%22 stroke-linecap%3D%22round%22 stroke-linejoin%3D%22round%22%3E%3Cpath d%3D%22M3 4h6M3 24h6M6 4v20%22 stroke%3D%22%23fff%22 stroke-width%3D%224%22%2F%3E%3Cpath d%3D%22M3 4h6M3 24h6M6 4v20%22 stroke%3D%22%23000%22 stroke-width%3D%221.6%22%2F%3E%3Cpath d%3D%22M14 4h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-6l-4 3v-3a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z%22 fill%3D%22%23612bd3%22 stroke%3D%22%23fff%22 stroke-width%3D%221.5%22%2F%3E%3Cpath d%3D%22M17 8h6M17 11h4%22 stroke%3D%22%23fff%22 stroke-width%3D%221.5%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E") 6 14, text;
+}
diff --git a/apps/frontend/src/components/admin/admin-stats.component.tsx b/apps/frontend/src/components/admin/admin-stats.component.tsx
index 2673eaa3bd..79f66b690e 100644
--- a/apps/frontend/src/components/admin/admin-stats.component.tsx
+++ b/apps/frontend/src/components/admin/admin-stats.component.tsx
@@ -28,6 +28,7 @@ interface StatsResponse {
publishingChannels?: StatsBlock;
scheduledChannels?: StatsBlock;
activeOrgsBySource?: StatsBlock;
+ connectedClients?: StatsBlock;
}
const isoDaysAgo = (days: number) => {
@@ -289,6 +290,14 @@ export const AdminStatsComponent: FC = () => {
/>
)}
+ {data.connectedClients && (
+
+
+
+ )}
@@ -354,6 +363,14 @@ export const AdminStatsComponent: FC = () => {
/>
)}
+ {data.connectedClients && (
+
+ )}
)}
diff --git a/apps/frontend/src/components/auth/after.activate.tsx b/apps/frontend/src/components/auth/after.activate.tsx
index ef2dee1f1e..70f36c44ab 100644
--- a/apps/frontend/src/components/auth/after.activate.tsx
+++ b/apps/frontend/src/components/auth/after.activate.tsx
@@ -23,18 +23,20 @@ export const AfterActivate = () => {
}, []);
const loadCode = useCallback(async () => {
if (params.code) {
- const { can } = await (
- await fetch(`/auth/activate`, {
- method: 'POST',
- body: JSON.stringify({
- code: params.code,
- datafast_visitor_id,
- }),
- headers: {
- 'Content-Type': 'application/json',
- },
- })
- ).json();
+ const response = await fetch(`/auth/activate`, {
+ method: 'POST',
+ body: JSON.stringify({
+ code: params.code,
+ datafast_visitor_id,
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (response.headers.get('onboarding')) {
+ return;
+ }
+ const { can } = await response.json();
if (!can) {
setShowLoader(false);
}
diff --git a/apps/frontend/src/components/billing/first.billing.component.tsx b/apps/frontend/src/components/billing/first.billing.component.tsx
index 654764817c..91e46dd095 100644
--- a/apps/frontend/src/components/billing/first.billing.component.tsx
+++ b/apps/frontend/src/components/billing/first.billing.component.tsx
@@ -52,6 +52,7 @@ export const FirstBillingComponent = () => {
const user = useUser();
const dub = useDubClickId();
const [stripe, setStripe] = useState
>(null);
+ const [stripeFailed, setStripeFailed] = useState(false);
const [tier, setTier] = useState('STANDARD');
const [period, setPeriod] = useState('MONTHLY');
const [dosCheckoutLoading, setDosCheckoutLoading] = useState(false);
@@ -67,7 +68,9 @@ export const FirstBillingComponent = () => {
if (sharedDosBilling) {
return;
}
- setStripe(loadStripe(stripeClient));
+ const stripePromise = loadStripe(stripeClient);
+ stripePromise.catch(() => setStripeFailed(true));
+ setStripe(stripePromise);
}, [sharedDosBilling, stripeClient]);
const loadCheckout = useCallback(async () => {
@@ -272,6 +275,13 @@ export const FirstBillingComponent = () => {
: t('billing_continue_dos_checkout', 'Continue to DOS checkout')}
+ ) : stripeFailed ? (
+
+ {t(
+ 'billing_stripe_load_failed',
+ 'The payment form could not be loaded. Please disable ad blockers or privacy extensions for this page and reload.'
+ )}
+
) : !isLoading && data && stripe ? (
void) => {
};
const data = await (await fetch(`/posts/group/${post.group}`)).json();
+ if (!data?.posts?.length) {
+ toaster.show(t('post_not_found', 'Post not found'), 'warning');
+ mutate();
+ return;
+ }
const date = !isDuplicate
? null
: (await (await fetch('/posts/find-slot')).json()).date;
@@ -170,7 +175,7 @@ const usePostActions = (onMutate?: () => void) => {
title: ``,
});
},
- [integrations, fetch, modal, mutate]
+ [integrations, fetch, modal, mutate, toaster, t]
);
const copyDebugJson = useCallback(
diff --git a/apps/frontend/src/components/launches/continue.integration.tsx b/apps/frontend/src/components/launches/continue.integration.tsx
index 229150dab1..b21a10234f 100644
--- a/apps/frontend/src/components/launches/continue.integration.tsx
+++ b/apps/frontend/src/components/launches/continue.integration.tsx
@@ -194,7 +194,11 @@ export const ContinueIntegration: FC<{
jwt: extensionToken,
backendUrl,
},
- () => {}
+ () => {
+ if (chrome.runtime.lastError) {
+ return;
+ }
+ }
);
} catch {
// Silently ignore — extension may not be available
diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx
index 9b1d16be51..053d276650 100644
--- a/apps/frontend/src/components/launches/menu/menu.tsx
+++ b/apps/frontend/src/components/launches/menu/menu.tsx
@@ -158,7 +158,11 @@ export const Menu: FC<{
chrome.runtime.sendMessage(
extensionId,
{ type: 'REMOVE_REFRESH_TOKEN', integrationId: id },
- () => {}
+ () => {
+ if (chrome.runtime.lastError) {
+ return;
+ }
+ }
);
} catch {
// Silently ignore
diff --git a/apps/frontend/src/components/media/new.uploader.tsx b/apps/frontend/src/components/media/new.uploader.tsx
index 532dc0132a..187f49e989 100644
--- a/apps/frontend/src/components/media/new.uploader.tsx
+++ b/apps/frontend/src/components/media/new.uploader.tsx
@@ -222,7 +222,7 @@ export function useUppyUploader(props: {
});
});
uppy2.on('error', (result) => {
- uppy2.clear();
+ uppy2.cancelAll();
setLocked(false);
props.onEnd();
fileOrderIndex = 0;
@@ -298,10 +298,14 @@ export function useUppyUploader(props: {
onUploadSuccess(sortedSuccessful.map((p) => p.response.body.saved));
});
uppy2.on('upload-success', (file, response) => {
+ const current = uppy2.getState().files[file.id];
+ if (!current) {
+ return;
+ }
// @ts-ignore
uppy2.setFileState(file.id, {
// @ts-ignore
- progress: uppy2.getState().files[file.id].progress,
+ progress: current.progress,
// @ts-ignore
uploadURL: response.body.Location,
response: response,
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 (
+
+
+
+ );
+};
+
+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 (
- <>
-
-
+
+ );
+};
+
+const CommentBody: FC<{ comment: PreviewComment }> = ({ comment }) => {
+ const t = useT();
+ return (
+
+
+ {comment.content}
+
+
+ {comment.name || t('reviewer', 'Reviewer')} ·{' '}
+ {dayjs(comment.createdAt).format('MMM D, YYYY HH:mm')}
- >
+
);
};
-export const CommentsComponents: FC<{
- postId: string;
-}> = (props) => {
- const user = useUser();
+
+const ThreadCard: FC<{
+ comment: PreviewComment;
+ replies: PreviewComment[];
+ canResolve: boolean;
+}> = ({ comment, replies, canResolve }) => {
const t = useT();
+ const fetch = useFetch();
+ const toast = useToaster();
+ const ref = useRef
(null);
+ const [replying, setReplying] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [flash, setFlash] = useState(false);
+ const {
+ mutate,
+ activeThread,
+ setActiveThread,
+ hoveredThread,
+ setHoveredThread,
+ } = usePreviewComments();
+ const resolved = !!comment.resolvedAt;
+ const anchored = comment.anchorStart !== null && comment.anchorEnd !== null;
+
+ useEffect(() => {
+ if (activeThread?.id !== comment.id || activeThread.source !== 'mark') {
+ return;
+ }
+ ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ setFlash(true);
+ const timer = setTimeout(() => setFlash(false), 1500);
+ return () => clearTimeout(timer);
+ }, [activeThread, comment.id]);
+
+ const toggleResolved = useCallback(async () => {
+ const response = await fetch(`/posts/comments/${comment.id}/resolve`, {
+ method: 'PUT',
+ body: JSON.stringify({ resolved: !resolved }),
+ });
+ if (!response.ok) {
+ toast.show(
+ t('preview_comment_failed', 'Could not post the comment'),
+ 'warning'
+ );
+ return;
+ }
+ mutate();
+ }, [comment.id, resolved]);
- const { postId } = props;
- const goToComments = useCallback(() => {
- window.location.href = `/auth?returnUrl=${window.location.href}`;
- }, []);
- if (!user?.id) {
- return (
-
- {t(
- 'login_register_to_add_comments',
- 'Login / Register to add comments'
+ return (
+ !resolved && setHoveredThread(comment.id)}
+ onMouseLeave={() => setHoveredThread(null)}
+ className={clsx(
+ 'flex flex-col gap-[8px] rounded-[8px] border p-[12px] transition-colors',
+ resolved && 'opacity-50',
+ flash || hoveredThread === comment.id || activeThread?.id === comment.id
+ ? 'border-btnPrimary'
+ : 'border-newTableBorder'
+ )}
+ >
+
+ {resolved && (
+
+ {t('resolved', 'Resolved')}
+
)}
-
- );
- }
- return
;
+ {!!comment.anchorQuote && !anchored && !resolved && (
+
+ {t('preview_comment_text_changed', 'Text changed')}
+
+ )}
+
+ {!!comment.anchorQuote && (
+
+ anchored &&
+ !resolved &&
+ setActiveThread({ id: comment.id, source: 'card' })
+ }
+ >
+ {comment.anchorQuote}
+
+ )}
+ {resolved && !expanded ? (
+
setExpanded(true)}
+ >
+ {comment.content}
+
+ ) : (
+
+ )}
+ {(!resolved || expanded) && !!replies.length && (
+
+ {replies.map((reply) => (
+
+ ))}
+
+ )}
+ {(!resolved || expanded) && (
+
+ {!resolved && (
+ setReplying(!replying)}
+ >
+ {t('reply', 'Reply')}
+
+ )}
+ {canResolve && (
+
+ {resolved ? t('reopen', 'Reopen') : t('resolve', 'Resolve')}
+
+ )}
+ {resolved && (
+ setExpanded(false)}
+ >
+ {t('collapse', 'Collapse')}
+
+ )}
+
+ )}
+ {replying && !resolved && (
+
setReplying(false)}
+ />
+ )}
+
+ );
+};
+
+export const CommentsComponents: FC<{ previewId: string }> = () => {
+ const t = useT();
+ const user = useUser();
+ const { comments, isLoading, postIds, organizationId } = usePreviewComments();
+ const canResolve = !!user?.id && user?.orgId === organizationId;
+
+ const threads = useMemo(() => {
+ const replies = comments.reduce((all, current) => {
+ if (current.parentId) {
+ all[current.parentId] = [...(all[current.parentId] || []), current];
+ }
+ return all;
+ }, {} as Record);
+
+ const rank = (c: PreviewComment) => {
+ if (c.resolvedAt) {
+ return [2, 0, dayjs(c.createdAt).valueOf()];
+ }
+ if (c.anchorStart !== null && c.anchorEnd !== null) {
+ return [0, postIds.indexOf(c.postId), c.anchorStart];
+ }
+ return [1, 0, dayjs(c.createdAt).valueOf()];
+ };
+
+ return comments
+ .filter((c) => !c.parentId)
+ .sort((a, b) => {
+ const ra = rank(a);
+ const rb = rank(b);
+ return ra[0] - rb[0] || ra[1] - rb[1] || ra[2] - rb[2];
+ })
+ .map((comment) => ({ comment, replies: replies[comment.id] || [] }));
+ }, [comments, postIds]);
+
+ return (
+
+
{t('comments', 'Comments')}
+
+ {!isLoading && !threads.length && (
+
+ {t(
+ 'preview_no_comments_yet',
+ 'No comments yet. Select some text in the post to comment on it, or add a general comment.'
+ )}
+
+ )}
+
+ {threads.map(({ comment, replies }) => (
+
+ ))}
+
+
+ );
};
diff --git a/apps/frontend/src/components/preview/post.content.client.tsx b/apps/frontend/src/components/preview/post.content.client.tsx
new file mode 100644
index 0000000000..fbcbc480e9
--- /dev/null
+++ b/apps/frontend/src/components/preview/post.content.client.tsx
@@ -0,0 +1,375 @@
+'use client';
+
+import {
+ FC,
+ MouseEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import clsx from 'clsx';
+import { useT } from '@gitroom/react/translation/get.transation.service.client';
+import {
+ PendingAnchor,
+ usePreviewComments,
+} from '@gitroom/frontend/components/preview/preview.comments.context';
+
+interface HighlightRange {
+ id: string;
+ start: number;
+ end: number;
+}
+
+// The btnPrimary token has no alpha channel, so the tints are mixed in CSS.
+const MARK_CLASS = [
+ 'bg-[color-mix(in_srgb,var(--new-btn-primary)_25%,transparent)]',
+ 'bg-[color-mix(in_srgb,var(--new-btn-primary)_45%,transparent)]',
+ 'bg-[color-mix(in_srgb,var(--new-btn-primary)_65%,transparent)]',
+];
+
+const markClassName = (count: number, pending: boolean) =>
+ clsx(
+ 'text-inherit rounded-[2px]',
+ pending
+ ? 'bg-[color-mix(in_srgb,var(--new-btn-primary)_40%,transparent)] underline decoration-btnPrimary decoration-2'
+ : [MARK_CLASS[Math.min(count, MARK_CLASS.length) - 1], 'cursor-pointer']
+ );
+
+// Offsets are into the plain text of the rendered item, so a range from the
+// start of the container to (node, offset) measures exactly that.
+const textOffset = (root: HTMLElement, node: Node, offset: number) => {
+ const range = document.createRange();
+ range.setStart(root, 0);
+ range.setEnd(node, offset);
+ return range.toString().length;
+};
+
+const clearHighlights = (root: HTMLElement) => {
+ root.querySelectorAll('mark[data-preview-mark]').forEach((mark) => {
+ const parent = mark.parentNode!;
+ while (mark.firstChild) {
+ parent.insertBefore(mark.firstChild, mark);
+ }
+ parent.removeChild(mark);
+ });
+ root.normalize();
+};
+
+// Split the text nodes at every range boundary, then wrap each piece once
+// with a mark that lists every thread covering it.
+const applyHighlights = (
+ root: HTMLElement,
+ ranges: HighlightRange[],
+ pending: PendingAnchor | null
+) => {
+ clearHighlights(root);
+
+ const all = [
+ ...ranges,
+ ...(pending ? [{ id: '', start: pending.start, end: pending.end }] : []),
+ ];
+ if (!all.length) {
+ return;
+ }
+
+ const boundaries = Array.from(
+ new Set(all.flatMap((r) => [r.start, r.end]))
+ ).sort((a, b) => a - b);
+
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const textNodes: Text[] = [];
+ let next: Node | null;
+ while ((next = walker.nextNode())) {
+ textNodes.push(next as Text);
+ }
+
+ const segments: { node: Text; start: number; end: number }[] = [];
+ let offset = 0;
+ for (const node of textNodes) {
+ const length = node.length;
+ let current = node;
+ let currentStart = offset;
+ for (const boundary of boundaries) {
+ if (boundary <= currentStart || boundary >= offset + length) {
+ continue;
+ }
+ const rest = current.splitText(boundary - currentStart);
+ segments.push({ node: current, start: currentStart, end: boundary });
+ current = rest;
+ currentStart = boundary;
+ }
+ segments.push({ node: current, start: currentStart, end: offset + length });
+ offset += length;
+ }
+
+ for (const segment of segments) {
+ if (segment.end <= segment.start) {
+ continue;
+ }
+ const covering = all.filter(
+ (r) => r.start <= segment.start && r.end >= segment.end
+ );
+ if (!covering.length) {
+ continue;
+ }
+ const ids = covering.map((c) => c.id).filter(Boolean);
+ const isPending = covering.some((c) => !c.id);
+ const mark = document.createElement('mark');
+ mark.setAttribute('data-preview-mark', '');
+ if (ids.length) {
+ mark.setAttribute('data-thread-ids', ids.join(','));
+ }
+ mark.className = markClassName(ids.length, isPending && !ids.length);
+ segment.node.parentNode!.insertBefore(mark, segment.node);
+ mark.appendChild(segment.node);
+ }
+};
+
+const marksOf = (root: HTMLElement, id: string) =>
+ Array.from(
+ root.querySelectorAll('mark[data-thread-ids]')
+ ).filter((mark) => mark.dataset.threadIds!.split(',').includes(id));
+
+export const PostContentClient: FC<{ postId: string; html: string }> = ({
+ postId,
+ html,
+}) => {
+ const t = useT();
+ const {
+ comments,
+ pending,
+ setPending,
+ activeThread,
+ setActiveThread,
+ hoveredThread,
+ setHoveredThread,
+ } = usePreviewComments();
+ const ref = useRef(null);
+ const [selection, setSelection] = useState<
+ (PendingAnchor & { top: number; left: number }) | null
+ >(null);
+ // Follows the mouse over the text so nobody has to guess that selecting
+ // it opens a comment.
+ const [hint, setHint] = useState<{
+ top: number;
+ left: number;
+ onMark: boolean;
+ } | null>(null);
+
+ const ranges = useMemo(
+ () =>
+ comments
+ .filter(
+ (c) =>
+ c.postId === postId &&
+ !c.parentId &&
+ !c.resolvedAt &&
+ c.anchorStart !== null &&
+ c.anchorEnd !== null
+ )
+ .map((c) => ({ id: c.id, start: c.anchorStart!, end: c.anchorEnd! })),
+ [comments, postId]
+ );
+
+ const ownPending = pending?.postId === postId ? pending : null;
+
+ useEffect(() => {
+ if (ref.current) {
+ applyHighlights(ref.current, ranges, ownPending);
+ }
+ }, [ranges, ownPending, html]);
+
+ useEffect(() => {
+ ref.current
+ ?.querySelectorAll('mark[data-thread-ids]')
+ .forEach((mark) => {
+ const ids = mark.dataset.threadIds!.split(',');
+ mark.classList.toggle(
+ 'ring-1',
+ ids.includes(hoveredThread!) || ids.includes(activeThread?.id!)
+ );
+ mark.classList.toggle(
+ 'ring-btnPrimary',
+ ids.includes(hoveredThread!) || ids.includes(activeThread?.id!)
+ );
+ });
+ }, [hoveredThread, activeThread, ranges, ownPending]);
+
+ useEffect(() => {
+ if (!ref.current || activeThread?.source !== 'card') {
+ return;
+ }
+ const [mark] = marksOf(ref.current, activeThread.id);
+ if (!mark) {
+ return;
+ }
+ mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ mark.classList.add('animate-pulse');
+ const timer = setTimeout(
+ () => mark.classList.remove('animate-pulse'),
+ 1500
+ );
+ return () => clearTimeout(timer);
+ }, [activeThread]);
+
+ useEffect(() => {
+ const onSelectionChange = () => {
+ const current = window.getSelection();
+ if (!current || current.isCollapsed) {
+ setSelection(null);
+ }
+ };
+ document.addEventListener('selectionchange', onSelectionChange);
+ return () =>
+ document.removeEventListener('selectionchange', onSelectionChange);
+ }, []);
+
+ const onMouseUp = useCallback(() => {
+ const root = ref.current;
+ const current = window.getSelection();
+ if (!root || !current || current.isCollapsed || !current.rangeCount) {
+ setSelection(null);
+ return;
+ }
+ const selected = current.getRangeAt(0);
+ if (!selected.intersectsNode(root)) {
+ setSelection(null);
+ return;
+ }
+ // A triple-click ends the selection just past the item; clamp to the item,
+ // but only when nothing visible lies outside it.
+ const range = selected.cloneRange();
+ if (!root.contains(range.startContainer)) {
+ range.setStart(root, 0);
+ }
+ if (!root.contains(range.endContainer)) {
+ range.setEnd(root, root.childNodes.length);
+ }
+ if (selected.toString().trim() !== range.toString().trim()) {
+ setSelection(null);
+ return;
+ }
+ const start = textOffset(root, range.startContainer, range.startOffset);
+ const end = textOffset(root, range.endContainer, range.endOffset);
+ const quote = (root.textContent || '').slice(start, end);
+ if (end <= start || !quote.trim() || quote.length > 500) {
+ setSelection(null);
+ return;
+ }
+ const rect = range.getBoundingClientRect();
+ const rootRect = root.getBoundingClientRect();
+ setSelection({
+ postId,
+ start,
+ end,
+ quote,
+ top: rect.bottom - rootRect.top + 6,
+ left: Math.max(0, rect.right - rootRect.left - 90),
+ });
+ }, [postId]);
+
+ const startComment = useCallback(() => {
+ if (!selection) {
+ return;
+ }
+ const { top, left, ...anchor } = selection;
+ setPending(anchor);
+ setSelection(null);
+ window.getSelection()?.removeAllRanges();
+ setActiveThread(null);
+ }, [selection, setPending, setActiveThread]);
+
+ const threadOf = (e: MouseEvent) =>
+ (e.target as HTMLElement)
+ .closest('mark[data-thread-ids]')
+ ?.dataset.threadIds?.split(',')[0] || null;
+
+ const onMouseMove = useCallback((e: MouseEvent) => {
+ const root = ref.current;
+ // While dragging a selection the "Comment" button takes over afterwards.
+ if (!root || e.buttons) {
+ setHint(null);
+ return;
+ }
+ const rootRect = root.getBoundingClientRect();
+ setHint({
+ top: e.clientY - rootRect.top + 22,
+ left: Math.max(
+ 0,
+ Math.min(e.clientX - rootRect.left + 14, rootRect.width - 190)
+ ),
+ onMark: !!threadOf(e),
+ });
+ }, []);
+
+ return (
+
+
setHint(null)}
+ onMouseOver={(e) => setHoveredThread(threadOf(e))}
+ onMouseOut={() => setHoveredThread(null)}
+ onClick={(e) => {
+ const id = threadOf(e);
+ if (id) {
+ setActiveThread({ id, source: 'mark' });
+ }
+ }}
+ dangerouslySetInnerHTML={{ __html: html }}
+ />
+ {!!hint && !selection && !ownPending && (
+
+
+
+
+ {hint.onMark
+ ? t('preview_click_to_open_comment', 'Click to open the comment')
+ : t('preview_select_text_to_comment', 'Select text to comment')}
+
+ )}
+ {!!selection && (
+
e.preventDefault()}
+ onClick={startComment}
+ style={{ top: selection.top, left: selection.left }}
+ className="absolute z-[10] bg-btnPrimary text-white text-[12px] font-[500] rounded-[6px] px-[10px] h-[28px] flex items-center gap-[6px] shadow-lg"
+ >
+
+
+
+ {t('comment', 'Comment')}
+
+ )}
+
+ );
+};
diff --git a/apps/frontend/src/components/preview/preview.comments.context.tsx b/apps/frontend/src/components/preview/preview.comments.context.tsx
new file mode 100644
index 0000000000..25d9eb7fd1
--- /dev/null
+++ b/apps/frontend/src/components/preview/preview.comments.context.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import {
+ createContext,
+ FC,
+ ReactNode,
+ useCallback,
+ useContext,
+ useState,
+} from 'react';
+import useSWR from 'swr';
+import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
+
+export interface PreviewComment {
+ id: string;
+ postId: string;
+ parentId: string | null;
+ content: string;
+ anchorStart: number | null;
+ anchorEnd: number | null;
+ anchorQuote: string | null;
+ resolvedAt: string | null;
+ createdAt: string;
+ name: string | null;
+}
+
+export interface PendingAnchor {
+ postId: string;
+ start: number;
+ end: number;
+ quote: string;
+}
+
+// Which side asked for the focus decides which side scrolls: a click on a
+// highlight scrolls the card into view, a click on a card quote scrolls the
+// highlight into view.
+export interface ActiveThread {
+ id: string;
+ source: 'mark' | 'card';
+}
+
+const useComments = (previewId: string) => {
+ const fetch = useFetch();
+ const load = useCallback(async () => {
+ return (await fetch(`/public/posts/${previewId}/comments`)).json();
+ }, [previewId]);
+ return useSWR<{ comments: PreviewComment[] }>(
+ `/public/posts/${previewId}/comments`,
+ load
+ );
+};
+
+interface PreviewCommentsContextInterface {
+ previewId: string;
+ postIds: string[];
+ organizationId: string;
+ comments: PreviewComment[];
+ isLoading: boolean;
+ mutate: () => Promise
;
+ pending: PendingAnchor | null;
+ setPending: (pending: PendingAnchor | null) => void;
+ activeThread: ActiveThread | null;
+ setActiveThread: (thread: ActiveThread | null) => void;
+ hoveredThread: string | null;
+ setHoveredThread: (id: string | null) => void;
+}
+
+const PreviewCommentsContext = createContext(
+ {} as PreviewCommentsContextInterface
+);
+
+export const PreviewCommentsProvider: FC<{
+ previewId: string;
+ postIds: string[];
+ organizationId: string;
+ children: ReactNode;
+}> = ({ previewId, postIds, organizationId, children }) => {
+ const { data, mutate, isLoading } = useComments(previewId);
+ const [pending, setPending] = useState(null);
+ const [activeThread, setActiveThread] = useState(null);
+ const [hoveredThread, setHoveredThread] = useState(null);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const usePreviewComments = () => useContext(PreviewCommentsContext);
diff --git a/beta-login-smoke-2026-09-21.png b/beta-login-smoke-2026-09-21.png
new file mode 100644
index 0000000000..be73347c02
Binary files /dev/null and b/beta-login-smoke-2026-09-21.png differ
diff --git a/i18n.lock b/i18n.lock
index 88840d1e14..6f5597c7db 100644
--- a/i18n.lock
+++ b/i18n.lock
@@ -509,6 +509,7 @@ checksums:
delete_integration: ccc879ccfcf7f85bcfe09f2bc3fa0dd3
start_writing_your_post: 471efc4f2a7e2cf02a065a2de34e7213
billing_other_account_subscribed: 6c12b4a726d55ee88b0594bd22ec556c
+ billing_stripe_load_failed: b578d90bcd0abe8bacc2c1e6d37ddc1a
already_subscribed: 3694e02bcb0a41286ea6b79615e6cdbf
ok: e2d6579a83aba07c4180c21c5fe9893d
billing_join_over: 6e1c237241ba00ddbb07fd603344c5c3
@@ -534,6 +535,7 @@ checksums:
billing_advanced_picture_editor: 329a6c2e8b2685f81609859a4de07b0f
billing_ai_images_per_month: 5073aa90b32654abe6200d426a97f03e
billing_ai_videos_per_month: c786199d8dc9bded54fab8f92c350b19
+ billing_clipping_minutes_per_month: e8337650fb3b1836ba1194e5e7730fd6
billing_billing_address: 48980a775bfc7292b0c4f9b74b4d352e
billing_payment: 95142d3fd8b6a6f551aba771842e9c11
billing_powered_by_stripe: f2767a22c2cd93d4e8fe5c5d6ee05ad1
@@ -772,3 +774,19 @@ checksums:
post_already_published_republish_warning: b552a207c31d6381fb831da005dac9ed
republish_at: 282cf74a2c63ff46388104f9853bfaf8
republish_recurring_note: e0eafbc730f5e8d5f9f5cb5aab48fe2d
+ comment: 1be52fc8cdfb25a4c4abe2482fc478aa
+ reply: 37a07e1dbda9841da3feb16c9e1e63b5
+ add_a_comment: 92a18f71e9cef81fdbdb39ee69b0b771
+ add_a_comment_placeholder: c28cfccc0cc69a2a2487e37eb9988028
+ write_a_reply: e15bb5e29e4ad42490d0f9d40d17e21e
+ reviewer: 882ff4831ea1d7d9eeb7d19575006be8
+ resolve: 47362c1e812b301e132d9c4be5f0329e
+ reopen: 0066d9cce35e2f7a6cec3af252ce6580
+ resolved: a1de202e0c3b352731a33bea1897bdb8
+ collapse: d64b33ae34d2ff729b0232220dbd9a99
+ preview_comment_name_required: fa36cde1b98dc2a86671b5d732cc4cae
+ preview_comment_your_name: b00c01deec0af9a441331a5134210de1
+ preview_comment_failed: f12e2f9e71c2ef3d2848c6ed5a16b99b
+ preview_comment_detach_hint: 6fa385ae985c409b511cf7adea2f950a
+ preview_comment_text_changed: 6a4298cc0304d150e0d150689fa049f4
+ preview_no_comments_yet: e9bda0977d617e8ee0079feba73b3c3a
diff --git a/libraries/helpers/src/utils/sanitize.post.content.ts b/libraries/helpers/src/utils/sanitize.post.content.ts
index 9f2d98618b..204fee139f 100644
--- a/libraries/helpers/src/utils/sanitize.post.content.ts
+++ b/libraries/helpers/src/utils/sanitize.post.content.ts
@@ -1,4 +1,5 @@
import DOMPurify from 'isomorphic-dompurify';
+import { parseFragment } from 'parse5';
const ALLOWED_TAGS = [
'p',
@@ -34,3 +35,17 @@ export const sanitizePostContent = (value: unknown): string => {
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/|#)/i,
});
};
+
+// The plain text a reviewer sees for a post item: the text nodes of the
+// sanitised HTML, in order, entities decoded. This is what anchor offsets
+// index into on both the frontend (element.textContent) and the backend.
+export const postContentPlainText = (value: unknown): string => {
+ const walk = (nodes: any[]): string =>
+ nodes
+ .map((node) =>
+ node.nodeName === '#text' ? node.value : walk(node.childNodes || [])
+ )
+ .join('');
+
+ return walk(parseFragment(sanitizePostContent(value)).childNodes as any[]);
+};
diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
index d7b670b2c8..9fa08a8e0a 100644
--- a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
+++ b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts
@@ -60,6 +60,7 @@ A single LinkedIn post with one comment
- postsAndComments array length will be one
Do not use this to update or delete existing posts.
+On success, each item in output contains a previewUrl the user can open to see the post.
If validation fails, the result contains output.errors describing what to fix; the call can be retried with corrected parameters.
`,
inputSchema: z.object({
@@ -127,6 +128,11 @@ If validation fails, the result contains output.errors describing what to fix; t
z.object({
postId: z.string(),
integration: z.string(),
+ previewUrl: z
+ .string()
+ .describe(
+ 'Public preview page of the created post, share it with the user'
+ ),
})
)
.or(z.object({ errors: z.string() })),
@@ -246,7 +252,13 @@ If validation fails, the result contains output.errors describing what to fix; t
},
],
}, 'MCP');
- finalOutput.push(...output);
+ // Same public preview page the calendar "Preview Post" button opens.
+ finalOutput.push(
+ ...output.map((p) => ({
+ ...p,
+ previewUrl: `${process.env.FRONTEND_URL}/p/${p.postId}?share=true`,
+ }))
+ );
}
return {
diff --git a/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.repository.ts b/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.repository.ts
index fded269d23..e898f28880 100644
--- a/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.repository.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.repository.ts
@@ -55,6 +55,7 @@ export interface StatsResponse {
publishingChannels: { total: number; perSocial: PerSocial[] };
scheduledChannels: { total: number; perSocial: PerSocial[] };
activeOrgsBySource: { total: number; perSocial: PerSocial[] };
+ connectedClients: { total: number; perSocial: PerSocial[] };
}
const sortDesc = (list: PerSocial[]) =>
@@ -65,7 +66,9 @@ export class AdminStatsRepository {
constructor(
private _post: PrismaRepository<'post'>,
private _integration: PrismaRepository<'integration'>,
- private _errors: PrismaRepository<'errors'>
+ private _errors: PrismaRepository<'errors'>,
+ private _oauthApp: PrismaRepository<'oAuthApp'>,
+ private _oauthAuth: PrismaRepository<'oAuthAuthorization'>
) {}
private async errorStats(params: StatsParams) {
@@ -312,6 +315,61 @@ export class AdminStatsRepository {
};
}
+ // Active OAuth authorizations first created in the range (MCP clients like
+ // Claude, Cursor or ChatGPT connecting to Postiz), per client name. A
+ // re-authorization upserts the same row, so it keeps its original createdAt
+ // and is not counted again. Dynamic registration creates a new OAuthApp row
+ // for every client install, so the counts are folded by the registered
+ // client_name rather than by app id.
+ private async clientStats(params: StatsParams) {
+ const where: Prisma.OAuthAuthorizationWhereInput = {
+ accessToken: { not: null },
+ ...(params.includeDeleted ? {} : { revokedAt: null }),
+ createdAt: { gte: params.from, lte: params.to },
+ ...(params.organizationId
+ ? { organizationId: params.organizationId }
+ : {}),
+ };
+
+ const [total, grouped] = await Promise.all([
+ this._oauthAuth.model.oAuthAuthorization.count({ where }),
+ this._oauthAuth.model.oAuthAuthorization.groupBy({
+ by: ['oauthAppId'],
+ where,
+ _count: { _all: true },
+ }),
+ ]);
+
+ // groupBy can't reach into the oauthApp relation, so resolve the client
+ // name for the apps we saw and fold the counts.
+ const appIds = grouped.map((g) => g.oauthAppId);
+ const apps = appIds.length
+ ? await this._oauthApp.model.oAuthApp.findMany({
+ where: { id: { in: appIds } },
+ select: { id: true, name: true },
+ })
+ : [];
+ const nameById = new Map(
+ apps.map((a) => [a.id, a.name.trim().toLowerCase()])
+ );
+
+ const byName = new Map();
+ for (const g of grouped) {
+ const name = nameById.get(g.oauthAppId) || 'unknown';
+ byName.set(name, (byName.get(name) || 0) + g._count._all);
+ }
+
+ return {
+ total,
+ perSocial: sortDesc(
+ [...byName.entries()].map(([provider, count]) => ({
+ provider,
+ count,
+ }))
+ ),
+ };
+ }
+
private async postStateStats(params: OrgActivityParams) {
const grouped = await this._post.model.post.groupBy({
by: ['state'],
@@ -405,14 +463,21 @@ export class AdminStatsRepository {
}
async getStats(params: StatsParams): Promise {
- const [errors, posts, accounts, connected, activeOrgsBySource] =
- await Promise.all([
- this.errorStats(params),
- this.postStats(params),
- this.accountStats(params),
- this.connectedStats(params),
- this.sourceStats(params),
- ]);
+ const [
+ errors,
+ posts,
+ accounts,
+ connected,
+ activeOrgsBySource,
+ connectedClients,
+ ] = await Promise.all([
+ this.errorStats(params),
+ this.postStats(params),
+ this.accountStats(params),
+ this.connectedStats(params),
+ this.sourceStats(params),
+ this.clientStats(params),
+ ]);
return {
from: params.from.toISOString(),
@@ -425,6 +490,7 @@ export class AdminStatsRepository {
publishingChannels: accounts.publishingChannels,
scheduledChannels: accounts.scheduledChannels,
activeOrgsBySource,
+ connectedClients,
};
}
}
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
index b6b3af92ed..51056e57f0 100644
--- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts
@@ -888,10 +888,21 @@ export class PostsRepository {
);
}
- async getComments(postId: string) {
+ getCommentsForPosts(postIds: string[]) {
return this._comments.model.comments.findMany({
where: {
- postId,
+ postId: {
+ in: postIds,
+ },
+ deletedAt: null,
+ },
+ include: {
+ user: {
+ select: {
+ name: true,
+ lastName: true,
+ },
+ },
},
orderBy: {
createdAt: 'asc',
@@ -899,6 +910,61 @@ export class PostsRepository {
});
}
+ getCommentById(id: string) {
+ return this._comments.model.comments.findFirst({
+ where: {
+ id,
+ deletedAt: null,
+ },
+ include: {
+ post: {
+ select: {
+ id: true,
+ organizationId: true,
+ },
+ },
+ },
+ });
+ }
+
+ setCommentResolved(id: string, resolvedAt: Date | null) {
+ return this._comments.model.comments.update({
+ where: {
+ id,
+ },
+ data: {
+ resolvedAt,
+ },
+ });
+ }
+
+ getAnchoredCommentsForPost(postId: string) {
+ return this._comments.model.comments.findMany({
+ where: {
+ postId,
+ deletedAt: null,
+ anchorStart: {
+ not: null,
+ },
+ },
+ });
+ }
+
+ detachAnchorsForPost(postId: string, ids: string[]) {
+ return this._comments.model.comments.updateMany({
+ where: {
+ postId,
+ id: {
+ in: ids,
+ },
+ },
+ data: {
+ anchorStart: null,
+ anchorEnd: null,
+ },
+ });
+ }
+
async getTags(orgId: string) {
return this._tags.model.tags.findMany({
where: {
@@ -952,9 +1018,16 @@ export class PostsRepository {
createComment(
orgId: string,
- userId: string,
+ userId: string | null,
postId: string,
- content: string
+ content: string,
+ extra: {
+ displayName?: string;
+ parentId?: string;
+ anchorStart?: number;
+ anchorEnd?: number;
+ anchorQuote?: string;
+ } = {}
) {
return this._comments.model.comments.create({
data: {
@@ -962,6 +1035,11 @@ export class PostsRepository {
userId,
postId,
content,
+ displayName: extra.displayName ?? null,
+ parentId: extra.parentId ?? null,
+ anchorStart: extra.anchorStart ?? null,
+ anchorEnd: extra.anchorEnd ?? null,
+ anchorQuote: extra.anchorQuote ?? null,
},
});
}
diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
index 5d5508bc6b..31ecc48bf3 100644
--- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
@@ -53,6 +53,8 @@ import { stripLinks } from '@gitroom/helpers/utils/strip.links';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
+import { postContentPlainText } from '@gitroom/helpers/utils/sanitize.post.content';
+import { CreatePublicCommentDto } from '@gitroom/nestjs-libraries/dtos/comments/add.comment.dto';
import { countLength } from '@gitroom/helpers/utils/count.length';
type PostWithConditionals = Post & {
@@ -508,6 +510,9 @@ export class PostsService {
const convertToJPEG = false;
const loadAll = await this._postRepository.getPostsByGroup(orgId, group);
const posts = this.arrangePostsByGroup(loadAll, undefined);
+ if (!posts.length) {
+ throw new NotFoundException('Post not found');
+ }
return {
group: posts?.[0]?.group,
@@ -842,7 +847,10 @@ export class PostsService {
errors = err?.message || 'Invalid media';
}
- const maximumCharacters = provider.maxLength(additionalSettings, settings);
+ const maximumCharacters = provider.maxLength(
+ additionalSettings,
+ settings
+ );
const emptyContent = (post.value || []).some((a) => {
const strip = stripHtmlValidation('normal', a.content || '', true);
@@ -891,7 +899,11 @@ export class PostsService {
// the platform: require the explicit `republish` opt-in instead. The message
// doubles as the confirmation dialog for API/MCP automation.
private guardAgainstRepublish(
- post: { state: State; publishDate: Date; integration?: { providerIdentifier: string } } | null,
+ post: {
+ state: State;
+ publishDate: Date;
+ integration?: { providerIdentifier: string };
+ } | null,
source: 'createPost' | 'changeDate'
) {
if (post?.state !== 'PUBLISHED') {
@@ -904,7 +916,9 @@ export class PostsService {
throw new BadRequestException(
`This post was already published on ${dayjs
.utc(post.publishDate)
- .format('YYYY-MM-DD HH:mm')} UTC. Saving it this way would publish it again to ${
+ .format(
+ 'YYYY-MM-DD HH:mm'
+ )} UTC. Saving it this way would publish it again to ${
post.integration?.providerIdentifier || 'the channel'
}. To edit without republishing, ${howToUpdate}. To intentionally publish again, pass republish: true.`
);
@@ -963,6 +977,11 @@ export class PostsService {
return [] as any[];
}
+ const existingIds = (post.value || []).map((p) => p.id).filter(Boolean);
+ await this.detachStaleAnchors(
+ posts.filter((p) => existingIds.includes(p.id))
+ );
+
if (body.type !== 'update') {
this.startWorkflow(
post.settings.__type.split('-')[0].toLowerCase(),
@@ -1344,8 +1363,170 @@ export class PostsService {
return date.clone().add(num, 'minutes').format('YYYY-MM-DDTHH:mm:00');
}
- getComments(postId: string) {
- return this._postRepository.getComments(postId);
+ async getComments(previewId: string) {
+ const posts = await this.getPostsRecursively(previewId, false);
+ const comments = await this._postRepository.getCommentsForPosts(
+ posts.map((p) => p.id)
+ );
+
+ return comments.map((comment) => ({
+ id: comment.id,
+ postId: comment.postId,
+ parentId: comment.parentId,
+ content: comment.content,
+ anchorStart: comment.anchorStart,
+ anchorEnd: comment.anchorEnd,
+ anchorQuote: comment.anchorQuote,
+ resolvedAt: comment.resolvedAt,
+ createdAt: comment.createdAt,
+ name: comment.userId
+ ? [comment.user?.name, comment.user?.lastName]
+ .filter(Boolean)
+ .join(' ')
+ .trim() || null
+ : comment.displayName,
+ }));
+ }
+
+ async createPublicComment(
+ previewId: string,
+ body: CreatePublicCommentDto,
+ userId: string | null,
+ ip: string
+ ) {
+ const posts = await this.getPostsRecursively(previewId, false);
+ if (!posts.length) {
+ throw new NotFoundException('Post not found');
+ }
+
+ let post = body.postId ? posts.find((p) => p.id === body.postId) : posts[0];
+ if (!post) {
+ throw new BadRequestException('Post does not belong to this preview');
+ }
+
+ if (!userId) {
+ if (!body.displayName?.trim()) {
+ throw new BadRequestException('Name is required');
+ }
+ await this.verifyRecaptcha(body.recaptchaToken, ip);
+ }
+
+ const hasStart = typeof body.anchorStart === 'number';
+ const hasEnd = typeof body.anchorEnd === 'number';
+ if (hasStart !== hasEnd) {
+ throw new BadRequestException('Both anchor offsets are required');
+ }
+
+ if (body.parentId) {
+ const parent = await this._postRepository.getCommentById(body.parentId);
+ if (!parent || !posts.some((p) => p.id === parent.postId)) {
+ throw new BadRequestException('Parent comment not found');
+ }
+ if (parent.parentId) {
+ throw new BadRequestException(
+ 'Replies can only be added to a root comment'
+ );
+ }
+ if (hasStart || body.anchorQuote) {
+ throw new BadRequestException('Replies cannot be anchored');
+ }
+ post = posts.find((p) => p.id === parent.postId)!;
+ }
+
+ if (hasStart) {
+ const plainText = postContentPlainText(post.content);
+ if (
+ body.anchorStart! < 0 ||
+ body.anchorStart! >= body.anchorEnd! ||
+ body.anchorEnd! > plainText.length
+ ) {
+ throw new BadRequestException('Anchor is out of range');
+ }
+ if (
+ body.anchorQuote !== plainText.slice(body.anchorStart!, body.anchorEnd!)
+ ) {
+ throw new BadRequestException('Anchor does not match the post text');
+ }
+ }
+
+ return this._postRepository.createComment(
+ post.organizationId,
+ userId,
+ post.id,
+ body.content,
+ {
+ displayName: userId ? undefined : body.displayName!.trim(),
+ parentId: body.parentId,
+ anchorStart: hasStart ? body.anchorStart : undefined,
+ anchorEnd: hasStart ? body.anchorEnd : undefined,
+ anchorQuote: hasStart ? body.anchorQuote : undefined,
+ }
+ );
+ }
+
+ private async verifyRecaptcha(token: string | undefined, ip: string) {
+ if (!process.env.RECAPTCHA_SECRET_KEY) {
+ return;
+ }
+
+ if (!token) {
+ throw new BadRequestException('Captcha verification failed');
+ }
+
+ const result = await (
+ await fetch('https://www.google.com/recaptcha/api/siteverify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ secret: process.env.RECAPTCHA_SECRET_KEY,
+ response: token,
+ remoteip: ip,
+ }),
+ })
+ ).json();
+
+ if (!result?.success) {
+ throw new BadRequestException('Captcha verification failed');
+ }
+ }
+
+ async resolveComment(orgId: string, commentId: string, resolved: boolean) {
+ const comment = await this._postRepository.getCommentById(commentId);
+ if (!comment || comment.post.organizationId !== orgId) {
+ throw new NotFoundException('Comment not found');
+ }
+ if (comment.parentId) {
+ throw new BadRequestException('Only root comments can be resolved');
+ }
+
+ return this._postRepository.setCommentResolved(
+ commentId,
+ resolved ? new Date() : null
+ );
+ }
+
+ // A comment anchored to a span of text keeps its quote but loses the
+ // highlight once the span no longer reads the same on the new content.
+ async detachStaleAnchors(posts: { id: string; content: string }[]) {
+ for (const post of posts) {
+ const anchored = await this._postRepository.getAnchoredCommentsForPost(
+ post.id
+ );
+ if (!anchored.length) {
+ continue;
+ }
+
+ const plainText = postContentPlainText(post.content);
+ const stale = anchored
+ .filter(
+ (c) => c.anchorQuote !== plainText.slice(c.anchorStart!, c.anchorEnd!)
+ )
+ .map((c) => c.id);
+
+ if (stale.length) {
+ await this._postRepository.detachAnchorsForPost(post.id, stale);
+ }
+ }
}
getTags(orgId: string) {
@@ -1363,13 +1544,4 @@ export class PostsService {
deleteTag(id: string, orgId: string) {
return this._postRepository.deleteTag(id, orgId);
}
-
- createComment(
- orgId: string,
- userId: string,
- postId: string,
- comment: string
- ) {
- return this._postRepository.createComment(orgId, userId, postId, comment);
- }
}
diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma
index dc0d491ddc..37f1803eb8 100644
--- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma
+++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma
@@ -428,18 +428,28 @@ model Comments {
content String
organizationId String
postId String
- userId String
+ userId String?
+ displayName String?
+ parentId String?
+ anchorStart Int?
+ anchorEnd Int?
+ anchorQuote String?
+ resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id])
post Post @relation(fields: [postId], references: [id])
- user User @relation(fields: [userId], references: [id])
+ user User? @relation(fields: [userId], references: [id])
+ parent Comments? @relation("CommentReplies", fields: [parentId], references: [id])
+ replies Comments[] @relation("CommentReplies")
@@index([createdAt])
@@index([organizationId])
@@index([userId])
@@index([postId])
+ @@index([parentId])
+ @@index([resolvedAt])
@@index([deletedAt])
}
diff --git a/libraries/nestjs-libraries/src/dtos/comments/add.comment.dto.ts b/libraries/nestjs-libraries/src/dtos/comments/add.comment.dto.ts
index df7114cbfb..0e23ca654b 100644
--- a/libraries/nestjs-libraries/src/dtos/comments/add.comment.dto.ts
+++ b/libraries/nestjs-libraries/src/dtos/comments/add.comment.dto.ts
@@ -1,4 +1,56 @@
+import {
+ IsBoolean,
+ IsInt,
+ IsOptional,
+ IsString,
+ MaxLength,
+ Min,
+ MinLength,
+} from 'class-validator';
+
export class AddCommentDto {
+ @IsString()
+ @MinLength(1)
+ @MaxLength(2000)
content: string;
- date: string;
+}
+
+export class CreatePublicCommentDto extends AddCommentDto {
+ @IsOptional()
+ @IsString()
+ postId?: string;
+
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ anchorStart?: number;
+
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ anchorEnd?: number;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(500)
+ anchorQuote?: string;
+
+ @IsOptional()
+ @IsString()
+ parentId?: string;
+
+ @IsOptional()
+ @IsString()
+ @MinLength(1)
+ @MaxLength(80)
+ displayName?: string;
+
+ @IsOptional()
+ @IsString()
+ recaptchaToken?: string;
+}
+
+export class ResolveCommentDto {
+ @IsBoolean()
+ resolved: boolean;
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
index 33c69599b7..c52c10b6e6 100644
--- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
@@ -452,6 +452,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
async fetchPageInformation(accessToken: string, data: { page: string }) {
const pageId = data.page;
const fields = 'id,username,name,access_token,picture.type(large)';
+ let foundWithoutToken = false;
const searchPaginated = async (startUrl: string) => {
let url: string | undefined = startUrl;
@@ -461,7 +462,11 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
const page = response.data.find(
(p: any) => String(p.id) === String(pageId)
);
- if (page) {
+ // A page listed through a business the user has no role on comes
+ // back without a page token, keep looking for a listing that has one
+ if (page && !page.access_token) {
+ foundWithoutToken = true;
+ } else if (page) {
return {
id: page.id,
name: page.name,
@@ -517,6 +522,12 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
// Business Manager API not available for all users
}
+ if (foundWithoutToken) {
+ throw new Error(
+ 'Your Facebook user has no permission to manage this page. Ask a page admin for full content access, then reconnect the channel'
+ );
+ }
+
throw new Error('Page not found in your accounts');
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
index 8800a5ce99..83e7bb532b 100644
--- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
@@ -11,6 +11,7 @@ import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import {
BadBody,
+ Disconnect,
SocialAbstract,
ValidityMedia,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
@@ -377,8 +378,17 @@ export class InstagramProvider
if (body.indexOf('2207082') > -1) {
return {
type: 'retry' as const,
- value: 'Could not upload your media',
- }
+ value:
+ 'Instagram could not process this video. If you attached audio to a video that has no sound track, set the original video volume to 0 and try again',
+ };
+ }
+
+ if (body.indexOf('2207085') > -1) {
+ return {
+ type: 'bad-body' as const,
+ value:
+ 'Instagram could not process the video, please check the video format, duration and resolution and try again',
+ };
}
if (body.indexOf('2207077') > -1) {
@@ -643,11 +653,20 @@ export class InstagramProvider
).json();
if (status_code === 'ERROR' || status_code === 'EXPIRED') {
+ const handleError = this.handleErrors(status || '', 200);
+ if (handleError?.type === 'disconnect') {
+ throw new Disconnect(
+ this.identifier,
+ JSON.stringify({ status_code, status }),
+ '{}',
+ handleError?.value
+ );
+ }
throw new BadBody(
this.identifier,
JSON.stringify({ status_code, status }),
'{}',
- status || 'Instagram could not process the media'
+ handleError?.value || status || 'Instagram could not process the media'
);
}
diff --git a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
index 873adbcb2e..08deea5712 100644
--- a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts
@@ -71,6 +71,12 @@ export class NostrProvider extends SocialAbstract implements SocialProvider {
};
}
+ private secretKey(password: string) {
+ return Uint8Array.from(
+ (password.match(/.{1,2}/g) || []).map((byte: any) => parseInt(byte, 16))
+ );
+ }
+
private async findRelayInformation(pubkey: string) {
// This queries ALL relays in parallel and resolves with
// the first matching event from ANY relay.
@@ -137,11 +143,7 @@ export class NostrProvider extends SocialAbstract implements SocialProvider {
try {
const body = JSON.parse(Buffer.from(params.code, 'base64').toString());
- const pubkey = getPublicKey(
- Uint8Array.from(
- body.password.match(/.{1,2}/g).map((byte: any) => parseInt(byte, 16))
- )
- );
+ const pubkey = getPublicKey(this.secretKey(body.password));
const user = await this.findRelayInformation(pubkey);
@@ -182,7 +184,7 @@ export class NostrProvider extends SocialAbstract implements SocialProvider {
tags: [],
created_at: Math.floor(Date.now() / 1000),
},
- password
+ this.secretKey(password)
);
const eventId = await this.publish(id, textEvent);
@@ -219,7 +221,7 @@ export class NostrProvider extends SocialAbstract implements SocialProvider {
],
created_at: Math.floor(Date.now() / 1000),
},
- password
+ this.secretKey(password)
);
const eventId = await this.publish(id, textEvent);
diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
index c6bb19dc42..8aa0002002 100644
--- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts
@@ -6,7 +6,10 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id';
import dayjs from 'dayjs';
-import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
+import {
+ BadBody,
+ SocialAbstract,
+} from '@gitroom/nestjs-libraries/integrations/social.abstract';
//@ts-ignore
import mime from 'mime';
import TelegramBot from 'node-telegram-bot-api';
@@ -172,6 +175,40 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider {
accessToken: string,
message: PostDetails,
replyToMessageId?: number
+ ): Promise {
+ try {
+ return await this.sendMessageInternal(
+ accessToken,
+ message,
+ replyToMessageId
+ );
+ } catch (error: any) {
+ // A Telegram 400 is a permanent rejection of this request (caption too
+ // long, media Telegram could not download, ...): retrying it can never
+ // succeed, so surface it to the user instead.
+ const body = error?.response?.body;
+ if (error?.code === 'ETELEGRAM' && body?.error_code === 400) {
+ const description = (body.description || '').replace(
+ 'Bad Request: ',
+ ''
+ );
+ throw new BadBody(
+ 'telegram-error',
+ JSON.stringify(body),
+ Buffer.from(JSON.stringify(body)),
+ description === 'failed to get HTTP URL content'
+ ? 'Telegram could not download your media file, please check the file and try again'
+ : `Telegram rejected the post: ${description}`
+ );
+ }
+ throw error;
+ }
+ }
+
+ private async sendMessageInternal(
+ accessToken: string,
+ message: PostDetails,
+ replyToMessageId?: number
): Promise {
let messageId: number | null = null;
const mediaFiles = message.media || [];
diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
index cac4d40bc4..26a4a123e5 100644
--- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
@@ -193,7 +193,9 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
this.identifier,
JSON.stringify({ status, error_message }),
'{}',
- error_message || 'Threads could not process the media'
+ error_message && error_message !== 'UNKNOWN'
+ ? error_message
+ : 'Threads could not process the media, please check the media format and try again'
);
}
diff --git a/libraries/nestjs-libraries/src/throttler/throttler.provider.ts b/libraries/nestjs-libraries/src/throttler/throttler.provider.ts
index db506f9ffd..80a890be24 100644
--- a/libraries/nestjs-libraries/src/throttler/throttler.provider.ts
+++ b/libraries/nestjs-libraries/src/throttler/throttler.provider.ts
@@ -2,13 +2,19 @@ import { ThrottlerGuard } from '@nestjs/throttler';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
+const isPublicComment = (method: string, url: string) =>
+ method === 'POST' && /^\/public\/posts\/[^/]+\/comments/.test(url);
+
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
public override async canActivate(
context: ExecutionContext
): Promise {
const { url, method } = context.switchToHttp().getRequest();
- if (method === 'POST' && url.includes('/public/v1/posts')) {
+ if (
+ (method === 'POST' && url.includes('/public/v1/posts')) ||
+ isPublicComment(method, url)
+ ) {
return super.canActivate(context);
}
@@ -18,6 +24,13 @@ export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
protected override async getTracker(
req: Record
): Promise {
+ if (!req.org) {
+ const forwarded = String(req.headers?.['x-forwarded-for'] || '')
+ .split(',')[0]
+ .trim();
+ return 'ip_' + (forwarded || req.ip || 'unknown');
+ }
+
return (
req.org.id + '_' + (req.url.indexOf('/posts') > -1 ? 'posts' : 'other')
);
diff --git a/libraries/react-shared-libraries/src/helpers/variable.context.tsx b/libraries/react-shared-libraries/src/helpers/variable.context.tsx
index f84252109e..293fc29d4f 100644
--- a/libraries/react-shared-libraries/src/helpers/variable.context.tsx
+++ b/libraries/react-shared-libraries/src/helpers/variable.context.tsx
@@ -46,6 +46,7 @@ export interface VariableContextInterface {
googleAdsId?: string;
googleAdsTrialTracking?: string;
brandConfig?: PublicBrandConfig;
+ recaptchaSiteKey?: string;
}
const defaultBrandWithCustom: PublicBrandConfig = {
@@ -87,6 +88,7 @@ const VariableContext = createContext({
sentryDsn: '',
extensionId: '',
brandConfig: defaultBrandWithCustom,
+ recaptchaSiteKey: '',
} as VariableContextInterface);
export const VariableContextComponent: FC<
VariableContextInterface & {
diff --git a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts
index a103b42959..e2f6376d37 100644
--- a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts
+++ b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts
@@ -18,6 +18,9 @@ export const initializeSentryBasic = (
/^NetworkError when attempting to fetch resource\. .*/i,
/^Object captured as promise rejection with keys: code, message$/i,
/^Called on script loaded before session recording is available$/i,
+ /^Failed to connect to MetaMask$/i,
+ /^MetaMask extension not found$/i,
+ /^undefined is not an object \(evaluating '\w+\.progress'\)$/i,
];
// Browser wallet extensions (Phantom, MetaMask, etc.) reject with a plain
diff --git a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json
index 90e5718368..34a1030e1f 100644
--- a/libraries/react-shared-libraries/src/translation/locales/ar/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/ar/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "حذف التكامل",
"start_writing_your_post": "ابدأ بكتابة منشورك لمعاينة",
"billing_other_account_subscribed": "هناك حساب آخر يستخدم هذا البريد الإلكتروني لديه اشتراك نشط بالفعل. يرجى تسجيل الخروج وتسجيل الدخول إلى ذلك الحساب لإدارة اشتراكك.",
+ "billing_stripe_load_failed": "تعذّر تحميل نموذج الدفع. يرجى تعطيل أدوات حظر الإعلانات أو ملحقات الخصوصية لهذه الصفحة وإعادة تحميلها.",
"already_subscribed": "مشترك بالفعل",
"ok": "موافق",
"billing_join_over": "انضم إلى أكثر من",
@@ -768,5 +769,21 @@
"republish_the_post": "إعادة نشر المنشور",
"post_already_published_republish_warning": "تم نشر هذا المنشور بالفعل. إعادة النشر ستقوم بنشره مرة أخرى على",
"republish_at": "في",
- "republish_recurring_note": "هذا منشور متكرر: ستنطبق تغييراتك على جميع التكرارات المستقبلية بدءًا من الآن."
+ "republish_recurring_note": "هذا منشور متكرر: ستنطبق تغييراتك على جميع التكرارات المستقبلية بدءًا من الآن.",
+ "comment": "تعليق",
+ "reply": "رد",
+ "add_a_comment": "إضافة تعليق",
+ "add_a_comment_placeholder": "أضف تعليقًا...",
+ "write_a_reply": "اكتب ردًا...",
+ "reviewer": "مراجع",
+ "resolve": "حل",
+ "reopen": "إعادة فتح",
+ "resolved": "تم الحل",
+ "collapse": "طي",
+ "preview_comment_name_required": "يمكننا فقط نشر تعليقك إذا قدمت اسمك.",
+ "preview_comment_your_name": "اسمك",
+ "preview_comment_failed": "تعذر نشر التعليق",
+ "preview_comment_detach_hint": "التعليقات على النص المحدد تفقد تمييزها إذا تم تعديل النص لاحقًا.",
+ "preview_comment_text_changed": "تم تغيير النص",
+ "preview_no_comments_yet": "لا توجد تعليقات بعد. حدد بعض النص في المنشور للتعليق عليه، أو أضف تعليقًا عامًا."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json
index ea4f926d97..d52209baa8 100644
--- a/libraries/react-shared-libraries/src/translation/locales/bn/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/bn/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "ইন্টিগ্রেশন মুছে ফেলুন",
"start_writing_your_post": "প্রিভিউর জন্য আপনার পোস্ট লেখা শুরু করুন",
"billing_other_account_subscribed": "এই ইমেইলটি ব্যবহার করে অন্য একটি অ্যাকাউন্টে ইতিমধ্যে সক্রিয় সাবস্ক্রিপশন রয়েছে। দয়া করে লগ আউট করুন এবং সাবস্ক্রিপশনটি পরিচালনা করতে ঐ অ্যাকাউন্টে সাইন ইন করুন।",
+ "billing_stripe_load_failed": "পেমেন্ট ফর্মটি লোড করা যায়নি। দয়া করে এই পৃষ্ঠার জন্য বিজ্ঞাপন ব্লকার বা গোপনীয়তা এক্সটেনশনগুলো নিষ্ক্রিয় করুন এবং পৃষ্ঠাটি পুনরায় লোড করুন।",
"already_subscribed": "ইতিমধ্যে সাবস্ক্রাইব করা হয়েছে",
"ok": "ঠিক আছে",
"billing_join_over": "যোগ দিন",
@@ -768,5 +769,21 @@
"republish_the_post": "পোস্টটি পুনঃপ্রকাশ করুন",
"post_already_published_republish_warning": "এই পোস্টটি ইতিমধ্যে প্রকাশিত হয়েছে। পুনঃপ্রকাশ করলে এটি আবার প্রকাশিত হবে",
"republish_at": "সময়",
- "republish_recurring_note": "এটি একটি পুনরাবৃত্তিমূলক পোস্ট: আপনার পরিবর্তনসমূহ এখন থেকে শুরু করে সকল ভবিষ্যৎ পুনরাবৃত্তিতে প্রয়োগ হবে।"
+ "republish_recurring_note": "এটি একটি পুনরাবৃত্তিমূলক পোস্ট: আপনার পরিবর্তনসমূহ এখন থেকে শুরু করে সকল ভবিষ্যৎ পুনরাবৃত্তিতে প্রয়োগ হবে।",
+ "comment": "মন্তব্য",
+ "reply": "জবাব দিন",
+ "add_a_comment": "একটি মন্তব্য যোগ করুন",
+ "add_a_comment_placeholder": "একটি মন্তব্য যোগ করুন...",
+ "write_a_reply": "একটি জবাব লিখুন...",
+ "reviewer": "রিভিউয়ার",
+ "resolve": "সমাধান করুন",
+ "reopen": "আবার খুলুন",
+ "resolved": "সমাধান হয়েছে",
+ "collapse": "ভাঁজ করুন",
+ "preview_comment_name_required": "আপনার নাম না দিলে আমরা আপনার মন্তব্য পোস্ট করতে পারি না।",
+ "preview_comment_your_name": "আপনার নাম",
+ "preview_comment_failed": "মন্তব্যটি পোস্ট করা যায়নি",
+ "preview_comment_detach_hint": "নির্বাচিত টেক্সটে মন্তব্য করলে, টেক্সট সম্পাদনার পর হাইলাইট হারিয়ে যেতে পারে।",
+ "preview_comment_text_changed": "টেক্সট পরিবর্তিত হয়েছে",
+ "preview_no_comments_yet": "এখনও কোনো মন্তব্য নেই। পোস্টে কিছু টেক্সট নির্বাচন করে অথবা সাধারণ মন্তব্য যোগ করে মন্তব্য করুন।"
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/de/translation.json b/libraries/react-shared-libraries/src/translation/locales/de/translation.json
index 3b06b11cd6..c9de6e6b41 100644
--- a/libraries/react-shared-libraries/src/translation/locales/de/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/de/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Integration löschen",
"start_writing_your_post": "Beginne, deinen Beitrag für eine Vorschau zu schreiben",
"billing_other_account_subscribed": "Ein anderes Konto mit dieser E-Mail-Adresse hat bereits ein aktives Abonnement. Bitte melden Sie sich ab und melden Sie sich mit diesem Konto an, um Ihr Abonnement zu verwalten.",
+ "billing_stripe_load_failed": "Das Zahlungsformular konnte nicht geladen werden. Bitte deaktivieren Sie Werbeblocker oder Datenschutz-Erweiterungen für diese Seite und laden Sie sie erneut.",
"already_subscribed": "Bereits abonniert",
"ok": "OK",
"billing_join_over": "Schließen Sie sich über",
@@ -768,5 +769,21 @@
"republish_the_post": "Beitrag erneut veröffentlichen",
"post_already_published_republish_warning": "Dieser Beitrag wurde bereits veröffentlicht. Durch erneutes Veröffentlichen wird er erneut veröffentlicht auf",
"republish_at": "um",
- "republish_recurring_note": "Dies ist ein wiederkehrender Beitrag: Ihre Änderungen gelten ab jetzt für alle zukünftigen Wiederholungen."
+ "republish_recurring_note": "Dies ist ein wiederkehrender Beitrag: Ihre Änderungen gelten ab jetzt für alle zukünftigen Wiederholungen.",
+ "comment": "Kommentar",
+ "reply": "Antworten",
+ "add_a_comment": "Einen Kommentar hinzufügen",
+ "add_a_comment_placeholder": "Kommentar hinzufügen...",
+ "write_a_reply": "Antwort schreiben...",
+ "reviewer": "Reviewer",
+ "resolve": "Lösen",
+ "reopen": "Wieder öffnen",
+ "resolved": "Gelöst",
+ "collapse": "Einklappen",
+ "preview_comment_name_required": "Wir können Ihren Kommentar nur veröffentlichen, wenn Sie Ihren Namen angeben.",
+ "preview_comment_your_name": "Ihr Name",
+ "preview_comment_failed": "Kommentar konnte nicht veröffentlicht werden",
+ "preview_comment_detach_hint": "Kommentare zu ausgewähltem Text verlieren ihre Markierung, wenn der Text später bearbeitet wird.",
+ "preview_comment_text_changed": "Text geändert",
+ "preview_no_comments_yet": "Noch keine Kommentare. Wählen Sie einen Text im Beitrag aus, um ihn zu kommentieren, oder fügen Sie einen allgemeinen Kommentar hinzu."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json
index 06d4971c13..290bc15df7 100644
--- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Delete Integration",
"start_writing_your_post": "Start writing your post for a preview",
"billing_other_account_subscribed": "Another account with this email already has an active subscription. Please log off and sign in to that account to manage your subscription.",
+ "billing_stripe_load_failed": "The payment form could not be loaded. Please disable ad blockers or privacy extensions for this page and reload.",
"already_subscribed": "Already subscribed",
"ok": "OK",
"billing_join_over": "Join Over",
@@ -770,5 +771,21 @@
"republish_the_post": "Republish the post",
"post_already_published_republish_warning": "This post was already published. Republishing will publish it again to",
"republish_at": "at",
- "republish_recurring_note": "This is a recurring post: your changes apply to all future recurrences starting now."
+ "republish_recurring_note": "This is a recurring post: your changes apply to all future recurrences starting now.",
+ "comment": "Comment",
+ "reply": "Reply",
+ "add_a_comment": "Add a comment",
+ "add_a_comment_placeholder": "Add a comment...",
+ "write_a_reply": "Write a reply...",
+ "reviewer": "Reviewer",
+ "resolve": "Resolve",
+ "reopen": "Reopen",
+ "resolved": "Resolved",
+ "collapse": "Collapse",
+ "preview_comment_name_required": "We can only post your comment if you provide your name.",
+ "preview_comment_your_name": "Your name",
+ "preview_comment_failed": "Could not post the comment",
+ "preview_comment_detach_hint": "Comments on selected text lose their highlight if the text is edited later.",
+ "preview_comment_text_changed": "Text changed",
+ "preview_no_comments_yet": "No comments yet. Select some text in the post to comment on it, or add a general comment."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/es/translation.json b/libraries/react-shared-libraries/src/translation/locales/es/translation.json
index 84ddbce929..8645878588 100644
--- a/libraries/react-shared-libraries/src/translation/locales/es/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/es/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Eliminar integración",
"start_writing_your_post": "Comienza a escribir tu publicación para obtener una vista previa",
"billing_other_account_subscribed": "Otra cuenta con este correo electrónico ya tiene una suscripción activa. Por favor, cierra sesión e inicia sesión con esa cuenta para gestionar tu suscripción.",
+ "billing_stripe_load_failed": "No se pudo cargar el formulario de pago. Por favor, desactiva los bloqueadores de anuncios o las extensiones de privacidad para esta página y vuelve a cargarla.",
"already_subscribed": "Ya está suscrito",
"ok": "OK",
"billing_join_over": "Únete a más de",
@@ -768,5 +769,21 @@
"republish_the_post": "Volver a publicar la publicación",
"post_already_published_republish_warning": "Esta publicación ya fue publicada. Volver a publicarla la publicará nuevamente en",
"republish_at": "a las",
- "republish_recurring_note": "Esta es una publicación recurrente: tus cambios se aplican a todas las recurrencias futuras a partir de ahora."
+ "republish_recurring_note": "Esta es una publicación recurrente: tus cambios se aplican a todas las recurrencias futuras a partir de ahora.",
+ "comment": "Comentario",
+ "reply": "Responder",
+ "add_a_comment": "Agregar un comentario",
+ "add_a_comment_placeholder": "Agregar un comentario...",
+ "write_a_reply": "Escribe una respuesta...",
+ "reviewer": "Revisor",
+ "resolve": "Resolver",
+ "reopen": "Reabrir",
+ "resolved": "Resuelto",
+ "collapse": "Colapsar",
+ "preview_comment_name_required": "Solo podemos publicar tu comentario si proporcionas tu nombre.",
+ "preview_comment_your_name": "Tu nombre",
+ "preview_comment_failed": "No se pudo publicar el comentario",
+ "preview_comment_detach_hint": "Los comentarios sobre texto seleccionado pierden su resalte si el texto se edita más tarde.",
+ "preview_comment_text_changed": "Texto modificado",
+ "preview_no_comments_yet": "Aún no hay comentarios. Selecciona texto en la publicación para comentarlo o agrega un comentario general."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json
index fa48535b17..23d93eca05 100644
--- a/libraries/react-shared-libraries/src/translation/locales/fr/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/fr/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Supprimer l'intégration",
"start_writing_your_post": "Commencez à écrire votre post pour un aperçu",
"billing_other_account_subscribed": "Un autre compte avec cet e-mail a déjà un abonnement actif. Veuillez vous déconnecter et vous connecter à ce compte pour gérer votre abonnement.",
+ "billing_stripe_load_failed": "Le formulaire de paiement n’a pas pu être chargé. Veuillez désactiver les bloqueurs de publicités ou les extensions de confidentialité pour cette page, puis rechargez-la.",
"already_subscribed": "Déjà abonné",
"ok": "OK",
"billing_join_over": "Rejoignez plus de",
@@ -768,5 +769,21 @@
"republish_the_post": "Republier la publication",
"post_already_published_republish_warning": "Cette publication a déjà été publiée. La republier la publiera à nouveau sur",
"republish_at": "à",
- "republish_recurring_note": "Il s'agit d'une publication récurrente : vos modifications s'appliquent à toutes les récurrences futures à partir de maintenant."
+ "republish_recurring_note": "Il s'agit d'une publication récurrente : vos modifications s'appliquent à toutes les récurrences futures à partir de maintenant.",
+ "comment": "Commentaire",
+ "reply": "Répondre",
+ "add_a_comment": "Ajouter un commentaire",
+ "add_a_comment_placeholder": "Ajouter un commentaire...",
+ "write_a_reply": "Écrire une réponse...",
+ "reviewer": "Relecteur",
+ "resolve": "Résoudre",
+ "reopen": "Rouvrir",
+ "resolved": "Résolu",
+ "collapse": "Réduire",
+ "preview_comment_name_required": "Nous ne pouvons publier votre commentaire que si vous indiquez votre nom.",
+ "preview_comment_your_name": "Votre nom",
+ "preview_comment_failed": "Impossible de publier le commentaire",
+ "preview_comment_detach_hint": "Les commentaires sur le texte sélectionné perdent leur surlignement si le texte est modifié par la suite.",
+ "preview_comment_text_changed": "Texte modifié",
+ "preview_no_comments_yet": "Aucun commentaire pour le moment. Sélectionnez du texte dans la publication pour le commenter, ou ajoutez un commentaire général."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/he/translation.json b/libraries/react-shared-libraries/src/translation/locales/he/translation.json
index 9b59611ab3..0ba46e14de 100644
--- a/libraries/react-shared-libraries/src/translation/locales/he/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/he/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "מחק אינטגרציה",
"start_writing_your_post": "התחל לכתוב את הפוסט שלך לתצוגה מקדימה",
"billing_other_account_subscribed": "קיים חשבון אחר עם כתובת האימייל הזו שכבר יש לו מנוי פעיל. נא להתנתק ולהתחבר לחשבון הזה כדי לנהל את המנוי שלך.",
+ "billing_stripe_load_failed": "לא ניתן היה לטעון את טופס התשלום. נא להשבית חוסמי פרסומות או תוספי פרטיות עבור עמוד זה ולרענן את הדף.",
"already_subscribed": "כבר רשום כמנוי",
"ok": "אישור",
"billing_join_over": "הצטרפו ליותר מ-",
@@ -768,5 +769,21 @@
"republish_the_post": "לפרסם מחדש את הפוסט",
"post_already_published_republish_warning": "הפוסט הזה כבר פורסם. פרסום מחדש יפרסם אותו שוב ב",
"republish_at": "בשעה",
- "republish_recurring_note": "זהו פוסט חוזר: השינויים שלך יחולו על כל ההופעות העתידיות החל מעכשיו."
+ "republish_recurring_note": "זהו פוסט חוזר: השינויים שלך יחולו על כל ההופעות העתידיות החל מעכשיו.",
+ "comment": "תגובה",
+ "reply": "השב",
+ "add_a_comment": "הוסף תגובה",
+ "add_a_comment_placeholder": "הוסף תגובה...",
+ "write_a_reply": "כתוב תגובה...",
+ "reviewer": "סוקר/ת",
+ "resolve": "סיים טיפול",
+ "reopen": "פתח מחדש",
+ "resolved": "טופל",
+ "collapse": "מזער",
+ "preview_comment_name_required": "נוכל לפרסם את ההערה שלך רק אם תספק את שמך.",
+ "preview_comment_your_name": "השם שלך",
+ "preview_comment_failed": "לא ניתן היה לפרסם את התגובה",
+ "preview_comment_detach_hint": "תגובות על טקסט נבחר מאבדות הדגשה אם הטקסט נערך מאוחר יותר.",
+ "preview_comment_text_changed": "הטקסט השתנה",
+ "preview_no_comments_yet": "עדיין אין תגובות. בחר טקסט בפוסט כדי להגיב עליו, או הוסף תגובה כללית."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/it/translation.json b/libraries/react-shared-libraries/src/translation/locales/it/translation.json
index 9d14414536..63f6eaf463 100644
--- a/libraries/react-shared-libraries/src/translation/locales/it/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/it/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Elimina integrazione",
"start_writing_your_post": "Inizia a scrivere il tuo post per un'anteprima",
"billing_other_account_subscribed": "Un altro account con questa email ha già un abbonamento attivo. Disconnettiti e accedi a quell'account per gestire il tuo abbonamento.",
+ "billing_stripe_load_failed": "Impossibile caricare il modulo di pagamento. Disattiva i blocchi pubblicitari o le estensioni per la privacy per questa pagina e ricarica.",
"already_subscribed": "Già abbonato",
"ok": "OK",
"billing_join_over": "Unisciti a oltre",
@@ -768,5 +769,21 @@
"republish_the_post": "Ripubblica il post",
"post_already_published_republish_warning": "Questo post è già stato pubblicato. Ripubblicandolo verrà pubblicato nuovamente su",
"republish_at": "a",
- "republish_recurring_note": "Questo è un post ricorrente: le tue modifiche verranno applicate a tutte le future ricorrenze a partire da ora."
+ "republish_recurring_note": "Questo è un post ricorrente: le tue modifiche verranno applicate a tutte le future ricorrenze a partire da ora.",
+ "comment": "Commento",
+ "reply": "Rispondi",
+ "add_a_comment": "Aggiungi un commento",
+ "add_a_comment_placeholder": "Aggiungi un commento...",
+ "write_a_reply": "Scrivi una risposta...",
+ "reviewer": "Revisore",
+ "resolve": "Risolvi",
+ "reopen": "Riapri",
+ "resolved": "Risolto",
+ "collapse": "Comprimi",
+ "preview_comment_name_required": "Possiamo pubblicare il tuo commento solo se ci fornisci il tuo nome.",
+ "preview_comment_your_name": "Il tuo nome",
+ "preview_comment_failed": "Impossibile pubblicare il commento",
+ "preview_comment_detach_hint": "I commenti su testo selezionato perdono l'evidenziazione se il testo viene modificato successivamente.",
+ "preview_comment_text_changed": "Testo modificato",
+ "preview_no_comments_yet": "Nessun commento ancora. Seleziona del testo nel post per commentarlo, oppure aggiungi un commento generale."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json
index fc479ff49e..870166c1c5 100644
--- a/libraries/react-shared-libraries/src/translation/locales/ja/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/ja/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "連携を削除",
"start_writing_your_post": "プレビュー用に投稿を書き始めてください",
"billing_other_account_subscribed": "このメールアドレスを持つ別のアカウントですでに有効なサブスクリプションがあります。サブスクリプションを管理するには、そのアカウントからサインアウトし、該当するアカウントでサインインしてください。",
+ "billing_stripe_load_failed": "お支払いフォームを読み込めませんでした。このページの広告ブロッカーやプライバシー拡張機能を無効にして、再読み込みしてください。",
"already_subscribed": "すでに登録済みです",
"ok": "OK",
"billing_join_over": "すでに参加している",
@@ -768,5 +769,21 @@
"republish_the_post": "投稿を再公開する",
"post_already_published_republish_warning": "この投稿は既に公開されています。再公開すると、もう一度投稿されます:",
"republish_at": "公開日時:",
- "republish_recurring_note": "これは定期投稿です:今後すべての繰り返しに対して変更が適用されます。"
+ "republish_recurring_note": "これは定期投稿です:今後すべての繰り返しに対して変更が適用されます。",
+ "comment": "コメント",
+ "reply": "返信",
+ "add_a_comment": "コメントを追加",
+ "add_a_comment_placeholder": "コメントを追加...",
+ "write_a_reply": "返信を書く...",
+ "reviewer": "レビュアー",
+ "resolve": "解決",
+ "reopen": "再オープン",
+ "resolved": "解決済み",
+ "collapse": "折りたたむ",
+ "preview_comment_name_required": "お名前を入力いただかないとコメントを投稿できません。",
+ "preview_comment_your_name": "あなたの名前",
+ "preview_comment_failed": "コメントの投稿に失敗しました",
+ "preview_comment_detach_hint": "選択テキストへのコメントは、テキストが後から編集されるとハイライトが失われます。",
+ "preview_comment_text_changed": "テキストが変更されました",
+ "preview_no_comments_yet": "まだコメントはありません。投稿内のテキストを選択してコメントするか、一般的なコメントを追加してください。"
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json
index 35cbd59260..e3469e4bce 100644
--- a/libraries/react-shared-libraries/src/translation/locales/ko/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/ko/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "통합 삭제",
"start_writing_your_post": "미리보기를 위해 게시글 작성을 시작하세요",
"billing_other_account_subscribed": "이 이메일로 이미 활성화된 구독이 있는 다른 계정이 있습니다. 구독을 관리하려면 로그아웃한 후 해당 계정으로 다시 로그인해 주세요.",
+ "billing_stripe_load_failed": "결제 양식을 불러올 수 없습니다. 이 페이지에 대한 광고 차단기나 프라이버시 확장 기능을 비활성화한 후 다시 불러와 주세요.",
"already_subscribed": "이미 구독됨",
"ok": "확인",
"billing_join_over": "이미 가입한",
@@ -768,5 +769,21 @@
"republish_the_post": "게시물 다시 게시하기",
"post_already_published_republish_warning": "이 게시물은 이미 게시되었습니다. 다시 게시하면 다음에 다시 게시됩니다:",
"republish_at": "다음 시간에",
- "republish_recurring_note": "이 게시물은 반복 게시물입니다. 변경사항은 지금부터 시작하는 모든 향후 반복에 적용됩니다."
+ "republish_recurring_note": "이 게시물은 반복 게시물입니다. 변경사항은 지금부터 시작하는 모든 향후 반복에 적용됩니다.",
+ "comment": "댓글",
+ "reply": "답글",
+ "add_a_comment": "댓글 추가",
+ "add_a_comment_placeholder": "댓글을 입력하세요...",
+ "write_a_reply": "답글을 작성하세요...",
+ "reviewer": "검토자",
+ "resolve": "해결",
+ "reopen": "다시 열기",
+ "resolved": "해결됨",
+ "collapse": "접기",
+ "preview_comment_name_required": "이름을 입력하셔야 댓글을 남길 수 있습니다.",
+ "preview_comment_your_name": "이름",
+ "preview_comment_failed": "댓글을 게시할 수 없습니다.",
+ "preview_comment_detach_hint": "선택한 텍스트에 단 댓글은, 텍스트가 나중에 수정되면 강조가 사라집니다.",
+ "preview_comment_text_changed": "텍스트가 변경됨",
+ "preview_no_comments_yet": "아직 댓글이 없습니다. 게시물에서 텍스트를 선택해 댓글을 남기거나, 일반 댓글을 추가하세요."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json
index 20215eddbd..13e301c5ea 100644
--- a/libraries/react-shared-libraries/src/translation/locales/pt/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/pt/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Excluir integração",
"start_writing_your_post": "Comece a escrever sua postagem para visualizar",
"billing_other_account_subscribed": "Outra conta com este e-mail já possui uma assinatura ativa. Por favor, faça logout e acesse essa conta para gerenciar sua assinatura.",
+ "billing_stripe_load_failed": "O formulário de pagamento não pôde ser carregado. Por favor, desative bloqueadores de anúncios ou extensões de privacidade para esta página e recarregue.",
"already_subscribed": "Já inscrito",
"ok": "OK",
"billing_join_over": "Junte-se a mais de",
@@ -768,5 +769,21 @@
"republish_the_post": "Republicar a publicação",
"post_already_published_republish_warning": "Esta publicação já foi publicada. Republicar irá publicá-la novamente em",
"republish_at": "em",
- "republish_recurring_note": "Esta é uma publicação recorrente: suas alterações se aplicam a todas as futuras recorrências a partir de agora."
+ "republish_recurring_note": "Esta é uma publicação recorrente: suas alterações se aplicam a todas as futuras recorrências a partir de agora.",
+ "comment": "Comentário",
+ "reply": "Responder",
+ "add_a_comment": "Adicionar um comentário",
+ "add_a_comment_placeholder": "Adicione um comentário...",
+ "write_a_reply": "Escreva uma resposta...",
+ "reviewer": "Revisor",
+ "resolve": "Resolver",
+ "reopen": "Reabrir",
+ "resolved": "Resolvido",
+ "collapse": "Recolher",
+ "preview_comment_name_required": "Só podemos publicar seu comentário se você informar seu nome.",
+ "preview_comment_your_name": "Seu nome",
+ "preview_comment_failed": "Não foi possível publicar o comentário",
+ "preview_comment_detach_hint": "Comentários em textos selecionados perdem o destaque se o texto for editado depois.",
+ "preview_comment_text_changed": "Texto alterado",
+ "preview_no_comments_yet": "Ainda não há comentários. Selecione um trecho do post para comentar ou adicione um comentário geral."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json
index 636e9c0925..c3cb03dd79 100644
--- a/libraries/react-shared-libraries/src/translation/locales/ru/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/ru/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Удалить интеграцию",
"start_writing_your_post": "Начните писать свой пост для предварительного просмотра",
"billing_other_account_subscribed": "Другой аккаунт с этим адресом электронной почты уже имеет активную подписку. Пожалуйста, выйдите из системы и войдите в этот аккаунт, чтобы управлять своей подпиской.",
+ "billing_stripe_load_failed": "Не удалось загрузить платежную форму. Пожалуйста, отключите блокировщики рекламы или расширения для защиты приватности для этой страницы и перезагрузите её.",
"already_subscribed": "Уже подписаны",
"ok": "ОК",
"billing_join_over": "Присоединяйтесь к",
@@ -768,5 +769,21 @@
"republish_the_post": "Опубликовать пост повторно",
"post_already_published_republish_warning": "Этот пост уже был опубликован. Повторная публикация опубликует его снова в",
"republish_at": "в",
- "republish_recurring_note": "Это повторяющийся пост: ваши изменения будут применяться ко всем будущим публикациям, начиная с этого момента."
+ "republish_recurring_note": "Это повторяющийся пост: ваши изменения будут применяться ко всем будущим публикациям, начиная с этого момента.",
+ "comment": "Комментарий",
+ "reply": "Ответить",
+ "add_a_comment": "Добавить комментарий",
+ "add_a_comment_placeholder": "Добавьте комментарий...",
+ "write_a_reply": "Напишите ответ...",
+ "reviewer": "Рецензент",
+ "resolve": "Решить",
+ "reopen": "Открыть заново",
+ "resolved": "Решено",
+ "collapse": "Свернуть",
+ "preview_comment_name_required": "Мы сможем опубликовать ваш комментарий только если вы укажете свое имя.",
+ "preview_comment_your_name": "Ваше имя",
+ "preview_comment_failed": "Не удалось опубликовать комментарий",
+ "preview_comment_detach_hint": "Комментарии к выделенному тексту теряют выделение, если текст позже изменяется.",
+ "preview_comment_text_changed": "Текст изменён",
+ "preview_no_comments_yet": "Пока нет ни одного комментария. Выделите текст в публикации, чтобы оставить к нему комментарий, или добавьте общий комментарий."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json
index 3ad6a0a6ad..2ec5eaffad 100644
--- a/libraries/react-shared-libraries/src/translation/locales/tr/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/tr/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Entegrasyonu Sil",
"start_writing_your_post": "Önizleme için gönderinizi yazmaya başlayın",
"billing_other_account_subscribed": "Bu e-posta ile başka bir hesapta zaten aktif bir abonelik var. Aboneliğinizi yönetmek için lütfen çıkış yapıp o hesaba giriş yapın.",
+ "billing_stripe_load_failed": "Ödeme formu yüklenemedi. Lütfen bu sayfa için reklam engelleyicileri veya gizlilik eklentilerini devre dışı bırakıp sayfayı yeniden yükleyin.",
"already_subscribed": "Zaten abone olundu",
"ok": "Tamam",
"billing_join_over": "Katılan",
@@ -768,5 +769,21 @@
"republish_the_post": "Gönderiyi tekrar yayımla",
"post_already_published_republish_warning": "Bu gönderi zaten yayımlanmış. Tekrar yayımlamak, gönderiyi yeniden yayımlar",
"republish_at": "tarihinde/saatinde",
- "republish_recurring_note": "Bu tekrar eden bir gönderi: yaptığınız değişiklikler bundan sonraki tüm tekrarlar için geçerli olacaktır."
+ "republish_recurring_note": "Bu tekrar eden bir gönderi: yaptığınız değişiklikler bundan sonraki tüm tekrarlar için geçerli olacaktır.",
+ "comment": "Yorum",
+ "reply": "Yanıtla",
+ "add_a_comment": "Yorum ekle",
+ "add_a_comment_placeholder": "Bir yorum ekleyin...",
+ "write_a_reply": "Bir yanıt yazın...",
+ "reviewer": "İnceleyen",
+ "resolve": "Çöz",
+ "reopen": "Yeniden Aç",
+ "resolved": "Çözüldü",
+ "collapse": "Daralt",
+ "preview_comment_name_required": "Yorumunuzu yalnızca adınızı girerseniz paylaşabiliriz.",
+ "preview_comment_your_name": "Adınız",
+ "preview_comment_failed": "Yorum paylaşılırken bir hata oluştu",
+ "preview_comment_detach_hint": "Seçili metindeki yorumlar, metin daha sonra değiştirilirse vurgusunu kaybeder.",
+ "preview_comment_text_changed": "Metin değişti",
+ "preview_no_comments_yet": "Henüz yorum yok. Yorum yapmak için gönderide bir metin seçin veya genel bir yorum ekleyin."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json
index 621bd8baf1..197691d3ad 100644
--- a/libraries/react-shared-libraries/src/translation/locales/vi/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/vi/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "Xóa tích hợp",
"start_writing_your_post": "Bắt đầu viết bài của bạn để xem trước",
"billing_other_account_subscribed": "Một tài khoản khác sử dụng email này đã có đăng ký đang hoạt động. Vui lòng đăng xuất và đăng nhập vào tài khoản đó để quản lý đăng ký của bạn.",
+ "billing_stripe_load_failed": "Không thể tải biểu mẫu thanh toán. Vui lòng tắt trình chặn quảng cáo hoặc các tiện ích mở rộng bảo mật cho trang này và tải lại.",
"already_subscribed": "Đã đăng ký",
"ok": "OK",
"billing_join_over": "Tham gia cùng hơn",
@@ -768,5 +769,21 @@
"republish_the_post": "Đăng lại bài viết",
"post_already_published_republish_warning": "Bài viết này đã được đăng. Đăng lại sẽ xuất bản bài viết một lần nữa đến",
"republish_at": "lúc",
- "republish_recurring_note": "Đây là bài viết định kỳ: các thay đổi của bạn sẽ áp dụng cho tất cả các lần lặp lại trong tương lai bắt đầu từ bây giờ."
+ "republish_recurring_note": "Đây là bài viết định kỳ: các thay đổi của bạn sẽ áp dụng cho tất cả các lần lặp lại trong tương lai bắt đầu từ bây giờ.",
+ "comment": "Bình luận",
+ "reply": "Trả lời",
+ "add_a_comment": "Thêm bình luận",
+ "add_a_comment_placeholder": "Thêm bình luận...",
+ "write_a_reply": "Viết trả lời...",
+ "reviewer": "Người kiểm duyệt",
+ "resolve": "Đã xử lý",
+ "reopen": "Mở lại",
+ "resolved": "Đã xử lý",
+ "collapse": "Thu gọn",
+ "preview_comment_name_required": "Chúng tôi chỉ có thể đăng bình luận của bạn khi bạn cung cấp tên.",
+ "preview_comment_your_name": "Tên của bạn",
+ "preview_comment_failed": "Không thể đăng bình luận",
+ "preview_comment_detach_hint": "Bình luận trên đoạn văn bản được chọn sẽ mất nổi bật nếu văn bản bị chỉnh sửa sau đó.",
+ "preview_comment_text_changed": "Nội dung đã thay đổi",
+ "preview_no_comments_yet": "Chưa có bình luận nào. Chọn một đoạn văn bản trong bài viết để bình luận, hoặc thêm bình luận chung."
}
diff --git a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json
index 180648672e..c7a089b1ba 100644
--- a/libraries/react-shared-libraries/src/translation/locales/zh/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/zh/translation.json
@@ -505,6 +505,7 @@
"delete_integration": "删除集成",
"start_writing_your_post": "开始写你的帖子以预览",
"billing_other_account_subscribed": "此邮箱已关联其他账户并有有效订阅。请注销当前账户并使用该账户登录以管理您的订阅。",
+ "billing_stripe_load_failed": "无法加载付款表单。请为此页面禁用广告拦截器或隐私扩展程序,然后重新加载。",
"already_subscribed": "已订阅",
"ok": "确定",
"billing_join_over": "已有超过",
@@ -768,5 +769,21 @@
"republish_the_post": "重新发布帖子",
"post_already_published_republish_warning": "此帖子已发布。重新发布将再次将其发布到",
"republish_at": "于",
- "republish_recurring_note": "这是一个重复的帖子:你的更改将从现在开始应用于所有未来的重复内容。"
+ "republish_recurring_note": "这是一个重复的帖子:你的更改将从现在开始应用于所有未来的重复内容。",
+ "comment": "评论",
+ "reply": "回复",
+ "add_a_comment": "添加评论",
+ "add_a_comment_placeholder": "添加评论……",
+ "write_a_reply": "写一个回复……",
+ "reviewer": "审阅者",
+ "resolve": "解决",
+ "reopen": "重新打开",
+ "resolved": "已解决",
+ "collapse": "折叠",
+ "preview_comment_name_required": "只有在你提供姓名后,我们才能发布你的评论。",
+ "preview_comment_your_name": "你的姓名",
+ "preview_comment_failed": "无法发布评论",
+ "preview_comment_detach_hint": "如果后续编辑了所选文本,所选文本上的评论高亮将丢失。",
+ "preview_comment_text_changed": "文本已更改",
+ "preview_no_comments_yet": "还没有评论。选中帖子中的某些文本以进行评论,或添加一个总体评论。"
}