feat(messages): enhance message rendering with metadata parsing and inline image support

This commit is contained in:
asepharyana
2026-08-26 17:43:01 +07:00
parent 3b8fe1b1c3
commit e81391484b
2 changed files with 151 additions and 25 deletions
@@ -491,26 +491,21 @@ export class MessagesRepository {
*/ */
async getRecentEdits(limit = 50, channelId?: string) { async getRecentEdits(limit = 50, channelId?: string) {
const db = getDatabase(); const db = getDatabase();
const where = channelId const result = await db.execute(sql`
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'` SELECT
: ""; e.id,
const result = await db.execute( e.message_id,
sql.raw(` e.old_content,
SELECT e.edited_at,
e.id, m.channel_id,
e.message_id, COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
e.old_content, m.username
e.edited_at, FROM message_edits e
m.channel_id, JOIN messages m ON m.id = e.message_id
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name, ${channelId ? sql`WHERE m.channel_id = ${channelId}` : sql``}
m.username ORDER BY e.edited_at DESC
FROM message_edits e LIMIT ${limit}
JOIN messages m ON m.id = e.message_id `);
${where}
ORDER BY e.edited_at DESC
LIMIT ${limit}
`),
);
const rows = (result.rows as Record<string, unknown>[]) || []; const rows = (result.rows as Record<string, unknown>[]) || [];
return rows.map((r) => ({ return rows.map((r) => ({
id: String(r.id), id: String(r.id),
@@ -57,6 +57,7 @@ import type {
AiStatus, AiStatus,
EditHistoryRow, EditHistoryRow,
Guild, Guild,
MessageMetadata,
MessageRecord, MessageRecord,
} from "@/lib/types"; } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
@@ -622,6 +623,22 @@ function MessageDetail({
); );
} }
/** Parse metadata JSON safely — returns null on malformed / missing data. */
function parseMeta(raw: string | null | undefined): MessageMetadata | null {
if (!raw) return null;
try {
return JSON.parse(raw) as MessageMetadata;
} catch {
return null;
}
}
/** True when an attachment content-type looks like an image we can inline. */
function isImageType(ct?: string | null): boolean {
if (!ct) return false;
return ct.startsWith("image/");
}
/** Single message card used by both the live feed and the date-grouped timeline. */ /** Single message card used by both the live feed and the date-grouped timeline. */
function MessageRow({ function MessageRow({
m, m,
@@ -632,6 +649,24 @@ function MessageRow({
selected: string | null; selected: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { }) {
const meta = parseMeta(m.metadata);
const attachments = meta?.attachments ?? [];
const embeds = meta?.embeds ?? [];
const stickers = meta?.stickers ?? [];
const imageAttachments = attachments.filter((a) =>
isImageType(a.contentType),
);
const fileAttachments = attachments.filter(
(a) => !isImageType(a.contentType),
);
// First embed image or sticker url for visual preview
const embedImage =
embeds.find((e) => e.image?.url)?.image?.url ??
embeds.find((e) => e.thumbnail?.url)?.thumbnail?.url ??
null;
const stickerUrl = stickers.find((s) => s.url)?.url ?? null;
return ( return (
<button <button
key={m.id} key={m.id}
@@ -656,11 +691,107 @@ function MessageRow({
{formatRelativeTime(m.created_at)} {formatRelativeTime(m.created_at)}
</span> </span>
</div> </div>
<div className="mt-0.5 line-clamp-2 text-xs text-ink-soft">
{renderMessageContent(m.content, m.metadata) || ( {/* Text content */}
<span className="italic text-ink-muted">(empty / embed)</span> {m.content && (
)} <div className="mt-0.5 line-clamp-2 text-xs text-ink-soft">
</div> {renderMessageContent(m.content, m.metadata)}
</div>
)}
{/* Inline image attachments */}
{imageAttachments.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{imageAttachments.map((a) => (
<a
key={a.url}
href={a.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={a.url}
alt={a.name}
loading="lazy"
className="max-h-32 w-auto max-w-[200px] object-cover"
/>
</a>
))}
</div>
)}
{/* Sticker image */}
{stickerUrl && imageAttachments.length === 0 && (
<div className="mt-1.5">
<a
href={stickerUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={stickerUrl}
alt={stickers[0]?.name ?? "sticker"}
loading="lazy"
className="max-h-28 w-auto max-w-[160px] object-contain"
/>
</a>
</div>
)}
{/* Embed image / thumbnail */}
{embedImage && imageAttachments.length === 0 && !stickerUrl && (
<div className="mt-1.5">
<a
href={embedImage}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={embedImage}
alt="embed"
loading="lazy"
className="max-h-32 w-auto max-w-[240px] object-cover"
/>
</a>
{embeds[0]?.title && (
<div className="mt-0.5 truncate text-[10px] font-medium text-ink-muted">
{embeds[0].title}
</div>
)}
</div>
)}
{/* Non-image file attachments */}
{fileAttachments.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{fileAttachments.map((a) => (
<a
key={a.url}
href={a.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 rounded-[4px] border border-hairline bg-surface px-2 py-0.5 font-mono text-[10px] text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink"
>
<Paperclip className="size-2.5" />
{a.name}
</a>
))}
</div>
)}
{/* No content at all */}
{!m.content && attachments.length === 0 && embeds.length === 0 && (
<div className="mt-0.5 text-xs italic text-ink-muted">
(empty / embed)
</div>
)}
</div> </div>
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} /> <AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
</button> </button>