merge: upstream main (42 commits - preview comments, connected clients, platform fixes) - #65
Conversation
…ting Anchored text comments with highlights, one-level replies, org-scoped resolve/reopen, named anonymous comments behind optional invisible reCAPTCHA v2 and the IP throttler, and a dashboard restyle of /p/:id.
…omments # Conflicts: # apps/frontend/src/app/(app)/layout.tsx # libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
…comments feat(preview): inline comments, replies, resolve and anonymous commenting
…e silent-video audio hint The container status poll is HTTP 200 with the failure in the body, so it never went through handleErrors and Meta's raw status text reached the tooltip.
…o a disconnect error
…audio-tooltip-main fix(instagram): curate container processing errors, map 2207082 to the silent-video audio hint
…allbacks The STORE_REFRESH_TOKEN and REMOVE_REFRESH_TOKEN calls to the Postiz browser extension passed a no-op callback. When the extension is not installed, disabled, or the origin is not allowed, Chrome logs "Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist." to the user's console because nothing read lastError. Reading it in the callback, as add.provider.component.tsx already does, marks the error as checked and silences the log. The calls stay fire-and-forget with no behavior change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rrors A Telegram 400 (caption too long, media Telegram could not download) is a permanent rejection, but it surfaced as a plain error the workflow retried 3 times and then reported as "Could not publish after several attempts" with a misleading couldn't-confirm email. Map it to BadBody so it fails once with the real reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyDRJpL27E7WCK5UGsUope
finalizeEvent requires the secret key as a Uint8Array, but post/comment passed the stored hex string, so every Nostr publish crashed with "expected Uint8Array, got type=string". Reuse the same conversion authenticate already does via a shared helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PyDRJpL27E7WCK5UGsUope
…ipt errors MetaMask's injected inpage.js throws "Failed to connect to MetaMask" (linked: "MetaMask extension not found") as Error instances, so the existing plain-object wallet filter does not catch them and beforeSend opens the user-report dialog. Both messages are added to ignorePatterns. Sentry CLOUD-C: 860 events / 166 users in the last 30 days, 2270 events / 305 users lifetime, no Postiz frames in the stack. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y-main fix(telegram): map Telegram 400 rejections to non-retryable BadBody errors
…tamask fix(frontend): stop the Sentry report dialog on MetaMask injected-script errors
A page listed without an access_token made reConnect return an undefined token, and the integration upsert then threw a 500.
…-main fix(nostr): convert the hex private key to bytes before signing posts
…ked-last-error fix(frontend): silence Unchecked runtime.lastError from extension messaging callbacks
…ct-page-token fix(facebook): fail reconnect cleanly when the page has no page token
…ed file The Uppy upload-success handler read getState().files[file.id].progress unconditionally. When the file had already been removed from Uppy's state (validation or error paths call removeFile) the lookup is undefined and the handler threw "Cannot read properties of undefined (reading 'progress')", which also pops the Sentry report dialog. Read the file state once and return when it is gone; setFileState is otherwise unchanged. Sentry CLOUD-20X + CLOUD-211 (Safari) + CLOUD-20Y: 1375 events / 62 users in the last 30 days, first seen 2026-09-16. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
getPostsByGroup crashed on posts[0].integrationId when the group had no posts left (deleted from another tab, by a teammate, or on sub cancel), so the API returned a 500 body. The calendar's editPost then indexed data.posts[0] on that error body and threw an unhandled TypeError, which also pops the Sentry report dialog. The service now throws NotFoundException like the other post lookups, and editPost returns early with the existing "Post not found" toast and a calendar mutate so the stale tile disappears. Sentry CLOUD-QF (Chrome) + CLOUD-QK (Safari): 833 events / 309 users in the last 30 days, 2807 / 635 lifetime. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ess error The @uppy/aws-s3 4.3.2 progress callback reads uppy.getFile(id).progress from the S3 part PUT load handler without a null check, so when the file has already left uppy's state it throws "undefined is not an object (evaluating 'r.progress')" (Sentry CLOUD-211). beforeSend shows the report dialog for every captured exception, so the throw surfaced as a "Something broke" popup for users on Safari. The upload itself is done or cancelled by the time this fires, so the error is ignored like the posthog recorder one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…py-progress fix(frontend): stop the Sentry report dialog on the uppy aws-s3 progress error
…-file-progress fix(media): skip the upload-success state update for an already removed file
…-group-guard fix(calendar): handle a deleted post group when opening a calendar tile
…heck add.provider.component.tsx tested chrome?.runtime?.sendMessage without first checking typeof chrome, so on Safari and iOS Chrome, where the chrome global does not exist, the bare identifier threw "ReferenceError: Can't find variable: chrome" instead of showing the existing "Extension Not Found" modal. The other two extension call sites already guard with typeof chrome !== 'undefined'. Sentry CLOUD-14W: 8 events / 6 users in the last 30 days, 22 / 11 lifetime, mostly on the LinkedIn connect flow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y state The Uppy error handler called uppy.clear() while the failed upload was still registered in currentUploads. Uppy core throws "The installed uploader plugin does not allow removing files during an upload." from clear() in that state, so every Transloadit assembly_error produced a second, unhandled exception on top of the real failure and popped the Sentry report dialog. cancelAll() removes every file first, which drops the upload record before that check, then resets the upload state; the rest of the handler is unchanged. Sentry CLOUD-S1: 53 events / 29 users in the last 30 days, 264 / 75 lifetime. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…crashing loadStripe() rejects when js.stripe.com is blocked (ad blockers, privacy extensions, corporate proxies). The promise was stored in state with no handler, so the rejection surfaced as an unhandled "Error: Failed to load Stripe.js", popped the Sentry report dialog, and the billing page stayed on the loading spinner forever. The promise now has a catch that flips a stripeFailed state, and the page renders an explanation in the same bordered box used for the "another account already subscribed" case. New translation key billing_stripe_load_failed added to all locales via lingo.dev. Sentry CLOUD-SJ: 185 events / 27 users in the last 30 days, 338 / 75 lifetime. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-undefined-guard fix(frontend): guard the chrome global before the extension connect check
…on-error fix(media): cancel in-flight uploads on error instead of clearing Uppy state
…ror-main fix: friendly message when Threads reports an UNKNOWN container error
…redirect started On a successful activation the backend answers with the onboarding header. The shared afterRequest handler sees it, sets window.location to the onboarding page and hands the response back; the activation page then called .json() on it while the page was already navigating away. Firefox rejects that body read with "AbortError: The operation was aborted.", which is unhandled and pops the Sentry report dialog. The page now returns early when the response carries the onboarding header and only reads the body otherwise, so the "already activated" path is unchanged. afterRequest itself is left alone because the register flow relies on getting the onboarding response back to fire its conversion events. Sentry CLOUD-11: 65 events / 65 users in the last 30 days, 311 / 132 lifetime, all Firefox on /auth/activate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…apping-main fix(instagram): map container error 2207085 to a curated video-format message
…oad-failure fix(billing): show a message when Stripe.js fails to load instead of crashing
…dy-on-redirect fix(auth): don't read the activate response body once the onboarding redirect started
Preview comments (inline, replies, resolve, anonymous + optional reCAPTCHA), connected clients, schedule post preview url, plus fixes: Instagram/Threads container error mapping, Telegram 400 -> BadBody, media upload cancel-on-error, calendar deleted post group, Stripe.js load failure message, extension chrome guards, nostr hex key signing. Conflicts resolved: - .env.example: union (Crove telemetry block + upstream reCAPTCHA and provider visibility docs; REDDIT_* stays removed) - first.billing.component: keep the DOS checkout branch (shared billing) and add upstream's stripeFailed branch; Stripe only loads outside shared dos billing - variable.context: keep brandConfig, add recaptchaSiteKey Schema: Comments gains threading/anchor/resolved columns and a nullable userId (anonymous). Beta DB is altered at deploy; the shared prod DB goes through the DOS-Me migration ledger before the prod deploy.
| return; | ||
| } | ||
| ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); | ||
| setFlash(true); |
| 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 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 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 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 load = useCallback(async () => { | ||
| return (await fetch(`/public/posts/${previewId}/comments`)).json(); | ||
| }, [previewId]); |
| ), | ||
| }); | ||
| }), | ||
| [t] |
| setLoading(false); | ||
| } | ||
| }, | ||
| [user?.id, recaptchaSiteKey, previewId, parentId, anchor, askForName] |
| return; | ||
| } | ||
| mutate(); | ||
| }, [comment.id, resolved]); |
| const fetch = useFetch(); | ||
| const load = useCallback(async () => { | ||
| return (await fetch(`/public/posts/${previewId}/comments`)).json(); | ||
| }, [previewId]); |
There was a problem hiding this comment.
Code Review
This pull request introduces a robust preview commenting system, enabling users to leave general comments, replies, and text-anchored inline comments on posts. It includes backend schema and controller updates, reCAPTCHA v2 integration for anonymous comments, rate-limiting, and frontend components for text selection and highlight rendering. Additionally, it addresses various social provider enhancements and bug fixes. The code review identified several critical issues: detachStaleAnchors is incorrectly called with old post data instead of updated content, preventing stale anchors from being detached; retryable errors in InstagramProvider are incorrectly treated as terminal exceptions; onDone is missing from a useCallback dependency array; unstable array references in PostContentClient cause redundant DOM mutations; unsafe non-null assertions are used on potentially null values; and the reCAPTCHA verification network request lacks error handling for network failures.
| const existingIds = (post.value || []).map((p) => p.id).filter(Boolean); | ||
| await this.detachStaleAnchors( | ||
| posts.filter((p) => existingIds.includes(p.id)) | ||
| ); |
There was a problem hiding this comment.
In posts.service.ts, detachStaleAnchors is called with posts.filter(...) where posts contains the old post data loaded from the database before the update. This means the anchor quotes are compared against the old content, so they will always match and never be identified as stale or detached. To correctly detach stale anchors when a post is updated, you should pass the new content from post.value instead.
| const existingIds = (post.value || []).map((p) => p.id).filter(Boolean); | |
| await this.detachStaleAnchors( | |
| posts.filter((p) => existingIds.includes(p.id)) | |
| ); | |
| const existingIds = (post.value || []).map((p) => p.id).filter(Boolean); | |
| await this.detachStaleAnchors( | |
| (post.value || []).filter((p) => p.id) as any | |
| ); |
| 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' | ||
| ); | ||
| } |
There was a problem hiding this comment.
In InstagramProvider.check, if the container status is ERROR or EXPIRED, handleErrors is called to check for specific error codes. If handleErrors returns a retry type (such as for error code 2207082), the code still throws a BadBody exception, which is a terminal, non-retryable error. This defeats the purpose of identifying retryable errors. You should handle the retry type by throwing a standard Error or another retry-signaling exception so the queue worker can retry the job.
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
);
}
if (handleError?.type === 'retry') {
throw new Error(handleError.value || 'Instagram temporary processing error');
}
throw new BadBody(
this.identifier,
JSON.stringify({ status_code, status }),
'{}',
handleError?.value || status || 'Instagram could not process the media'
);
}| setLoading(false); | ||
| } | ||
| }, | ||
| [user?.id, recaptchaSiteKey, previewId, parentId, anchor, askForName] |
There was a problem hiding this comment.
The submit callback in CommentComposer does not include onDone in its dependency array. Since onDone is a prop that can change, this can lead to a stale closure where an outdated version of onDone is called. Please add onDone to the dependency array of useCallback.
| [user?.id, recaptchaSiteKey, previewId, parentId, anchor, askForName] | |
| [user?.id, recaptchaSiteKey, previewId, parentId, anchor, askForName, onDone] |
| const ranges = useMemo<HighlightRange[]>( | ||
| () => | ||
| 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] | ||
| ); |
There was a problem hiding this comment.
The ranges array is recreated on every change to comments because .map returns a new array reference. This causes the useEffect that calls applyHighlights to run on every comment update (including replies or unrelated comment resolutions), leading to unnecessary DOM mutations and potential performance issues. You can optimize this by using a stringified representation of ranges as the dependency for the useEffect.
| const ranges = useMemo<HighlightRange[]>( | |
| () => | |
| 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 ranges = useMemo<HighlightRange[]>( | |
| () => | |
| 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 rangesStr = JSON.stringify(ranges); |
| ref.current | ||
| ?.querySelectorAll<HTMLElement>('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!) | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Using non-null assertions (!) on hoveredThread and activeThread?.id inside ids.includes(...) is unsafe because these values can be null or undefined. It is safer and cleaner to explicitly check if they are defined before calling includes.
ref.current
?.querySelectorAll<HTMLElement>('mark[data-thread-ids]')
.forEach((mark) => {
const ids = mark.dataset.threadIds!.split(',');
const isHovered = hoveredThread ? ids.includes(hoveredThread) : false;
const isActive = activeThread?.id ? ids.includes(activeThread.id) : false;
mark.classList.toggle('ring-1', isHovered || isActive);
mark.classList.toggle('ring-btnPrimary', isHovered || isActive);
});
| 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(); |
There was a problem hiding this comment.
The fetch call to Google's reCAPTCHA API is not wrapped in a try-catch block. If there is a network error or DNS failure, the promise rejection will bubble up as an unhandled 500 Internal Server Error. It is safer to wrap the network request in a try-catch block and throw a friendly BadRequestException.
try {
const response = 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,
}),
});
const result = await response.json();
if (!result?.success) {
throw new BadRequestException('Captcha verification failed');
}
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException('Captcha verification failed due to a network error');
}|
ESLint code-scanning check (10 new alerts incl. 6 errors): same accepted class as the documented baseline - react-compiler style rules (exhaustive-deps, preserve-manual-memoization, set-state-in-effect, immutability) + no-img-element, overwhelmingly in upstream's new preview-comments/auth files. The repo's own eslint.yml gate passes on this PR; the merge-introduced files (first.billing, variable.context, .env.example) lint clean at 0 errors. Accepted per the documented precedent from PR #40/#59 (upstream ESLint findings accepted, refactor/suppress as separate follow-up). Upstream code is not refactored here per the standing no-shared-UI-rebuilds directive. |
Upstream sync 4c33d52..374fb20 (42 commits since upstream-sync-20260922 / PR #59).
Features: inline preview comments with replies, resolve and anonymous commenting (optional reCAPTCHA v2 invisible), connected clients, schedule post preview url.
Fixes: Instagram container error curation (2207082/2207085 + disconnect mapping), Threads UNKNOWN container message, Telegram 400 -> non-retryable BadBody, media upload cancel-on-error + removed-file guard, calendar deleted post group, Stripe.js load failure message (new stripeFailed branch in first.billing), extension chrome guards, Facebook reconnect without page token, nostr hex key signing, auth activate redirect race.
Conflict resolutions:
.env.example: union - Crove telemetry block kept, upstream reCAPTCHA + provider visibility docs added, REDDIT_CLIENT_ID/SECRET stay removed.first.billing.component: DOS checkout branch (shared billing) preserved AND upstream stripeFailed branch added; Stripe.js only loads outside shared dos billing.variable.context: keep brandConfig, add recaptchaSiteKey.Schema note (deploy gating): Comments model gains threading/anchor/resolved columns + nullable userId. Beta DB gets the idempotent ALTERs at deploy time; the SHARED PROD DB (gulptwduchsjcsbndmua, schema post) must receive this through the DOS-Me migration ledger BEFORE the prod deploy (rule: no out-of-band DDL on shared prod). Prod deploy is intentionally held until then - a migration file follows via the DOS-Me handoff channel.
📌 TL;DR
Introduces an interactive inline and threaded commenting experience on public post preview pages with anonymous reviewer support, reCAPTCHA v2 protection, and thread resolution, alongside various frontend resilience fixes (Stripe error handling, extension messaging safety, and uppy upload cancellation).
🎯 Type of Change
🔍 Changes Walkthrough
.env.example.zcodeignoreapps/backend/src/api/routes/posts.controller.tsPOST /posts/:id/commentsto useCreatePublicCommentDtoand capture client IP; addedPUT /posts/comments/:commentId/resolveendpoint for resolving comment threads.apps/backend/src/api/routes/public.controller.tsPOST /public/posts/:id/commentsendpoint allowing anonymous comments with IP tracking.apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsxapps/frontend/src/app/(app)/(preview)/p/[id]/page.tsxPreviewCommentsProvider, adopted modern design system tokens, and replaced static post text withPostContentClientto support anchored inline selection commenting.apps/frontend/src/app/(app)/layout.tsxrecaptchaSiteKeydown to client-side app variables context.apps/frontend/src/app/global.scss.preview-comment-cursor.apps/frontend/src/components/admin/admin-stats.component.tsxconnectedClientsmetrics.apps/frontend/src/components/auth/after.activate.tsxonboardingresponse header before parsing JSON to prevent crashes during onboarding activation redirects.apps/frontend/src/components/billing/first.billing.component.tsxapps/frontend/src/components/launches/add.provider.component.tsxcontinue.integration.tsxmenu/menu.tsxchrome.runtime.lastErrorcallbacks to prevent unhandled errors.apps/frontend/src/components/launches/calendar.tsxapps/frontend/src/components/media/new.uploader.tsxuppy.clear()tocancelAll()on error and added null checks for current file state inupload-success.apps/frontend/src/components/preview/comments.components.tsx📊 Architectural Flow
sequenceDiagram autonumber actor Reviewer as Reviewer (Anonymous / Logged In) participant UI as Preview <!-- /dos-pr-summary -->