feat: MessageBody component with markdown, links, @mentions, #channels
Replace plain-text message rendering with a rich MessageBody component
that supports bold, italic, inline code, fenced code blocks, lists,
headers, auto-linked URLs, @mention pills (linking to /dm/{name}), and
#channel pills (linking to /channels/{name}). Input is HTML-sanitized
before processing to prevent XSS. Updated all 5 rendering locations:
channels, DMs, conversations, MessageList, and ThreadPanel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
96d9fbd246
commit
640838d5a6
@@ -0,0 +1,262 @@
|
||||
<script lang="ts">
|
||||
let { body, truncate }: { body: string; truncate?: number } = $props();
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer with @mentions, #channels, and URL auto-linking.
|
||||
* No external dependencies. Input is sanitized (HTML stripped) before processing.
|
||||
*/
|
||||
|
||||
// Strip all HTML tags from raw input to prevent XSS
|
||||
function stripHtml(text: string): string {
|
||||
return text.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
// Escape HTML special characters in text content
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Process inline markdown: bold, italic, code, URLs, @mentions, #channels
|
||||
function processInline(text: string): string {
|
||||
// Inline code first (so content inside is not further processed)
|
||||
let result = '';
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = codeRegex.exec(text)) !== null) {
|
||||
result += processInlineNonCode(text.slice(lastIndex, match.index));
|
||||
result += `<code class="msg-inline-code">${escapeHtml(match[1])}</code>`;
|
||||
lastIndex = codeRegex.lastIndex;
|
||||
}
|
||||
result += processInlineNonCode(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Process inline elements that are NOT inside code spans
|
||||
function processInlineNonCode(text: string): string {
|
||||
let s = escapeHtml(text);
|
||||
|
||||
// URLs - must come before bold/italic to avoid mangling URLs with underscores
|
||||
s = s.replace(
|
||||
/https?:\/\/[^\s<>&]+/g,
|
||||
(url) => {
|
||||
// Decode escaped HTML entities back for the href
|
||||
const href = url.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
return `<a href="${href}" target="_blank" rel="noopener" class="msg-link">${url}</a>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Bold: **text**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic: *text* (but not inside **)
|
||||
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||||
|
||||
// @mentions
|
||||
s = s.replace(
|
||||
/@([\w][\w.-]*)/g,
|
||||
'<a href="/dm/$1" class="msg-mention">@$1</a>'
|
||||
);
|
||||
|
||||
// #channels
|
||||
s = s.replace(
|
||||
/#([\w][\w.-]*)/g,
|
||||
'<a href="/channels/$1" class="msg-channel">$&</a>'
|
||||
);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// Parse a full message body into HTML
|
||||
function renderMarkdown(raw: string): string {
|
||||
const sanitized = stripHtml(raw);
|
||||
|
||||
// Handle truncation on the sanitized text
|
||||
let text = sanitized;
|
||||
if (truncate && text.length > truncate) {
|
||||
text = text.slice(0, truncate) + '...';
|
||||
}
|
||||
|
||||
// Fenced code blocks: ```...```
|
||||
const blocks: string[] = [];
|
||||
const codeBlockRegex = /```(?:\w*)\n?([\s\S]*?)```/g;
|
||||
let processed = text.replace(codeBlockRegex, (_match, code) => {
|
||||
const idx = blocks.length;
|
||||
blocks.push(`<pre class="msg-code-block"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
|
||||
return `\x00BLOCK${idx}\x00`;
|
||||
});
|
||||
|
||||
// Split into paragraphs by blank lines
|
||||
const paragraphs = processed.split(/\n{2,}/);
|
||||
const htmlParts: string[] = [];
|
||||
|
||||
for (const para of paragraphs) {
|
||||
// Check for block placeholder
|
||||
if (/^\x00BLOCK\d+\x00$/.test(para.trim())) {
|
||||
const idx = parseInt(para.trim().replace(/\x00BLOCK(\d+)\x00/, '$1'));
|
||||
htmlParts.push(blocks[idx]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = para.split('\n');
|
||||
|
||||
// Check if this paragraph is a list
|
||||
const isUnordered = lines.every(l => /^\s*[-*]\s/.test(l) || l.trim() === '');
|
||||
const isOrdered = lines.every(l => /^\s*\d+\.\s/.test(l) || l.trim() === '');
|
||||
|
||||
if (isUnordered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*[-*]\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*[-*]\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ul class="msg-list">${items}</ul>`);
|
||||
} else if (isOrdered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*\d+\.\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ol class="msg-list msg-list-ordered">${items}</ol>`);
|
||||
} else {
|
||||
// Check for headers
|
||||
const headerMatch = para.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
htmlParts.push(`<h${level} class="msg-heading msg-h${level}">${processInline(headerMatch[2])}</h${level}>`);
|
||||
} else {
|
||||
// Regular paragraph - preserve line breaks within
|
||||
const inlineHtml = lines
|
||||
.map(l => {
|
||||
// Replace block placeholders inline
|
||||
if (/\x00BLOCK\d+\x00/.test(l)) {
|
||||
return l.replace(/\x00BLOCK(\d+)\x00/g, (_, idx) => blocks[parseInt(idx)]);
|
||||
}
|
||||
return processInline(l);
|
||||
})
|
||||
.join('<br>');
|
||||
htmlParts.push(`<p>${inlineHtml}</p>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return htmlParts.join('');
|
||||
}
|
||||
|
||||
let rendered = $derived(renderMarkdown(body));
|
||||
</script>
|
||||
|
||||
<div class="msg-body">
|
||||
{@html rendered}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.msg-body {
|
||||
/* Inherit parent text styling */
|
||||
}
|
||||
.msg-body :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
.msg-body :global(p + p) {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.msg-body :global(strong) {
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg-body :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
.msg-body :global(.msg-inline-code) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.msg-body :global(.msg-code-block) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8em;
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
white-space: pre;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.msg-body :global(.msg-code-block code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg-body :global(.msg-link) {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-link:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.msg-body :global(.msg-mention) {
|
||||
display: inline;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(124, 58, 237, 0.15);
|
||||
color: var(--accent-purple);
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-mention:hover) {
|
||||
background-color: rgba(124, 58, 237, 0.25);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-channel) {
|
||||
display: inline;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(54, 197, 240, 0.15);
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-channel:hover) {
|
||||
background-color: rgba(54, 197, 240, 0.25);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-list) {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
.msg-body :global(.msg-list-ordered) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.msg-body :global(.msg-list li) {
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
.msg-body :global(.msg-heading) {
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
margin: 0.4em 0 0.2em;
|
||||
}
|
||||
.msg-body :global(.msg-h1) {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.msg-body :global(.msg-h2) {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.msg-body :global(.msg-h3) {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.msg-body :global(.msg-h4),
|
||||
.msg-body :global(.msg-h5),
|
||||
.msg-body :global(.msg-h6) {
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { openThread } from '$lib/stores/thread';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
@@ -105,7 +106,7 @@
|
||||
<span class="badge bg-accent-yellow/20 text-accent-yellow">P{msg.priority}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}</p>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={300} /></div>
|
||||
|
||||
<!-- Thread link -->
|
||||
{#if showConversationLink && msg.conversation_id}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { activeThread, closeThread } from '$lib/stores/thread';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let conversation = $state<any>(null);
|
||||
let threadMessages = $state<any[]>([]);
|
||||
@@ -162,7 +163,7 @@
|
||||
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</p>
|
||||
<div class="text-xs text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let channel = $state<any>(null);
|
||||
let members = $state<any[]>([]);
|
||||
@@ -268,7 +269,7 @@
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let conversation = $state<any>(null);
|
||||
let messagesList = $state<any[]>([]);
|
||||
@@ -151,7 +152,7 @@
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary ml-auto">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<div class="text-sm text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</div>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let peerAgent = $derived($page.params.name);
|
||||
let peer = $state<any>(null);
|
||||
@@ -244,7 +245,7 @@
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded {msg.status === 'pending' ? 'bg-accent-yellow/20 text-accent-yellow' : msg.status === 'processing' ? 'bg-accent-blue/20 text-accent-blue' : 'bg-bg-tertiary text-text-secondary'}">{msg.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
|
||||
Reference in New Issue
Block a user