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:
- Instant clear on send — clear text + preview immediately, before the async callback resolves.
- 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:
- Capture the text
- Immediately clear the composer and hide preview
- Call
onSubmit(text)asynchronously - If
onSubmitthrows 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
- Add
clearBeforeSubmitoption tomountComposerinwww/js/post-composer.mjs - Add input history ring buffer to
mountComposer - Update
www/css/post-composer.cssif needed for any new elements - Test in msg.html — send should now clear instantly, arrow-up should recall
Phase 2: Create Messaging UI Component
- Create
www/css/messaging-ui.csswith extracted/namespaced styles - Create
www/js/messaging-ui.mjswithmountMessagingWindow() - The component internally uses
mountComposerfor the input area
Phase 3: Refactor msg.html
- Replace inline
<style>block with<link>tomessaging-ui.css - Replace manual DOM construction with
mountMessagingWindow()call - Keep msg.html-specific logic (Nostr DM encryption, conversation list, send modes) in the page script
- The page provides
onSubmitandonLoadOldercallbacks to the component
Phase 4: Integrate into ai.html
- Add
<link>tomessaging-ui.cssin ai.html - Replace the
divAiChatPanemessage area withmountMessagingWindow() - Keep AI-specific logic (provider selection, streaming, conversation persistence) in the page script
- Map AI message roles to the component message shape
Phase 5: Future Pages
- Any page needing a chat/thread UI can import and mount the component
- 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 |
New — mountMessagingWindow() |
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