feat: remove title/header image, add Twitter-style link previews

Remove the title input and header image upload/generate section from
the thread builder editor. Title is now auto-derived from first tweet.

Add link preview cards that render inline in tweet content, similar to
Twitter's URL card unfurling. Server-side /api/link-preview endpoint
fetches OG metadata (title, description, image) with caching.

URLs in tweet text are rendered as clickable links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-05 21:04:58 -08:00
parent ffbc1ce127
commit b591267b81
2 changed files with 178 additions and 156 deletions

View File

@ -32,6 +32,7 @@ export class FolkThreadBuilder extends HTMLElement {
private _offlineUnsub: (() => void) | null = null;
private _offlineReady: Promise<void> | null = null;
private _tweetImageUploadIdx: string | null = null;
private _linkPreviewCache: Map<string, { title: string; description: string; image: string | null; domain: string } | null> = new Map();
// SVG icons
private svgReply = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
@ -217,6 +218,7 @@ export class FolkThreadBuilder extends HTMLElement {
const tweetImgHtml = tweetImgUrl
? `<div class="attached-image"><img src="${this.esc(tweetImgUrl)}" alt="Tweet image"></div>`
: '';
const linkCards = this.extractLinkCards(text);
return `<div class="tweet-card">
${connector}
<div class="tc-header">
@ -226,8 +228,9 @@ export class FolkThreadBuilder extends HTMLElement {
<span class="tc-dot">&#183;</span>
<span class="tc-time">${this.esc(dateStr)}</span>
</div>
<p class="tc-content">${this.esc(text)}</p>
<p class="tc-content">${this.renderTweetContent(text)}</p>
${tweetImgHtml}
${linkCards}
<div class="tc-footer">
<div class="tc-actions">
<span class="tc-action">${this.svgReply}</span>
@ -243,10 +246,6 @@ export class FolkThreadBuilder extends HTMLElement {
</div>`;
}).join('\n');
const imageHTML = t.imageUrl
? `<div class="ro-image"><img src="${this.esc(t.imageUrl)}" alt="Thread preview"></div>`
: '';
this.shadowRoot.innerHTML = `
<style>${this.getBaseStyles()}${this.getReadonlyStyles()}</style>
<div class="thread-ro">
@ -264,8 +263,6 @@ export class FolkThreadBuilder extends HTMLElement {
<span>${this.esc(dateStr)}</span>
</div>
</div>
${t.title ? `<h1 class="ro-title">${this.esc(t.title)}</h1>` : ''}
${imageHTML}
<div class="preview ro-cards">${tweetCards}</div>
<div class="ro-actions">
<a href="/${this.esc(this._space)}/rsocials/thread/${this.esc(t.id)}/edit" class="btn btn--primary">Edit Thread</a>
@ -324,17 +321,6 @@ export class FolkThreadBuilder extends HTMLElement {
</div>
<div id="share-link-area"></div>
<div class="compose">
<div class="image-section">
<input type="file" id="upload-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
<div class="image-preview" id="thread-image-preview" ${t?.imageUrl ? '' : 'hidden'}>
<img id="thread-image-thumb" alt="Preview" ${t?.imageUrl ? `src="${this.esc(t.imageUrl)}"` : ''}>
</div>
<div class="image-buttons">
<button class="btn btn--outline" id="upload-image-btn">${t?.imageUrl ? 'Replace Image' : 'Upload Image'}</button>
<button class="btn btn--outline" id="gen-image-btn">${t?.imageUrl ? 'Replace with AI' : 'Generate with AI'}</button>
</div>
</div>
<input class="compose-title" id="thread-title" placeholder="Thread title (defaults to first tweet)" value="${this.esc(t?.title || '')}">
<div class="compose-fields">
<input class="compose-input" id="thread-name" placeholder="Display name" value="${this.esc(t?.name || 'Your Name')}">
<input class="compose-input" id="thread-handle" placeholder="@handle" value="${this.esc(t?.handle || '@yourhandle')}">
@ -397,6 +383,7 @@ export class FolkThreadBuilder extends HTMLElement {
<button data-generate-idx="${i}">${this.svgSparkle} Generate with AI</button>
</div>`
: '';
const linkCards = this.extractLinkCards(text);
return `<div class="tweet-card">
${connector}
${photoBtn}
@ -407,8 +394,9 @@ export class FolkThreadBuilder extends HTMLElement {
<span class="tc-dot">&#183;</span>
<span class="tc-time">now</span>
</div>
<p class="tc-content">${this.esc(text)}</p>
<p class="tc-content">${this.renderTweetContent(text)}</p>
${imgHtml}
${linkCards}
<div class="tc-footer">
<div class="tc-actions">
<span class="tc-action">${this.svgReply}</span>
@ -425,6 +413,94 @@ export class FolkThreadBuilder extends HTMLElement {
}).join('');
}
// ── Link preview helpers ──
private static URL_REGEX = /https?:\/\/[^\s<>"')\]]+/gi;
private extractUrls(text: string): string[] {
return [...text.matchAll(FolkThreadBuilder.URL_REGEX)].map(m => m[0]);
}
private renderTweetContent(text: string): string {
// Escape first, then linkify URLs
let html = this.esc(text);
html = html.replace(/https?:\/\/[^\s&lt;&quot;&#39;\]\)]+/gi, (url) => {
// Decode the escaped URL back for href
const decoded = url.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"');
return `<a href="${url}" class="tc-link" target="_blank" rel="noopener">${url}</a>`;
});
return html;
}
private extractLinkCards(text: string): string {
const urls = this.extractUrls(text);
if (!urls.length) return '';
return urls.map(url => {
const cached = this._linkPreviewCache.get(url);
if (cached === undefined) {
// Not fetched yet — start fetching and show placeholder
this.fetchLinkPreview(url);
const domain = this.getDomain(url);
return `<a href="${this.esc(url)}" class="link-card link-card--loading" target="_blank" rel="noopener">
<div class="link-card__body">
<div class="link-card__domain">${this.esc(domain)}</div>
<div class="link-card__title">Loading preview...</div>
</div>
</a>`;
}
if (cached === null) {
// Fetch failed — show simple link card
const domain = this.getDomain(url);
return `<a href="${this.esc(url)}" class="link-card" target="_blank" rel="noopener">
<div class="link-card__body">
<div class="link-card__domain">${this.esc(domain)}</div>
<div class="link-card__title">${this.esc(url)}</div>
</div>
</a>`;
}
// Full preview card
const imgHtml = cached.image
? `<div class="link-card__image"><img src="${this.esc(cached.image)}" alt="" loading="lazy"></div>`
: '';
return `<a href="${this.esc(url)}" class="link-card" target="_blank" rel="noopener">
${imgHtml}
<div class="link-card__body">
<div class="link-card__domain">${this.esc(cached.domain)}</div>
<div class="link-card__title">${this.esc(cached.title)}</div>
${cached.description ? `<div class="link-card__desc">${this.esc(cached.description.substring(0, 120))}</div>` : ''}
</div>
</a>`;
}).join('');
}
private getDomain(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; }
}
private async fetchLinkPreview(url: string) {
if (this._linkPreviewCache.has(url)) return;
this._linkPreviewCache.set(url, undefined as any); // Mark as in-progress
try {
const res = await fetch('/api/link-preview?url=' + encodeURIComponent(url));
if (!res.ok) throw new Error('fetch failed');
const data = await res.json();
this._linkPreviewCache.set(url, {
title: data.title || url,
description: data.description || '',
image: data.image || null,
domain: data.domain || this.getDomain(url),
});
} catch {
this._linkPreviewCache.set(url, null);
}
// Re-render to show fetched preview
if (this._mode === 'readonly') {
this.renderReadonly();
} else {
this.renderPreview();
}
}
// ── Draft management (Automerge) ──
private async saveDraft() {
@ -433,7 +509,6 @@ export class FolkThreadBuilder extends HTMLElement {
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
const handleInput = sr.getElementById('thread-handle') as HTMLInputElement;
const titleInput = sr.getElementById('thread-title') as HTMLInputElement;
const saveBtn = sr.getElementById('thread-save') as HTMLButtonElement;
const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean);
@ -447,7 +522,7 @@ export class FolkThreadBuilder extends HTMLElement {
id: this._threadId,
name: nameInput.value || 'Your Name',
handle: handleInput.value || '@yourhandle',
title: titleInput.value || tweets[0].substring(0, 60),
title: tweets[0].substring(0, 60),
tweets,
tweetImages: Object.keys(this._tweetImages).length ? { ...this._tweetImages } : null,
imageUrl: this._thread?.imageUrl || null,
@ -485,27 +560,10 @@ export class FolkThreadBuilder extends HTMLElement {
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
const handleInput = sr.getElementById('thread-handle') as HTMLInputElement;
const titleInput = sr.getElementById('thread-title') as HTMLInputElement;
const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement;
const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement;
const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement;
const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement;
textarea.value = thread.tweets.join('\n---\n');
nameInput.value = thread.name || '';
handleInput.value = thread.handle || '';
titleInput.value = thread.title || '';
if (thread.imageUrl) {
imageThumb.src = thread.imageUrl;
imagePreview.hidden = false;
genBtn.textContent = 'Replace with AI';
uploadBtn.textContent = 'Replace Image';
} else {
imagePreview.hidden = true;
genBtn.textContent = 'Generate with AI';
uploadBtn.textContent = 'Upload Image';
}
history.replaceState(null, '', this.basePath + 'thread/' + thread.id + '/edit');
this.renderPreview();
@ -531,13 +589,7 @@ export class FolkThreadBuilder extends HTMLElement {
const sr = this.shadowRoot;
if (sr) {
const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement;
const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement;
const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement;
const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement;
if (imagePreview) imagePreview.hidden = true;
if (genBtn) genBtn.textContent = 'Generate with AI';
if (uploadBtn) uploadBtn.textContent = 'Upload Image';
if (shareLinkArea) shareLinkArea.innerHTML = '';
}
}
@ -582,91 +634,6 @@ export class FolkThreadBuilder extends HTMLElement {
// ── Image operations (server API) ──
private async generateImage() {
if (!this._threadId) {
await this.saveDraft();
if (!this._threadId) return;
}
const sr = this.shadowRoot;
if (!sr) return;
const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement;
const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement;
const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement;
const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement;
genBtn.textContent = 'Generating...';
genBtn.disabled = true;
try {
const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/image', { method: 'POST' });
const data = await res.json();
if (data.imageUrl) {
imageThumb.src = data.imageUrl;
imagePreview.hidden = false;
genBtn.textContent = 'Replace with AI';
uploadBtn.textContent = 'Replace Image';
// Update Automerge with new image URL
if (this._thread) {
this._thread.imageUrl = data.imageUrl;
await this.saveToAutomerge(this._thread);
}
} else {
genBtn.textContent = 'Generation Failed';
setTimeout(() => { genBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
}
} catch {
genBtn.textContent = 'Generation Failed';
setTimeout(() => { genBtn.textContent = imagePreview.hidden ? 'Generate with AI' : 'Replace with AI'; }, 2000);
} finally {
genBtn.disabled = false;
}
}
private async uploadImage(file: File) {
if (!this._threadId) {
await this.saveDraft();
if (!this._threadId) return;
}
const sr = this.shadowRoot;
if (!sr) return;
const uploadBtn = sr.getElementById('upload-image-btn') as HTMLButtonElement;
const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement;
const imageThumb = sr.getElementById('thread-image-thumb') as HTMLImageElement;
const genBtn = sr.getElementById('gen-image-btn') as HTMLButtonElement;
uploadBtn.textContent = 'Uploading...';
uploadBtn.disabled = true;
try {
const form = new FormData();
form.append('file', file);
const res = await fetch(this.basePath + 'api/threads/' + this._threadId + '/upload-image', { method: 'POST', body: form });
const data = await res.json();
if (data.imageUrl) {
imageThumb.src = data.imageUrl;
imagePreview.hidden = false;
uploadBtn.textContent = 'Replace Image';
genBtn.textContent = 'Replace with AI';
if (this._thread) {
this._thread.imageUrl = data.imageUrl;
await this.saveToAutomerge(this._thread);
}
} else {
uploadBtn.textContent = data.error || 'Upload Failed';
setTimeout(() => { uploadBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000);
}
} catch {
uploadBtn.textContent = 'Upload Failed';
setTimeout(() => { uploadBtn.textContent = imagePreview.hidden ? 'Upload Image' : 'Replace Image'; }, 2000);
} finally {
uploadBtn.disabled = false;
}
}
private async uploadTweetImage(index: string, file: File) {
if (!this._threadId) {
await this.saveDraft();
@ -782,13 +749,9 @@ export class FolkThreadBuilder extends HTMLElement {
const textarea = sr.getElementById('thread-input') as HTMLTextAreaElement;
const nameInput = sr.getElementById('thread-name') as HTMLInputElement;
const handleInput = sr.getElementById('thread-handle') as HTMLInputElement;
const titleInput = sr.getElementById('thread-title') as HTMLInputElement;
const saveBtn = sr.getElementById('thread-save');
const shareBtn = sr.getElementById('thread-share');
const copyBtn = sr.getElementById('thread-copy');
const genImageBtn = sr.getElementById('gen-image-btn');
const uploadImageBtn = sr.getElementById('upload-image-btn');
const uploadImageInput = sr.getElementById('upload-image-input') as HTMLInputElement;
const toggleDraftsBtn = sr.getElementById('toggle-drafts');
const draftsList = sr.getElementById('drafts-list') as HTMLElement;
const preview = sr.getElementById('thread-preview') as HTMLElement;
@ -803,18 +766,10 @@ export class FolkThreadBuilder extends HTMLElement {
textarea?.addEventListener('blur', () => this.scheduleAutoSave());
nameInput?.addEventListener('blur', () => this.scheduleAutoSave());
handleInput?.addEventListener('blur', () => this.scheduleAutoSave());
titleInput?.addEventListener('blur', () => this.scheduleAutoSave());
// Buttons
saveBtn?.addEventListener('click', () => this.saveDraft());
shareBtn?.addEventListener('click', () => this.shareThread());
genImageBtn?.addEventListener('click', () => this.generateImage());
uploadImageBtn?.addEventListener('click', () => uploadImageInput?.click());
uploadImageInput?.addEventListener('change', () => {
const file = uploadImageInput.files?.[0];
if (file) this.uploadImage(file);
uploadImageInput.value = '';
});
// Drafts toggle
toggleDraftsBtn?.addEventListener('click', () => {
@ -935,7 +890,6 @@ export class FolkThreadBuilder extends HTMLElement {
if (!sr) return;
const shareBtn = sr.getElementById('thread-share') as HTMLButtonElement;
const shareLinkArea = sr.getElementById('share-link-area') as HTMLElement;
const imagePreview = sr.getElementById('thread-image-preview') as HTMLElement;
shareBtn.textContent = 'Saving...';
shareBtn.disabled = true;
@ -944,11 +898,6 @@ export class FolkThreadBuilder extends HTMLElement {
await this.saveDraft();
if (!this._threadId) { shareBtn.textContent = 'Share'; shareBtn.disabled = false; return; }
if (imagePreview?.hidden) {
shareBtn.textContent = 'Generating image...';
await this.generateImage();
}
const url = window.location.origin + this.basePath + 'thread/' + this._threadId;
try {
await navigator.clipboard.writeText(url);
@ -1135,12 +1084,6 @@ export class FolkThreadBuilder extends HTMLElement {
}
.draft-item__delete:hover { color: #ef4444; }
.image-section { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 0.75rem; }
.image-buttons { display: flex; gap: 0.5rem; }
.image-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); }
.image-preview[hidden] { display: none; }
.image-preview img { display: block; width: 100%; max-height: 200px; object-fit: cover; }
#share-link-area { grid-column: 1 / -1; }
.share-link {
display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem;
@ -1182,6 +1125,22 @@ export class FolkThreadBuilder extends HTMLElement {
.photo-menu button + button { border-top: 1px solid var(--rs-bg-hover, #334155); }
.photo-menu button svg { width: 14px; height: 14px; }
.link-card {
display: flex; overflow: hidden; border: 1px solid var(--rs-input-border, #334155);
border-radius: 12px; text-decoration: none; color: inherit; margin-top: 0.5rem;
transition: border-color 0.15s;
}
.link-card:hover { border-color: #6366f1; }
.link-card--loading { opacity: 0.6; }
.link-card__image { width: 120px; min-height: 80px; flex-shrink: 0; overflow: hidden; background: var(--rs-bg-hover, #334155); }
.link-card__image img { width: 100%; height: 100%; object-fit: cover; display: block; }
.link-card__body { padding: 0.5rem 0.75rem; min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 0.15rem; }
.link-card__domain { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); text-transform: lowercase; }
.link-card__title { font-size: 0.82rem; font-weight: 600; color: var(--rs-text-primary, #f1f5f9); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.link-card__desc { font-size: 0.75rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.tc-link { color: #7dd3fc; text-decoration: none; }
.tc-link:hover { text-decoration: underline; }
@media (max-width: 700px) {
.thread-page { grid-template-columns: 1fr; }
.compose { position: static; }
@ -1197,9 +1156,6 @@ export class FolkThreadBuilder extends HTMLElement {
.ro-name { font-weight: 700; color: var(--rs-text-primary, #f1f5f9); font-size: 1.1rem; }
.ro-handle { color: var(--rs-text-muted, #64748b); font-size: 0.9rem; }
.ro-meta { display: flex; align-items: center; gap: 0.5rem; color: var(--rs-text-muted, #64748b); font-size: 0.85rem; }
.ro-title { font-size: 1.4rem; color: var(--rs-text-primary, #f1f5f9); margin: 0 0 1.5rem; line-height: 1.3; }
.ro-image { margin-bottom: 1.5rem; border-radius: 12px; overflow: hidden; border: 1px solid var(--rs-input-border, #334155); }
.ro-image img { display: block; width: 100%; height: auto; }
.ro-cards { margin-bottom: 1.5rem; }
.ro-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rs-input-border, #334155); }
.ro-cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }

View File

@ -159,6 +159,72 @@ app.get("/data/files/generated/:filename", async (c) => {
return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
});
// ── Link preview / unfurl API ──
const linkPreviewCache = new Map<string, { title: string; description: string; image: string | null; domain: string; fetchedAt: number }>();
app.get("/api/link-preview", async (c) => {
const url = c.req.query("url");
if (!url) return c.json({ error: "Missing url parameter" }, 400);
try { new URL(url); } catch { return c.json({ error: "Invalid URL" }, 400); }
// Check cache (1 hour TTL)
const cached = linkPreviewCache.get(url);
if (cached && Date.now() - cached.fetchedAt < 3600_000) {
return c.json(cached, 200, { "Cache-Control": "public, max-age=3600" });
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; rSpace/1.0; +https://rspace.online)", "Accept": "text/html" },
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timeout);
if (!res.ok) return c.json({ error: "Fetch failed" }, 502);
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("text/html")) {
const domain = new URL(url).hostname.replace(/^www\./, "");
const result = { title: url, description: "", image: null, domain, fetchedAt: Date.now() };
linkPreviewCache.set(url, result);
return c.json(result);
}
const html = await res.text();
const getMetaContent = (nameOrProp: string): string => {
const re = new RegExp(`<meta[^>]*(?:name|property)=["']${nameOrProp}["'][^>]*content=["']([^"']*?)["']`, "i");
const re2 = new RegExp(`<meta[^>]*content=["']([^"']*?)["'][^>]*(?:name|property)=["']${nameOrProp}["']`, "i");
return re.exec(html)?.[1] || re2.exec(html)?.[1] || "";
};
const ogTitle = getMetaContent("og:title") || getMetaContent("twitter:title");
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const title = ogTitle || titleMatch?.[1]?.trim() || url;
const description = getMetaContent("og:description") || getMetaContent("twitter:description") || getMetaContent("description");
let image = getMetaContent("og:image") || getMetaContent("twitter:image") || null;
if (image && !image.startsWith("http")) {
try { image = new URL(image, url).href; } catch { image = null; }
}
const domain = new URL(url).hostname.replace(/^www\./, "");
const result = { title, description, image, domain, fetchedAt: Date.now() };
linkPreviewCache.set(url, result);
// Cap cache size
if (linkPreviewCache.size > 500) {
const oldest = [...linkPreviewCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
for (let i = 0; i < 100; i++) linkPreviewCache.delete(oldest[i][0]);
}
return c.json(result, 200, { "Cache-Control": "public, max-age=3600" });
} catch {
return c.json({ error: "Failed to fetch URL" }, 502);
}
});
// ── Space registry API ──
app.route("/api/spaces", spaces);