Merge branch 'fix/repost-link-overflow' into 'main'

Fix horizontal overflow on posts with unrenderable links when reposting

Closes #5

See merge request soapbox-pub/ditto-mew!12
This commit is contained in:
Alex Gleason
2026-02-24 01:04:02 +00:00
4 changed files with 51 additions and 8 deletions
+46 -3
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Image, Film, ExternalLink, Blocks } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
@@ -104,8 +105,8 @@ function EmbeddedNoteCard({
// Truncate long content, stripping media URLs and nested nostr event references
const truncatedContent = useMemo(() => {
const cleaned = event.content
// Strip media URLs
.replace(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|mp3|ogg|wav|pdf)(\?[^\s]*)?/gi, '')
// Strip media URLs (same extensions as NoteContent's MEDIA_URL_REGEX)
.replace(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|mp3|ogg|wav|pdf|xdc)(\?[^\s]*)?/gi, '')
// Strip embedded event references (nevent / note) so they don't nest
.replace(/nostr:(nevent1|note1)[023456789acdefghjklmnpqrstuvwxyz]+/g, '')
// Collapse leftover whitespace
@@ -123,6 +124,18 @@ function EmbeddedNoteCard({
return match?.[0] ?? null;
}, [event.content]);
// Detect stripped attachments to show indicator chips
const attachments = useMemo(() => {
const imgs = (event.content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi) || []).length;
const vids = (event.content.match(/https?:\/\/[^\s]+\.(mp4|webm|mov)(\?[^\s]*)?/gi) || []).length;
const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length;
// Count non-media URLs that were kept as link previews in the full render
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
const mediaRegex = /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|mp3|ogg|wav|pdf|xdc)(\?[^\s]*)?$/i;
const links = allUrls.filter((u) => !mediaRegex.test(u)).length;
return { imgs, vids, apps, links };
}, [event.content]);
// NIP-36 content-warning check
const cwTag = event.tags.find(([name]) => name === 'content-warning');
const hasCW = !!cwTag;
@@ -221,6 +234,36 @@ function EmbeddedNoteCard({
) : truncatedContent ? (
<EmbedContentPreview text={truncatedContent} />
) : null}
{/* Attachment indicators for stripped media/links */}
{!hasCW && (attachments.imgs > (firstImage ? 1 : 0) || attachments.vids > 0 || attachments.apps > 0 || attachments.links > 0) && (
<div className="flex items-center gap-2 flex-wrap">
{attachments.imgs > (firstImage ? 1 : 0) && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Image className="size-3" />
{attachments.imgs > 1 ? `${attachments.imgs} images` : '1 image'}
</span>
)}
{attachments.vids > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Film className="size-3" />
{attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'}
</span>
)}
{attachments.apps > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Blocks className="size-3" />
App
</span>
)}
{attachments.links > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<ExternalLink className="size-3" />
{attachments.links > 1 ? `${attachments.links} links` : 'Link'}
</span>
)}
</div>
)}
</div>
</div>
);
@@ -231,7 +274,7 @@ function EmbedContentPreview({ text }: { text: string }) {
const segments = useMemo(() => parseEmbedSegments(text), [text]);
return (
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words line-clamp-3">
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-hidden line-clamp-3">
{segments.map((seg, i) => {
if (seg.type === 'text') {
return <span key={i}>{seg.value}</span>;
+2 -2
View File
@@ -340,7 +340,7 @@ export function NoteCard({ event, className, repostedBy, compact, threaded }: No
return (
<article
className={cn(
'px-4 pt-3 pb-0 hover:bg-secondary/30 transition-colors cursor-pointer',
'px-4 pt-3 pb-0 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden',
className,
)}
onClick={handleCardClick}
@@ -363,7 +363,7 @@ export function NoteCard({ event, className, repostedBy, compact, threaded }: No
return (
<article
className={cn(
'px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer',
'px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden',
className,
)}
onClick={handleCardClick}
+1 -1
View File
@@ -274,7 +274,7 @@ export function NoteContent({
const isEmojiOnly = tokens.length === 1 && tokens[0].type === 'text' && isOnlyEmojis(tokens[0].value);
return (
<div className={cn('whitespace-pre-wrap break-words', isEmojiOnly && 'text-5xl leading-tight', className)}>
<div className={cn('whitespace-pre-wrap break-words overflow-hidden', isEmojiOnly && 'text-5xl leading-tight', className)}>
{tokens.map((token, i) => {
switch (token.type) {
case 'text':
+2 -2
View File
@@ -141,8 +141,8 @@ function EmbeddedPost({ event }: { event: NostrEvent }) {
</div>
{/* Content preview clamp to a few lines */}
<div className="text-sm line-clamp-4">
<NoteContent event={event} className="text-sm leading-relaxed" />
<div className="text-sm line-clamp-4 overflow-hidden">
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
</div>
{/* Show first image thumbnail if any */}