Files
client/plans/msg-markdown-support.md
2026-04-17 16:52:51 -04:00

9.6 KiB

Markdown Support in msg.html

Current State

How messages render today

In msg.html, the renderThread() function renders message content using textContent:

const content = document.createElement('div');
content.textContent = msg.content || '';

This means all message text is displayed as plain text — no links, no bold, no formatting of any kind. Even URLs are not clickable.

Existing assets in the project

  1. marked.min.js — marked v9.0.3 is already bundled in the project. It is used by note.html for rendering long-form note content as markdown into an iframe.
  2. htmlFormatText() — A simple utility that converts URLs to <a> links and image URLs to <img> tags. Used by post-interactions.mjs for social feed posts.

Industry Standard for Messaging Formatting

What major messaging platforms do

Platform Formatting Approach
Slack Custom markdown subset: *bold*, _italic_, ~strikethrough~, `code`, ```code blocks```, > blockquote, links, emoji shortcodes
Discord Full markdown subset: **bold**, *italic*, ~~strikethrough~~, `code`, ```lang code blocks```, > blockquote, || spoiler ||, links
Telegram Markdown + HTML: **bold**, __italic__, `code`, ```code blocks```, ~~strikethrough~~, || spoiler ||, links
WhatsApp Limited markdown: *bold*, _italic_, ~strikethrough~, `code`
Signal Limited markdown: *bold*, _italic_, ~~strikethrough~~, `
Matrix/Element Full markdown rendering with HTML sanitization
iMessage No markdown; rich text via attributed strings

Nostr ecosystem conventions

  • NIP-01 kind 1 notes and kind 14 DMs use plain text content — no official markdown NIP exists
  • In practice, most Nostr clients render:
    • URLs as clickable links
    • Image URLs as inline images
    • nostr: URIs as profile/event links
    • Some clients like Amethyst, Damus, and Coracle render basic markdown in notes
  • NIP-23 long-form content (kind 30023) explicitly uses markdown

The industry consensus for chat/messaging is a sanitized markdown subset — not full markdown. The key principles:

  1. Render inline formatting: bold, italic, strikethrough, inline code
  2. Render code blocks: fenced code blocks with syntax highlighting optional
  3. Auto-link URLs: make URLs clickable
  4. Render image URLs: show inline image previews
  5. Blockquotes: useful for quoting previous messages
  6. Sanitize HTML: prevent XSS — never allow raw HTML tags
  7. Do NOT render: headings, tables, horizontal rules, or complex block elements that don't make sense in chat bubbles

Implementation Plan

Architecture

flowchart TD
    A[Raw message content] --> B{Contains markdown?}
    B -->|Any content| C[DOMPurify sanitize raw text]
    C --> D[marked.parse with chat-safe options]
    D --> E[DOMPurify sanitize HTML output]
    E --> F[Set innerHTML on bubble]
    
    G[Reply input box] --> H[Get plaintext from contenteditable]
    H --> I[Send as plain text over NIP-04/NIP-44/NIP-17]
    
    style A fill:#1a1a2e,stroke:#e94560,color:#eee
    style F fill:#1a1a2e,stroke:#16c79a,color:#eee
    style I fill:#1a1a2e,stroke:#16c79a,color:#eee

Steps

1. Add marked.min.js script tag to msg.html

Include the existing marked.min.js in the <head> of msg.html, just like note.html does:

<script src="./js/marked.min.js"></script>

2. Add DOMPurify for XSS protection

Since messages come from untrusted third parties, HTML sanitization is critical. Add DOMPurify via CDN:

<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>

Alternatively, download and bundle it locally like marked.min.js.

3. Configure marked for chat-safe rendering

Create a chat-specific marked configuration that disables features inappropriate for chat bubbles:

function configureMarkedForChat() {
    marked.setOptions({
        breaks: true,       // Convert \n to <br> - essential for chat
        gfm: true,          // GitHub Flavored Markdown - strikethrough, tables
        pedantic: false,
        silent: true         // Don't throw on bad markdown
    });

    // Custom renderer to limit what gets rendered
    const renderer = new marked.Renderer();
    
    // Disable headings in chat - render as bold text instead
    renderer.heading = function(text, level) {
        return `<strong>${text}</strong><br>`;
    };
    
    // Disable horizontal rules
    renderer.hr = function() {
        return '<br>';
    };
    
    // Make links open in new tab
    renderer.link = function(href, title, text) {
        const titleAttr = title ? ` title="${title}"` : '';
        return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
    };
    
    // Render images inline with max-width constraint
    renderer.image = function(href, title, text) {
        const titleAttr = title ? ` title="${title}"` : '';
        return `<img src="${href}" alt="${text}"${titleAttr} style="max-width:100%;border-radius:6px;">`;
    };

    marked.use({ renderer });
}

4. Create a renderMessageContent helper function

Add a function that safely converts message text to formatted HTML:

function renderMessageContent(text) {
    if (!text) return '';
    
    // Parse markdown to HTML
    let html = '';
    try {
        html = marked.parse(text);
    } catch (e) {
        // Fallback to escaped plain text
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }
    
    // Sanitize to prevent XSS
    if (window.DOMPurify) {
        html = DOMPurify.sanitize(html, {
            ALLOWED_TAGS: [
                'b', 'i', 'em', 'strong', 'a', 'code', 'pre',
                'br', 'p', 'ul', 'ol', 'li', 'blockquote',
                'del', 's', 'img', 'span'
            ],
            ALLOWED_ATTR: [
                'href', 'target', 'rel', 'title', 'alt', 'src', 'style'
            ],
            ALLOW_DATA_ATTR: false
        });
    }
    
    return html;
}

5. Update renderThread to use innerHTML with sanitized markdown

Change the content rendering in renderThread() from textContent to innerHTML:

Before (line 779):

content.textContent = msg.content || '';

After:

content.innerHTML = renderMessageContent(msg.content || '');

6. Add CSS styles for markdown inside chat bubbles

Add styles scoped to .msgBubble so markdown elements look appropriate in chat context:

.msgBubble p {
    margin: 0 0 0.3em 0;
}
.msgBubble p:last-child {
    margin-bottom: 0;
}
.msgBubble code {
    background: rgba(128,128,128,0.2);
    padding: 1px 4px;
    border-radius: 3px;
    font-size: 85%;
}
.msgBubble pre {
    background: rgba(0,0,0,0.15);
    padding: 6px 8px;
    border-radius: 5px;
    overflow-x: auto;
    margin: 4px 0;
}
.msgBubble pre code {
    background: none;
    padding: 0;
}
.msgBubble blockquote {
    border-left: 3px solid var(--muted-color);
    margin: 4px 0;
    padding: 2px 8px;
    color: var(--muted-color);
}
.msgBubble a {
    color: var(--accent-color);
    text-decoration: underline;
}
.msgBubble img {
    max-width: 100%;
    border-radius: 6px;
    margin: 4px 0;
}
.msgBubble ul, .msgBubble ol {
    margin: 4px 0;
    padding-left: 20px;
}
.msgBubble del, .msgBubble s {
    text-decoration: line-through;
    opacity: 0.7;
}

7. Adjust white-space on msgBubble

The current msgBubble CSS has white-space: pre-wrap which will conflict with markdown-rendered HTML. Change it:

Before:

.msgBubble {
    white-space: pre-wrap;
    ...
}

After:

.msgBubble {
    white-space: normal;
    word-wrap: break-word;
    overflow-wrap: break-word;
    ...
}

The breaks: true option in marked handles newline conversion to <br>, so pre-wrap is no longer needed.


Security Considerations

  • DOMPurify is essential — messages come from untrusted Nostr users. Without sanitization, a malicious user could inject <script> tags or event handlers via crafted markdown.
  • The allowlist approach in DOMPurify config above is restrictive by design — only safe formatting tags are permitted.
  • target="_blank" links must include rel="noopener noreferrer" to prevent tab-nabbing.
  • Image rendering from arbitrary URLs has privacy implications — loading an image reveals the user's IP to the image host. Consider adding a click-to-load option for images in a future iteration.

Summary of Files to Modify

File Change
www/msg.html Add marked.min.js + DOMPurify script tags, add configureMarkedForChat() + renderMessageContent() functions, update renderThread() to use innerHTML, add markdown CSS styles, adjust white-space on .msgBubble

Optional Future Enhancements

  • Markdown toolbar in the reply input box with buttons for bold, italic, code, link
  • Markdown preview toggle in the reply box showing formatted output before sending
  • Syntax highlighting for code blocks using highlight.js or Prism
  • Click-to-load images for privacy protection
  • nostr: URI handling to render nostr:npub... and nostr:note... as clickable profile/event links
  • Emoji shortcode support