Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 96 additions & 5 deletions components/compose/compose-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Comment on lines +423 to +427

Copy link
Copy Markdown
Collaborator

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.jpg changes neighboring text containing https://evilcdn.example.com/a.jpg to https://evil and https://cdn.example.com/a.jpg2 to 2. It also reconstructs the URL with hostname, 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 as PostContent.

source: ['codex']

} catch {
// Fallback to simple replacement
const cleanUrl = stripTrailingPunctuation(firstUrl)
displayContent = post.content.replace(cleanUrl, '').trim()
}
}
Comment thread
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 useSettingsStore(...linkPreviews). Posted content disables useLinkPreview and LinkPreview when that privacy setting is false, leaving the URL visible instead of contacting its host. Opening compose preview therefore leaks a request to the remote image host despite the user's opt-out and produces a different preview from the posted content. Gate both the image rendering and URL removal on the setting, or reuse the shared posted-content rendering path.

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
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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&apos;s encryption</span>
</div>
)}
Expand Down