feat: replace Embed.tsx with AttachmentRenderer + EmbedRenderer in Message

Switches Message.tsx from client-side URL metadata fetching (old Embed.tsx)
to server-driven embed objects via message.embeds[]. Delegates attachment
rendering to AttachmentRenderer. Removes firstUrl extraction and urlRegex.
This commit is contained in:
Jannis Braun
2026-03-20 23:47:16 +01:00
parent 5a670bc6ad
commit 95762a1974
3 changed files with 14 additions and 131 deletions
@@ -1,81 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useAuthStore } from '../../stores/authStore';
interface EmbedProps {
url: string;
}
interface Metadata {
title?: string;
description?: string;
image?: string;
siteName?: string;
}
export function Embed({ url }: EmbedProps) {
const [metadata, setMetadata] = useState<Metadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
const token = useAuthStore.getState().token;
fetch(`/api/utils/metadata?url=${encodeURIComponent(url)}`, {
headers: {
'Authorization': `Bearer ${token}`
},
signal: controller.signal,
})
.then(res => res.json())
.then(data => {
if (data.title) {
setMetadata(data);
}
setIsLoading(false);
})
.catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return;
setIsLoading(false);
});
return () => { controller.abort(); };
}, [url]);
if (isLoading || !metadata) return null;
return (
<div className="mt-2 max-w-[520px] bg-surface-channel rounded-[4px] border-l-4 border-border-hard flex overflow-hidden">
<div className="flex-1 p-3 min-w-0">
{metadata.siteName && (
<div className="text-[12px] text-txt-message font-medium mb-1 truncate">
{metadata.siteName}
</div>
)}
{metadata.title && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-[16px] text-txt-link font-semibold hover:underline block mb-2"
>
{metadata.title}
</a>
)}
{metadata.description && (
<div className="text-[14px] text-txt-message leading-[1.125rem]">
{metadata.description}
</div>
)}
</div>
{metadata.image && (
<div className="w-[80px] h-[80px] m-3 flex-shrink-0">
<img
src={metadata.image}
alt=""
className="w-full h-full object-cover rounded-[4px]"
/>
</div>
)}
</div>
);
}
+12 -48
View File
@@ -10,7 +10,8 @@ import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { Embed } from './Embed';
import { AttachmentRenderer } from './AttachmentRenderer';
import { EmbedRenderer } from './EmbedRenderer';
import { Username } from '../ui/Username';
import { EmojiPicker } from './EmojiPicker';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
@@ -73,7 +74,6 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useSpaceStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const channelKey = message.channelId || (message as any).dmChannelId;
@@ -131,9 +131,6 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const isGifOnly = isGifOnlyMessage(message.content);
const urlRegex = /(https?:\/\/[^\s]+)/g;
const firstUrl = isGifOnly ? null : message.content?.match(urlRegex)?.[0];
// Close reaction picker on outside click
useEffect(() => {
if (!showReactionPicker) return;
@@ -362,55 +359,22 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
)}
{/* Embeds */}
{!isEditing && firstUrl && <Embed url={firstUrl} />}
{!isEditing && message.embeds && message.embeds.length > 0 && (
<div className="flex flex-col gap-2 mt-1">
{message.embeds.map((embed) => (
<EmbedRenderer key={embed.id} embed={embed} />
))}
</div>
)}
</>
)}
{/* Attachments */}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-1 grid gap-2">
{message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
const attUrl = att.filename.startsWith('http') || att.filename.startsWith('/') ? att.filename : `/api/uploads/${att.filename}`;
const thumbUrl = att.thumbnailFilename
? (att.thumbnailFilename.startsWith('http') || att.thumbnailFilename.startsWith('/') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`)
: null;
if (isImage) {
return (
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-white/[0.06]">
<img
src={thumbUrl ?? attUrl}
alt={att.originalName}
className="max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all"
onClick={() => openImagePreview(attUrl)}
loading="lazy"
/>
</div>
);
}
return (
<a
key={att.id}
href={attUrl}
download={att.originalName}
className="flex items-center gap-3 p-4 bg-surface-channel/50 rounded-lg border border-border-hard hover:bg-interactive-hover transition-all max-w-[400px] mt-1 group/att"
>
<div className="p-2 bg-surface-base rounded text-txt-tertiary group-hover/att:text-txt-primary transition-colors">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</div>
<div className="min-w-0">
<p className="text-txt-link text-[15px] font-medium truncate hover:underline">{att.originalName}</p>
<p className="text-[12px] text-txt-tertiary font-medium">
{att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB`}
</p>
</div>
</a>
);
})}
{message.attachments.map((att) => (
<AttachmentRenderer key={att.id} attachment={att} />
))}
</div>
)}
@@ -134,7 +134,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
if (el) {
el.scrollIntoView({ block: 'start' });
const dist = container.scrollHeight - container.scrollTop - container.clientHeight;
const near = dist < 5000;
const near = dist < 150;
setIsNearBottom(near);
isNearBottomRef.current = near;
return;
@@ -218,7 +218,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
// Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const nearBottom = distanceFromBottom < 5000;
const nearBottom = distanceFromBottom < 150;
setIsNearBottom(nearBottom);
isNearBottomRef.current = nearBottom;