Files
client/plans/messaging-ui-component.md
2026-04-17 16:52:51 -04:00

10 KiB

Messaging UI Component Plan

Overview

Extract the thread pane from www/msg.html into a reusable mountMessagingWindow() component in www/js/messaging-ui.mjs, with companion styles in www/css/messaging-ui.css. The component follows the same mount-and-return-API pattern as www/js/post-composer.mjs.

Additionally, enhance www/js/post-composer.mjs with two UX improvements:

  1. Instant clear on send — clear text + preview immediately, before the async callback resolves.
  2. Input history — arrow-up/down recalls previous sent messages, like a terminal.

Architecture

graph TD
    A[Host Page] -->|calls| B[mountMessagingWindow hostEl, options]
    B --> C[Thread Header]
    B --> D[Message List - scrollable]
    B --> E[mountComposer - input area]
    E -->|onSubmit| F[Host callback]
    F -->|appendMessage| B
    D -->|scroll top| G[onLoadOlder callback]

Component: mountMessagingWindow(hostEl, options)

Creates the full thread pane UI inside hostEl. Returns a control API.

Options

Option Type Default Description
onSubmit async fn(text) => bool required Called when user sends. Return true = success.
onLoadOlder async fn() => void null Called when user scrolls to top.
renderMessageContent fn(rawText) => html built-in markdown Custom renderer for message body.
composerOptions object {} Extra options forwarded to mountComposer.
scrollThreshold number 100 Pixels from bottom to auto-scroll.
loadOlderThreshold number 50 Pixels from top to trigger load-older.
emptyStateText string "No messages yet" Shown when message list is empty.

Returned API

{
  setHeader({ name, avatarUrl }),     // Update thread header
  setMessages(messages),              // Replace all messages, re-render
  appendMessage(message),             // Add one message, auto-scroll
  prependMessages(messages),          // Prepend older messages, preserve scroll
  scrollToBottom(force),              // Scroll to bottom
  setDisabled(bool),                  // Disable/enable input
  getComposer(),                      // Access the inner post-composer API
  destroy()                           // Tear down all DOM and listeners
}

Message Shape

{
  id: string,
  content: string,
  outgoing: boolean,          // true = sent by user, false = received
  created_at: number,         // unix seconds
  protocol?: string,          // optional label e.g. "kind4-nip04"
  role?: string,              // optional, for AI: "user" | "assistant" | "system"
  attachments?: Array         // optional image attachments
}

DOM Structure Created

hostEl
  div.msg-thread-header
    img.msg-thread-header-avatar
    div.msg-thread-header-name
  div.msg-thread-messages          (scrollable)
    div.msg-bubble-row.outgoing|incoming
      div.msg-bubble.outgoing|incoming
        div.msg-bubble-content     (rendered markdown)
        div.msg-bubble-time
        div.msg-bubble-meta        (optional protocol label)
  div.msg-reply-box
    [mountComposer creates its own DOM here]

Post-Composer Enhancements

1. Instant Clear on Send

Problem: In onSendClick(), the composer waits for await onSubmit(text) before clearing. For msg.html this means the text lingers while encryption + publish completes.

Solution: Add a clearBeforeSubmit option (default true). When enabled:

  1. Capture the text
  2. Immediately clear the composer and hide preview
  3. Call onSubmit(text) asynchronously
  4. If onSubmit throws or returns false, restore the text
// In onSendClick():
const text = (hostEl.innerText || '').trim();
if (!text) return;

if (clearBeforeSubmit) {
  clearComposerContent();           // instant visual feedback
  pushToHistory(text);              // save to history ring
}

try {
  const result = await onSubmit(text);
  if (result === false && clearBeforeSubmit) {
    // Restore on failure
    hostEl.innerText = text;
    queuePreviewRender();
    updateSendButtonVisibility();
  }
} catch (error) {
  if (clearBeforeSubmit) {
    hostEl.innerText = text;
    queuePreviewRender();
    updateSendButtonVisibility();
  }
  console.error('[post-composer] Submit failed:', error);
}

2. Input History (Arrow Up/Down)

Problem: No way to recall previously sent messages.

Solution: Maintain a localStorage-backed ring buffer of sent messages. Arrow-up/down navigates when the cursor is at the start/end of the input.

Storage key: post_composer_history_v1 Max entries: 50 (configurable via historyMaxEntries option)

// New option:
//   enableHistory: true (default true)
//   historyKey: string (default 'post_composer_history_v1')
//   historyMaxEntries: number (default 50)

// State:
let history = loadHistory();    // string[]
let historyIndex = -1;          // -1 = not browsing, 0 = most recent
let historyDraft = '';          // saves current unsent text

// On successful send:
function pushToHistory(text) {
  if (!text.trim()) return;
  // Remove duplicate if exists
  history = history.filter(h => h !== text);
  history.unshift(text);
  if (history.length > historyMaxEntries) history.pop();
  saveHistory();
  historyIndex = -1;
  historyDraft = '';
}

// On ArrowUp in keydown handler:
if (event.key === 'ArrowUp' && enableHistory && !mentionState.visible) {
  const atStart = getCaretCharacterOffsetWithin(hostEl) === 0;
  const singleLine = !(hostEl.innerText || '').includes('\n');
  if (atStart || singleLine) {
    event.preventDefault();
    if (historyIndex === -1) {
      historyDraft = hostEl.innerText || '';
    }
    if (historyIndex < history.length - 1) {
      historyIndex++;
      hostEl.innerText = history[historyIndex];
      // Move caret to end
      setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
      queuePreviewRender();
      updateSendButtonVisibility();
    }
    return;
  }
}

// On ArrowDown:
if (event.key === 'ArrowDown' && enableHistory && !mentionState.visible) {
  const text = hostEl.innerText || '';
  const atEnd = getCaretCharacterOffsetWithin(hostEl) >= text.length;
  const singleLine = !text.includes('\n');
  if ((atEnd || singleLine) && historyIndex >= 0) {
    event.preventDefault();
    historyIndex--;
    if (historyIndex < 0) {
      hostEl.innerText = historyDraft;
    } else {
      hostEl.innerText = history[historyIndex];
    }
    setCaretByCharacterOffset(hostEl, (hostEl.innerText || '').length);
    queuePreviewRender();
    updateSendButtonVisibility();
    return;
  }
}

CSS: www/css/messaging-ui.css

Extract and namespace the thread pane styles from msg.html. Use .msg- prefix for all classes. The styles will be a cleaned-up version of the inline styles currently in msg.html lines 43-471.

Key classes:

  • .msg-thread-pane — the outer container
  • .msg-thread-header — header bar
  • .msg-thread-messages — scrollable message area
  • .msg-bubble-row, .msg-bubble — message bubbles
  • .msg-bubble-content — markdown-rendered content
  • .msg-reply-box — input area wrapper
  • .msg-empty-state — empty state text

Migration Plan

Phase 1: Post-Composer Enhancements

  1. Add clearBeforeSubmit option to mountComposer in www/js/post-composer.mjs
  2. Add input history ring buffer to mountComposer
  3. Update www/css/post-composer.css if needed for any new elements
  4. Test in msg.html — send should now clear instantly, arrow-up should recall

Phase 2: Create Messaging UI Component

  1. Create www/css/messaging-ui.css with extracted/namespaced styles
  2. Create www/js/messaging-ui.mjs with mountMessagingWindow()
  3. The component internally uses mountComposer for the input area

Phase 3: Refactor msg.html

  1. Replace inline <style> block with <link> to messaging-ui.css
  2. Replace manual DOM construction with mountMessagingWindow() call
  3. Keep msg.html-specific logic (Nostr DM encryption, conversation list, send modes) in the page script
  4. The page provides onSubmit and onLoadOlder callbacks to the component

Phase 4: Integrate into ai.html

  1. Add <link> to messaging-ui.css in ai.html
  2. Replace the divAiChatPane message area with mountMessagingWindow()
  3. Keep AI-specific logic (provider selection, streaming, conversation persistence) in the page script
  4. Map AI message roles to the component message shape

Phase 5: Future Pages

  1. Any page needing a chat/thread UI can import and mount the component
  2. Candidates: html-tv.html, strudel.html, didactyl.html

File Changes Summary

File Action
www/js/post-composer.mjs Add clearBeforeSubmit, input history
www/css/post-composer.css Minor updates if needed
www/js/messaging-ui.mjs NewmountMessagingWindow()
www/css/messaging-ui.css New — extracted thread pane styles
www/msg.html Refactor to use component, remove inline styles
www/ai.html Refactor chat pane to use component

Interaction Flow

sequenceDiagram
    participant User
    participant Composer as post-composer
    participant MsgUI as messaging-ui
    participant Page as Host Page

    User->>Composer: Types message, hits Send
    Composer->>Composer: Save text to history ring
    Composer->>Composer: Clear input + preview immediately
    Composer->>Page: onSubmit text
    Page->>Page: Encrypt / API call async
    Page->>MsgUI: appendMessage sent msg
    MsgUI->>MsgUI: Render bubble, auto-scroll

    Note over Composer: If onSubmit fails...
    Page-->>Composer: throws or returns false
    Composer->>Composer: Restore saved text

    User->>Composer: Presses Arrow Up
    Composer->>Composer: Load previous from history
    Composer->>Composer: Display in input
sequenceDiagram
    participant User
    participant MsgUI as messaging-ui
    participant Page as Host Page

    User->>MsgUI: Scrolls to top
    MsgUI->>Page: onLoadOlder callback
    Page->>Page: Fetch older messages
    Page->>MsgUI: prependMessages older
    MsgUI->>MsgUI: Render, preserve scroll position