# 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 ```mermaid 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 ```js { 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 ```js { 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 ```js // 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) ```js // 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 5. Create `www/css/messaging-ui.css` with extracted/namespaced styles 6. Create `www/js/messaging-ui.mjs` with `mountMessagingWindow()` 7. The component internally uses `mountComposer` for the input area ### Phase 3: Refactor msg.html 8. Replace inline `