Private
Public Access
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user