-
Notifications
You must be signed in to change notification settings - Fork 2
Fix image preview not showing in compose modal preview #198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -21,6 +23,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, stripTrailingPunctuation } 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' | ||
|
|
@@ -37,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' | ||
|
|
@@ -168,6 +171,12 @@ function ThreadPostEditor({ | |
| }) { | ||
| const localRef = useRef<HTMLTextAreaElement>(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) { | ||
|
|
@@ -388,13 +397,95 @@ function ThreadPostEditor({ | |
|
|
||
| {/* Content area */} | ||
| {showPreview || isPosted ? ( | ||
| <div className={`min-h-[60px] whitespace-pre-wrap break-words ${ | ||
| <div className={`min-h-[60px] ${ | ||
| isPosted | ||
| ? 'text-gray-600 dark:text-gray-400' | ||
| : 'text-gray-900 dark:text-gray-100' | ||
| }`}> | ||
| {post.content ? ( | ||
| <MarkdownContent content={post.content} /> | ||
| (() => { | ||
| const firstUrl = extractFirstUrl(post.content) | ||
| 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) { | ||
| 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() | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Extract hostname for footer | ||
| const hostname = isImageUrl && firstUrl ? (() => { | ||
| try { | ||
| return new URL(firstUrl).hostname.replace(/^www\./, '') | ||
| } catch { | ||
| return firstUrl | ||
| } | ||
| })() : null | ||
|
|
||
| return ( | ||
| <> | ||
| {displayContent && ( | ||
| <div className="whitespace-pre-wrap break-words"> | ||
| <MarkdownContent content={displayContent} /> | ||
| </div> | ||
| )} | ||
| {/* Image preview matching LinkPreview component exactly */} | ||
| {isImageUrl && firstUrl && ( | ||
| <div className="mt-3"> | ||
| <a | ||
| href={firstUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| 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" | ||
| > | ||
| {!imageError ? ( | ||
| <div className="relative bg-neutral-100 dark:bg-neutral-800"> | ||
| <Image | ||
| src={firstUrl} | ||
| alt="Image preview" | ||
| width={600} | ||
| height={400} | ||
| className="w-full max-h-[400px] object-contain" | ||
| onError={() => setImageError(true)} | ||
| unoptimized | ||
|
Comment on lines
+452
to
+470
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Respect the link-preview privacy setting before loading images The compose preview renders the remote image whenever the first URL has an image extension, regardless of source: ['codex'] |
||
| /> | ||
| </div> | ||
| ) : ( | ||
| <div className="flex items-center justify-center h-32 bg-neutral-100 dark:bg-neutral-800 text-neutral-400"> | ||
| Failed to load image | ||
| </div> | ||
| )} | ||
| {/* Domain info footer */} | ||
| <div className="flex items-center gap-1.5 px-3 py-2 text-xs text-neutral-500 dark:text-neutral-400 border-t border-neutral-200 dark:border-neutral-700"> | ||
| <LinkIcon className="h-3.5 w-3.5 flex-shrink-0" /> | ||
| <span className="truncate">{hostname}</span> | ||
| </div> | ||
| </a> | ||
| </div> | ||
| )} | ||
| </> | ||
| ) | ||
| })() | ||
| ) : ( | ||
| <span className="text-gray-400 dark:text-gray-600 italic"> | ||
| Nothing to preview | ||
|
|
@@ -1302,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" | ||
| > | ||
| <LinkIcon className="w-4 h-4 text-purple-600 dark:text-purple-400" /> | ||
| <LinkIconSolid className="w-4 h-4 text-purple-600 dark:text-purple-400" /> | ||
| <span className="text-sm text-purple-700 dark:text-purple-300"> | ||
| Your reply will be visible to all subscribers of this private feed | ||
| </span> | ||
|
|
@@ -1468,7 +1559,7 @@ export function ComposeModal() { | |
| {/* Inherited encryption indicator */} | ||
| {inheritedEncryption && !isPrivatePostVisibility && ( | ||
| <div className="flex items-center gap-1.5 text-xs text-purple-600 dark:text-purple-400"> | ||
| <LinkIcon className="w-3 h-3" /> | ||
| <LinkIconSolid className="w-3 h-3" /> | ||
| <span>Reply inherits parent's encryption</span> | ||
| </div> | ||
| )} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Blocking: Remove only the exact detected URL token
This global regex has no token boundaries, so it removes the detected image URL wherever its hostname and path appear as a substring. For example, detecting
https://cdn.example.com/a.jpgchanges neighboring text containinghttps://evilcdn.example.com/a.jpgtohttps://evilandhttps://cdn.example.com/a.jpg2to2. It also reconstructs the URL withhostname, excluding an explicit port and potentially failing to remove the actual token. The compose preview can therefore show corrupted text that the posted-content parser will preserve. Remove only the exact URL occurrence using the same token semantics asPostContent.source: ['codex']