From f55f516d0dc77844d6517da467401cb577d1af5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Jan 2026 21:47:22 +0000 Subject: [PATCH 1/3] Fix image preview not showing in compose modal preview When composing a post with a direct image URL (e.g., https://example.com/image.jpg), the preview mode now shows the image inline, matching the behavior after posting. Previously, the compose preview only showed image URLs as clickable links while the posted version showed them as inline images via LinkPreview. https://claude.ai/code/session_01QS6hsm9agZ7diaU7Tp8waK --- components/compose/compose-modal.tsx | 29 ++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index 30c2f0af..f974b2ea 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -21,6 +21,7 @@ import { useRequireAuth } from '@/hooks/use-require-auth' import { usePlatformDetection } from '@/hooks/use-platform-detection' import { UserAvatar } from '@/components/ui/avatar-image' import { extractAllTags, extractMentions } from '@/lib/post-helpers' +import { extractFirstUrl, isDirectImageUrl } from '@/hooks/use-link-preview' import { hashtagService } from '@/lib/services/hashtag-service' import { mentionService } from '@/lib/services/mention-service' import { extractErrorMessage, isTimeoutError, categorizeError } from '@/lib/error-utils' @@ -388,13 +389,37 @@ function ThreadPostEditor({ {/* Content area */} {showPreview || isPosted ? ( -
{post.content ? ( - + <> +
+ +
+ {/* Image preview for direct image URLs */} + {(() => { + const firstUrl = extractFirstUrl(post.content) + if (firstUrl && isDirectImageUrl(firstUrl)) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Image preview { + e.currentTarget.style.display = 'none' + }} + /> +
+ ) + } + return null + })()} + ) : ( Nothing to preview From c3af10dd02c8865b1db15db2e14320f1df3beb91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 15:23:30 +0000 Subject: [PATCH 2/3] Match compose preview to actual post display for image URLs - Strip the image URL from displayed text (matching PostContent behavior) - Use exact LinkPreview styling: border, rounded corners, domain footer with LinkIcon - Match max height (400px) and object-contain behavior - Show hostname in footer like the actual post display https://claude.ai/code/session_01QS6hsm9agZ7diaU7Tp8waK --- components/compose/compose-modal.tsx | 89 ++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index f974b2ea..42cc6c0a 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -21,7 +21,8 @@ import { useRequireAuth } from '@/hooks/use-require-auth' import { usePlatformDetection } from '@/hooks/use-platform-detection' import { UserAvatar } from '@/components/ui/avatar-image' import { extractAllTags, extractMentions } from '@/lib/post-helpers' -import { extractFirstUrl, isDirectImageUrl } from '@/hooks/use-link-preview' +import { extractFirstUrl, isDirectImageUrl, stripTrailingPunctuation } from '@/hooks/use-link-preview' +import { LinkIcon } from '@heroicons/react/24/outline' import { hashtagService } from '@/lib/services/hashtag-service' import { mentionService } from '@/lib/services/mention-service' import { extractErrorMessage, isTimeoutError, categorizeError } from '@/lib/error-utils' @@ -395,31 +396,69 @@ function ThreadPostEditor({ : 'text-gray-900 dark:text-gray-100' }`}> {post.content ? ( - <> -
- -
- {/* Image preview for direct image URLs */} - {(() => { - const firstUrl = extractFirstUrl(post.content) - if (firstUrl && isDirectImageUrl(firstUrl)) { - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - Image preview { - e.currentTarget.style.display = 'none' - }} - /> -
- ) + (() => { + const firstUrl = extractFirstUrl(post.content) + const isImageUrl = firstUrl && isDirectImageUrl(firstUrl) + + // Strip the image URL from content (matching PostContent behavior) + let displayContent = post.content + if (isImageUrl && firstUrl) { + // Remove the URL but keep any trailing punctuation + const cleanUrl = stripTrailingPunctuation(firstUrl) + displayContent = post.content.replace(cleanUrl, '').trim() + } + + // Extract hostname for footer + const hostname = isImageUrl && firstUrl ? (() => { + try { + return new URL(firstUrl).hostname.replace(/^www\./, '') + } catch { + return firstUrl } - return null - })()} - + })() : null + + return ( + <> + {displayContent && ( +
+ +
+ )} + {/* Image preview matching LinkPreview component exactly */} + {isImageUrl && firstUrl && ( + + )} + + ) + })() ) : ( Nothing to preview From fc582c9b79ddbaf573a2a13cdad954f398875ec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 19:54:19 +0000 Subject: [PATCH 3/3] Improve compose preview image handling - Fix duplicate LinkIcon imports by aliasing solid variant to LinkIconSolid - Fix URL stripping to handle all variants (http://, https://, //, www.) using regex that matches optional protocols and www prefix - Replace DOM innerHTML manipulation with React state-based error handling - Use Next.js Image component with unoptimized for external URLs - Reset imageError state when post content changes https://claude.ai/code/session_01QS6hsm9agZ7diaU7Tp8waK --- components/compose/compose-modal.tsx | 69 +++++++++++++++++++--------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index 42cc6c0a..8110ffe0 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -9,7 +9,9 @@ import { EyeIcon, EyeSlashIcon, ExclamationTriangleIcon, + LinkIcon, } from '@heroicons/react/24/outline' +import Image from 'next/image' import { useAppStore, useSettingsStore, ThreadPost, PostVisibility } from '@/lib/store' import type { Post } from '@/lib/types' import { Button } from '@/components/ui/button' @@ -22,7 +24,6 @@ import { usePlatformDetection } from '@/hooks/use-platform-detection' import { UserAvatar } from '@/components/ui/avatar-image' import { extractAllTags, extractMentions } from '@/lib/post-helpers' import { extractFirstUrl, isDirectImageUrl, stripTrailingPunctuation } from '@/hooks/use-link-preview' -import { LinkIcon } from '@heroicons/react/24/outline' import { hashtagService } from '@/lib/services/hashtag-service' import { mentionService } from '@/lib/services/mention-service' import { extractErrorMessage, isTimeoutError, categorizeError } from '@/lib/error-utils' @@ -39,7 +40,7 @@ import { getDialogDescription, } from './compose-sub-components' import { VisibilitySelector, TEASER_LIMIT } from './visibility-selector' -import { LockClosedIcon, LinkIcon } from '@heroicons/react/24/solid' +import { LockClosedIcon, LinkIcon as LinkIconSolid } from '@heroicons/react/24/solid' import { isPrivatePost } from '@/components/post/private-post-content' import type { EncryptionSource } from '@/lib/services/post-service' import { AddEncryptionKeyModal } from '@/components/auth/add-encryption-key-modal' @@ -170,6 +171,12 @@ function ThreadPostEditor({ }) { const localRef = useRef(null) const ref = textareaRef || localRef + const [imageError, setImageError] = useState(false) + + // Reset image error when content changes + useEffect(() => { + setImageError(false) + }, [post.content]) useEffect(() => { if (isActive && ref.current) { @@ -401,11 +408,28 @@ function ThreadPostEditor({ const isImageUrl = firstUrl && isDirectImageUrl(firstUrl) // Strip the image URL from content (matching PostContent behavior) + // Handle all URL variants: http://, https://, //, www., or protocol-less let displayContent = post.content if (isImageUrl && firstUrl) { - // Remove the URL but keep any trailing punctuation - const cleanUrl = stripTrailingPunctuation(firstUrl) - displayContent = post.content.replace(cleanUrl, '').trim() + try { + const parsedUrl = new URL(firstUrl) + // Get the path portion (e.g., /path/to/image.jpg) + const pathAndQuery = parsedUrl.pathname + parsedUrl.search + parsedUrl.hash + // Escape special regex characters in hostname and path + const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const escapedHost = escapeRegex(parsedUrl.hostname) + const escapedPath = escapeRegex(pathAndQuery) + // Build regex that matches: optional protocol (http://, https://, //), optional www., hostname, path + const urlPattern = new RegExp( + `(?:https?:\\/\\/|\\/)?\\/?(www\\.)?${escapedHost}${escapedPath}`, + 'gi' + ) + displayContent = post.content.replace(urlPattern, '').trim() + } catch { + // Fallback to simple replacement + const cleanUrl = stripTrailingPunctuation(firstUrl) + displayContent = post.content.replace(cleanUrl, '').trim() + } } // Extract hostname for footer @@ -434,20 +458,23 @@ function ThreadPostEditor({ onClick={(e) => e.stopPropagation()} className="block border border-neutral-200 dark:border-neutral-700 rounded-xl overflow-hidden hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors" > -
- {/* eslint-disable-next-line @next/next/no-img-element */} - Image preview { - const parent = e.currentTarget.parentElement - if (parent) { - parent.innerHTML = '
Failed to load image
' - } - }} - /> -
+ {!imageError ? ( +
+ Image preview setImageError(true)} + unoptimized + /> +
+ ) : ( +
+ Failed to load image +
+ )} {/* Domain info footer */}
@@ -1366,7 +1393,7 @@ export function ComposeModal() { animate={{ opacity: 1, y: 0 }} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800" > - + Your reply will be visible to all subscribers of this private feed @@ -1532,7 +1559,7 @@ export function ComposeModal() { {/* Inherited encryption indicator */} {inheritedEncryption && !isPrivatePostVisibility && (
- + Reply inherits parent's encryption
)}