Fixed remote link previews

This commit is contained in:
Christopher
2026-01-22 15:50:37 -08:00
parent 898125a131
commit 482acf1ff6
2 changed files with 139 additions and 54 deletions
+116 -53
View File
@@ -22,6 +22,58 @@ const sanitizeText = (value?: string | null) => {
return decoded.replace(/\s+/g, ' ').trim() || null; return decoded.replace(/\s+/g, ' ').trim() || null;
}; };
const extractTextAndUrls = (value?: string | null) => {
if (!value) return { text: '', urls: [] as string[] };
let html = value;
// Replace <br> with spaces to avoid words running together.
html = html.replace(/<br\s*\/?>/gi, ' ');
// Replace anchor tags with their hrefs (preferred) or inner text.
html = html.replace(/<a[^>]*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(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
const match = html.match(regex);
if (match) return match[1];
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
const matchRev = html.match(regexRev);
return matchRev ? matchRev[1] : null;
};
const title = getMeta('title') || html.match(/<title>([^<]+)<\/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 parseRemoteHandle = (handle: string) => {
const clean = handle.toLowerCase().replace(/^@/, ''); const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@').filter(Boolean); 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, avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
bio: sanitizeText(remoteProfile.summary), bio: sanitizeText(remoteProfile.summary),
}; };
const posts = outboxItems const posts = [];
.map((item: any) => { for (const item of outboxItems) {
const activity = item?.type === 'Create' ? item : null; const activity = item?.type === 'Create' ? item : null;
const object = activity?.object; const object = activity?.object;
if (!object || typeof object === 'string' || object.type !== 'Note') { if (!object || typeof object === 'string' || object.type !== 'Note') {
return null; continue;
} }
const attachments = Array.isArray(object.attachment) ? object.attachment : []; const attachments = Array.isArray(object.attachment) ? object.attachment : [];
return { const { text, urls } = extractTextAndUrls(object.content);
id: object.id || activity.id, const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
content: sanitizeText(object.content) || '', const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl) : null;
createdAt: object.published || activity.published || new Date().toISOString(), posts.push({
likesCount: 0, id: object.id || activity.id,
repostsCount: 0, content: text || '',
repliesCount: 0, createdAt: object.published || activity.published || new Date().toISOString(),
author, likesCount: 0,
media: attachments repostsCount: 0,
.filter((attachment: any) => attachment?.url) repliesCount: 0,
.map((attachment: any, index: number) => ({ author,
id: `${object.id || activity.id || 'media'}-${index}`, media: attachments
url: attachment.url, .filter((attachment: any) => attachment?.url)
altText: sanitizeText(attachment.name) || null, .map((attachment: any, index: number) => ({
})), id: `${object.id || activity.id || 'media'}-${index}`,
}; url: attachment.url,
}) altText: sanitizeText(attachment.name) || null,
.filter(Boolean); })),
linkPreviewUrl: linkPreview?.url || normalizedUrl,
linkPreviewTitle: linkPreview?.title || (normalizedUrl ?? null),
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
});
}
return NextResponse.json({ posts, nextCursor: 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, avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
bio: sanitizeText(remoteProfile.summary), bio: sanitizeText(remoteProfile.summary),
}; };
const posts = [];
const posts = outboxItems for (const item of outboxItems) {
.map((item: any) => { const activity = item?.type === 'Create' ? item : null;
const activity = item?.type === 'Create' ? item : null; const object = activity?.object;
const object = activity?.object; if (!object || typeof object === 'string' || object.type !== 'Note') {
if (!object || typeof object === 'string' || object.type !== 'Note') { continue;
return null; }
} const attachments = Array.isArray(object.attachment) ? object.attachment : [];
const attachments = Array.isArray(object.attachment) ? object.attachment : []; const { text, urls } = extractTextAndUrls(object.content);
return { const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
id: object.id || activity.id, const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl) : null;
content: sanitizeText(object.content) || '', posts.push({
createdAt: object.published || activity.published || new Date().toISOString(), id: object.id || activity.id,
likesCount: 0, content: text || '',
repostsCount: 0, createdAt: object.published || activity.published || new Date().toISOString(),
repliesCount: 0, likesCount: 0,
author, repostsCount: 0,
media: attachments repliesCount: 0,
.filter((attachment: any) => attachment?.url) author,
.map((attachment: any, index: number) => ({ media: attachments
id: `${object.id || activity.id || 'media'}-${index}`, .filter((attachment: any) => attachment?.url)
url: attachment.url, .map((attachment: any, index: number) => ({
altText: sanitizeText(attachment.name) || null, id: `${object.id || activity.id || 'media'}-${index}`,
})), url: attachment.url,
}; altText: sanitizeText(attachment.name) || null,
}) })),
.filter(Boolean); linkPreviewUrl: linkPreview?.url || normalizedUrl,
linkPreviewTitle: linkPreview?.title || (normalizedUrl ?? null),
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
});
}
return NextResponse.json({ return NextResponse.json({
posts, posts,
+23 -1
View File
@@ -118,6 +118,28 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, isDetail
const postUrl = `/${post.author.handle}/posts/${post.id}`; 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 ( return (
<article className={`post ${isDetail ? 'detail' : ''}`}> <article className={`post ${isDetail ? 'detail' : ''}`}>
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />} {!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>
)} )}
<div className="post-content">{post.content}</div> <div className="post-content">{renderContent(post.content)}</div>
{post.media && post.media.length > 0 && ( {post.media && post.media.length > 0 && (
<div className="post-media-grid"> <div className="post-media-grid">