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

308 lines
10 KiB
Markdown

# 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 `<style>` block with `<link>` to `messaging-ui.css`
9. Replace manual DOM construction with `mountMessagingWindow()` call
10. Keep msg.html-specific logic (Nostr DM encryption, conversation list, send modes) in the page script
11. The page provides `onSubmit` and `onLoadOlder` callbacks to the component
### Phase 4: Integrate into ai.html
12. Add `<link>` to `messaging-ui.css` in ai.html
13. Replace the `divAiChatPane` message area with `mountMessagingWindow()`
14. Keep AI-specific logic (provider selection, streaming, conversation persistence) in the page script
15. Map AI message roles to the component message shape
### Phase 5: Future Pages
16. Any page needing a chat/thread UI can import and mount the component
17. 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
```mermaid
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
```
```mermaid
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
```