308 lines
10 KiB
Markdown
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
|
|
```
|