diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index d76f2f7..50115cf 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -22,6 +22,58 @@ const sanitizeText = (value?: string | null) => { return decoded.replace(/\s+/g, ' ').trim() || null; }; +const extractTextAndUrls = (value?: string | null) => { + if (!value) return { text: '', urls: [] as string[] }; + let html = value; + // Replace
with spaces to avoid words running together. + html = html.replace(//gi, ' '); + // Replace anchor tags with their hrefs (preferred) or inner text. + html = html.replace(/]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (_, href, text) => { + const cleanedHref = decodeEntities(String(href)); + const cleanedText = decodeEntities(String(text)).replace(/<[^>]*>/g, ' ').trim(); + return cleanedHref || cleanedText; + }); + const withoutTags = html.replace(/<[^>]*>/g, ' '); + const decoded = decodeEntities(withoutTags); + const text = decoded.replace(/\s+/g, ' ').trim(); + const urls = Array.from(text.matchAll(/https?:\/\/[^\s]+/gi)).map((match) => match[0]); + return { text, urls }; +}; + +const normalizeUrl = (value: string) => value.replace(/[)\].,!?]+$/, ''); + +const fetchLinkPreview = async (url: string) => { + try { + const res = await fetch(url, { + headers: { + 'User-Agent': 'SynapsisBot/1.0', + }, + signal: AbortSignal.timeout(4000), + }); + if (!res.ok) return null; + const html = await res.text(); + const getMeta = (property: string) => { + const regex = new RegExp(`]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'); + const match = html.match(regex); + if (match) return match[1]; + const regexRev = new RegExp(`]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i'); + const matchRev = html.match(regexRev); + return matchRev ? matchRev[1] : null; + }; + const title = getMeta('title') || html.match(/([^<]+)<\/title>/i)?.[1]; + const description = getMeta('description'); + const image = getMeta('image'); + return { + url, + title: title?.trim() || url, + description: description?.trim() || null, + image: image?.trim() || null, + }; + } catch { + return null; + } +}; + const parseRemoteHandle = (handle: string) => { const clean = handle.toLowerCase().replace(/^@/, ''); const parts = clean.split('@').filter(Boolean); @@ -83,32 +135,38 @@ export async function GET(request: Request, context: RouteContext) { avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url, bio: sanitizeText(remoteProfile.summary), }; - const posts = outboxItems - .map((item: any) => { - const activity = item?.type === 'Create' ? item : null; - const object = activity?.object; - if (!object || typeof object === 'string' || object.type !== 'Note') { - return null; - } - const attachments = Array.isArray(object.attachment) ? object.attachment : []; - return { - id: object.id || activity.id, - content: sanitizeText(object.content) || '', - createdAt: object.published || activity.published || new Date().toISOString(), - likesCount: 0, - repostsCount: 0, - repliesCount: 0, - author, - media: attachments - .filter((attachment: any) => attachment?.url) - .map((attachment: any, index: number) => ({ - id: `${object.id || activity.id || 'media'}-${index}`, - url: attachment.url, - altText: sanitizeText(attachment.name) || null, - })), - }; - }) - .filter(Boolean); + const posts = []; + for (const item of outboxItems) { + const activity = item?.type === 'Create' ? item : null; + const object = activity?.object; + if (!object || typeof object === 'string' || object.type !== 'Note') { + continue; + } + const attachments = Array.isArray(object.attachment) ? object.attachment : []; + const { text, urls } = extractTextAndUrls(object.content); + const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null; + const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl) : null; + posts.push({ + id: object.id || activity.id, + content: text || '', + createdAt: object.published || activity.published || new Date().toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author, + media: attachments + .filter((attachment: any) => attachment?.url) + .map((attachment: any, index: number) => ({ + id: `${object.id || activity.id || 'media'}-${index}`, + url: attachment.url, + altText: sanitizeText(attachment.name) || null, + })), + linkPreviewUrl: linkPreview?.url || normalizedUrl, + linkPreviewTitle: linkPreview?.title || (normalizedUrl ?? null), + linkPreviewDescription: linkPreview?.description || null, + linkPreviewImage: linkPreview?.image || null, + }); + } return NextResponse.json({ posts, nextCursor: null }); } @@ -135,33 +193,38 @@ export async function GET(request: Request, context: RouteContext) { avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url, bio: sanitizeText(remoteProfile.summary), }; - - const posts = outboxItems - .map((item: any) => { - const activity = item?.type === 'Create' ? item : null; - const object = activity?.object; - if (!object || typeof object === 'string' || object.type !== 'Note') { - return null; - } - const attachments = Array.isArray(object.attachment) ? object.attachment : []; - return { - id: object.id || activity.id, - content: sanitizeText(object.content) || '', - createdAt: object.published || activity.published || new Date().toISOString(), - likesCount: 0, - repostsCount: 0, - repliesCount: 0, - author, - media: attachments - .filter((attachment: any) => attachment?.url) - .map((attachment: any, index: number) => ({ - id: `${object.id || activity.id || 'media'}-${index}`, - url: attachment.url, - altText: sanitizeText(attachment.name) || null, - })), - }; - }) - .filter(Boolean); + const posts = []; + for (const item of outboxItems) { + const activity = item?.type === 'Create' ? item : null; + const object = activity?.object; + if (!object || typeof object === 'string' || object.type !== 'Note') { + continue; + } + const attachments = Array.isArray(object.attachment) ? object.attachment : []; + const { text, urls } = extractTextAndUrls(object.content); + const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null; + const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl) : null; + posts.push({ + id: object.id || activity.id, + content: text || '', + createdAt: object.published || activity.published || new Date().toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author, + media: attachments + .filter((attachment: any) => attachment?.url) + .map((attachment: any, index: number) => ({ + id: `${object.id || activity.id || 'media'}-${index}`, + url: attachment.url, + altText: sanitizeText(attachment.name) || null, + })), + linkPreviewUrl: linkPreview?.url || normalizedUrl, + linkPreviewTitle: linkPreview?.title || (normalizedUrl ?? null), + linkPreviewDescription: linkPreview?.description || null, + linkPreviewImage: linkPreview?.image || null, + }); + } return NextResponse.json({ posts, diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 10ee4d4..ae89e73 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -118,6 +118,28 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail const postUrl = `/${post.author.handle}/posts/${post.id}`; + const renderContent = (content: string) => { + const parts = content.split(/(https?:\/\/[^\s]+)/g); + return parts.map((part, index) => { + if (part.match(/^https?:\/\/[^\s]+$/)) { + const display = part.replace(/^https?:\/\//, ''); + const truncated = display.length > 48 ? `${display.slice(0, 32)}…${display.slice(-8)}` : display; + return ( + <a + key={`url-${index}`} + href={part} + target="_blank" + rel="noopener noreferrer" + onClick={(e) => e.stopPropagation()} + > + {truncated} + </a> + ); + } + return <span key={`text-${index}`}>{part}</span>; + }); + }; + return ( <article className={`post ${isDetail ? 'detail' : ''}`}> {!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />} @@ -146,7 +168,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail </div> )} - <div className="post-content">{post.content}</div> + <div className="post-content">{renderContent(post.content)}</div> {post.media && post.media.length > 0 && ( <div className="post-media-grid">