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:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,8 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { Embed } from './Embed';
|
import { AttachmentRenderer } from './AttachmentRenderer';
|
||||||
|
import { EmbedRenderer } from './EmbedRenderer';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
import { EmojiPicker } from './EmojiPicker';
|
import { EmojiPicker } from './EmojiPicker';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
@@ -73,7 +74,6 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
const editMessage = useChatStore((s) => s.editMessage);
|
const editMessage = useChatStore((s) => s.editMessage);
|
||||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
const channelKey = message.channelId || (message as any).dmChannelId;
|
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 isGifOnly = isGifOnlyMessage(message.content);
|
||||||
|
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
|
||||||
const firstUrl = isGifOnly ? null : message.content?.match(urlRegex)?.[0];
|
|
||||||
|
|
||||||
// Close reaction picker on outside click
|
// Close reaction picker on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showReactionPicker) return;
|
if (!showReactionPicker) return;
|
||||||
@@ -362,55 +359,22 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Embeds */}
|
{/* 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 */}
|
{/* Attachments */}
|
||||||
{message.attachments && message.attachments.length > 0 && (
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
<div className="mt-1 grid gap-2">
|
<div className="mt-1 grid gap-2">
|
||||||
{message.attachments.map((att) => {
|
{message.attachments.map((att) => (
|
||||||
const isImage = att.mimetype.startsWith('image/');
|
<AttachmentRenderer key={att.id} attachment={att} />
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ block: 'start' });
|
el.scrollIntoView({ block: 'start' });
|
||||||
const dist = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const dist = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
const near = dist < 5000;
|
const near = dist < 150;
|
||||||
setIsNearBottom(near);
|
setIsNearBottom(near);
|
||||||
isNearBottomRef.current = near;
|
isNearBottomRef.current = near;
|
||||||
return;
|
return;
|
||||||
@@ -218,7 +218,7 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
|||||||
|
|
||||||
// Check if near bottom
|
// Check if near bottom
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
const nearBottom = distanceFromBottom < 5000;
|
const nearBottom = distanceFromBottom < 150;
|
||||||
setIsNearBottom(nearBottom);
|
setIsNearBottom(nearBottom);
|
||||||
isNearBottomRef.current = nearBottom;
|
isNearBottomRef.current = nearBottom;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user