Add URL previews for chat messages (#43)

Slack/Discord-style link unfurling: the first http(s) URL in a message's
content gets a small preview card (title/description/image/site name)
fetched from the page's Open Graph tags.

Backend:
- Message.preview_url (extracted at create/edit time, cheap regex, no
  I/O) points at a link_previews cache row keyed by URL -- the same URL
  posted in different messages/rooms fetches once, and a failed fetch is
  cached too so a dead URL isn't retried on every reference.
- The actual fetch runs in a background asyncio.create_task from
  broadcast_new_message/broadcast_message_update, on its own DB session,
  so a slow third-party site never delays message delivery. A separate
  "link_preview" WS envelope carries the result once it resolves.
- SSRF protection reuses app/services/ssrf.py's validate_target_url
  (renamed from UnsafeWebhookUrlError to UnsafeUrlError now that it's
  shared with webhooks), but re-validates before every hop of a redirect
  chain rather than once up front -- redirects are followed manually so
  each intermediate URL is checked before it's ever connected to.
- Parsed with stdlib html.parser -- no new dependency.

Frontend: a LinkPreviewCard rendered under message content when present,
patched into state live via the new WS envelope and included in message
history for reloads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:50:55 -06:00
co-authored by Claude Sonnet 5
parent 752da74c5a
commit 760d2cf5dd
18 changed files with 835 additions and 21 deletions
+31 -2
View File
@@ -122,16 +122,45 @@ export function ChatPane({
setLive((prev) => [...prev, envelope])
markRead()
} else if (envelope.type === 'message_update' && envelope.room_id === room.id) {
// link_preview only survives the edit if the URL it came from is
// still there -- an edit that changed or removed it clears the
// stale preview instead of leaving the old one showing. A new one
// (if the new URL has any) arrives via its own 'link_preview'
// envelope shortly after, same as a fresh send.
setHistory((prev) =>
prev.map((m) =>
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
m.id === envelope.id
? {
...m,
content: envelope.content,
edited_at: envelope.edited_at,
link_preview: m.link_preview?.url === envelope.preview_url ? m.link_preview : null,
}
: m,
),
)
setLive((prev) =>
prev.map((m) =>
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
m.id === envelope.id
? {
...m,
content: envelope.content,
edited_at: envelope.edited_at,
link_preview: m.link_preview?.url === envelope.preview_url ? m.link_preview : null,
}
: m,
),
)
} else if (envelope.type === 'link_preview' && envelope.room_id === room.id) {
const linkPreview = {
url: envelope.url,
title: envelope.title,
description: envelope.description,
image_url: envelope.image_url,
site_name: envelope.site_name,
}
setHistory((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, link_preview: linkPreview } : m)))
setLive((prev) => prev.map((m) => (m.id === envelope.id ? { ...m, link_preview: linkPreview } : m)))
} else if (envelope.type === 'reaction_update' && envelope.room_id === room.id) {
setHistory((prev) =>
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
@@ -0,0 +1,65 @@
.link-preview-card {
display: flex;
gap: 10px;
align-items: stretch;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-left: 3px solid var(--ds-accent);
border-radius: var(--radius);
padding: 10px 12px;
margin-bottom: 4px;
max-width: min(420px, 100%);
text-decoration: none;
color: var(--ds-text);
}
.link-preview-card:hover {
border-color: var(--ds-accent);
border-left-color: var(--ds-accent);
}
.link-preview-image {
flex: none;
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 6px;
background: var(--ds-void);
}
.link-preview-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
justify-content: center;
}
.link-preview-site {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--ds-muted);
}
.link-preview-title {
font-size: 0.86rem;
font-weight: 700;
color: var(--ds-accent-2, var(--ds-accent));
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.link-preview-description {
font-size: 0.8rem;
color: var(--ds-muted);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -0,0 +1,30 @@
import type { LinkPreviewInfo } from '../types'
import './LinkPreviewCard.css'
interface LinkPreviewCardProps {
preview: LinkPreviewInfo
}
// Slack/Discord-style unfurl card, rendered under a message's text when the
// backend found a URL in it and successfully fetched Open Graph data for it
// (see link_preview_service.py -- title/description/image_url/site_name are
// all independently optional, since not every page sets every og: tag).
export function LinkPreviewCard({ preview }: LinkPreviewCardProps) {
return (
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="link-preview-card"
>
{preview.image_url && (
<img src={preview.image_url} alt="" className="link-preview-image" loading="lazy" />
)}
<div className="link-preview-body">
{preview.site_name && <span className="link-preview-site">{preview.site_name}</span>}
{preview.title && <span className="link-preview-title">{preview.title}</span>}
{preview.description && <span className="link-preview-description">{preview.description}</span>}
</div>
</a>
)
}
+2
View File
@@ -7,6 +7,7 @@ import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard'
import { MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
import './MessageList.css'
@@ -171,6 +172,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
{msg.edited_at && <span className="message-edited"> (edited)</span>}
</div>
)}
{msg.link_preview && <LinkPreviewCard preview={msg.link_preview} />}
{msg.reactions.length > 0 && (
<div className="message-reaction-pills">
{msg.reactions.map((r) => {
+26
View File
@@ -108,6 +108,14 @@ export interface MessageFileInfo {
content_type: string
}
export interface LinkPreviewInfo {
url: string
title: string | null
description: string | null
image_url: string | null
site_name: string | null
}
export interface Message {
id: string
room_id: string
@@ -116,6 +124,7 @@ export interface Message {
content: string | null
image_id: string | null
file: MessageFileInfo | null
link_preview: LinkPreviewInfo | null
reactions: ReactionSummary[]
created_at: string
edited_at: string | null
@@ -130,6 +139,7 @@ export interface ChatMessageEnvelope {
content: string | null
image_id: string | null
file: MessageFileInfo | null
link_preview: LinkPreviewInfo | null
reactions: ReactionSummary[]
created_at: string
edited_at: string | null
@@ -141,6 +151,21 @@ export interface ChatMessageUpdateEnvelope {
room_id: string
content: string
edited_at: string | null
// Lets the frontend clear a stale preview when an edit changes/removes
// the URL it came from -- compare against whatever link_preview.url the
// message currently has rather than assuming it's still valid.
preview_url: string | null
}
export interface ChatLinkPreviewEnvelope {
type: 'link_preview'
id: string
room_id: string
url: string
title: string | null
description: string | null
image_url: string | null
site_name: string | null
}
export interface ChatReactionUpdateEnvelope {
@@ -181,6 +206,7 @@ export type ServerEnvelope =
| ChatMessageEnvelope
| ChatMessageUpdateEnvelope
| ChatReactionUpdateEnvelope
| ChatLinkPreviewEnvelope
| ChatJoinedEnvelope
| ChatErrorEnvelope
| ChatRoomAddedEnvelope