11 KiB
Post Composer Component — Plan
Overview
Create a reusable post-composer module (www/js/post-composer.mjs) that encapsulates:
- File upload — drag-and-drop onto the compose area + click-to-upload icon
- Live preview — renders the post content as it will appear (images,
nostr:entities, links) - @ mention autocomplete — type
@nameto search followed users and insert theirnostr:npub1…reference - Reusability — the same module can be mounted inside
post.html, reply dialogs, or any future compose context
The composer uploads files to all configured blossom servers (via existing uploadToAllServers) but inserts only the default server URL into the post text (via existing getDefaultBlossomServer / getBlobUrl).
Architecture
graph TD
A[post-composer.mjs] -->|imports| B[blossom-api.mjs]
A -->|imports| C[blossom-ui.mjs]
A -->|imports| D[utilities.mjs - htmlFormatText]
A -->|imports| E[post-interactions.mjs - hydrateNostrEntities]
F[post.html] -->|calls| A
G[future reply dialog] -->|calls| A
A -->|receives| H[contact list - followed pubkeys + profiles]
Module API — post-composer.mjs
/**
* Mount a post composer onto an existing contenteditable div.
*
* @param {HTMLElement} hostEl — the contenteditable element, e.g. divPost
* @param {Object} options
* @param {string} options.currentPubkey — logged-in user hex pubkey
* @param {Array} options.followedProfiles — [{pubkey, name, display_name, picture}, ...]
* @param {Function} options.onSubmit — called with final plain-text content string
* @param {Function} options.fetchCachedProfile — profile lookup fn
* @param {boolean} [options.showUploadIcon=true]
* @param {boolean} [options.showPreview=true]
* @returns {{ destroy: Function, refresh: Function }}
*/
export function mountComposer(hostEl, options) { ... }
The module does not own the <div contenteditable> element — it wraps around it, adding:
- A wrapper div around the host for positioning context
- An upload icon (bottom-right of the host)
- A preview div (below the host, inside the same wrapper)
- An @ mention dropdown (positioned inline near the cursor)
- Drag-and-drop event listeners on the host
- A hidden
<input type="file">for click-to-upload
Detailed Design
1. File Upload
Drag & Drop on divPost:
- Listen for
dragover,dragleave,dropon the host element - On
drop: extract files, show a visual uploading indicator, calluploadToAllServers(file)fromblossom-api.mjs - On success: construct the URL using
getBlobUrl(sha256, ext)fromblossom-api.mjs— this uses the default (first healthy upload) server - Insert the URL at the cursor position in the contenteditable div
- For images: insert the raw URL on its own line (the preview will render it as
<img>) - For other files: insert the URL as a link
- For images: insert the raw URL on its own line (the preview will render it as
Upload Icon (bottom-right):
- A small clickable icon (SVG paperclip or upload arrow) positioned absolutely in the bottom-right corner of the host element
- On click: trigger a hidden
<input type="file" accept="image/*,video/*,audio/*"> - Same upload flow as drag-and-drop
Upload Progress:
- While uploading, show a small spinner or progress text overlay on the host element
- Disable the submit (Enter key) while upload is in progress
2. Live Preview
Trigger: The preview div appears once the user starts typing (first input event with non-empty content).
Rendering:
- On each
inputevent (debounced ~300ms), takehostEl.innerText, run it throughhtmlFormatText()fromutilities.mjs - Set the preview div's
innerHTMLto the result - Call
hydrateNostrEntities(previewEl)frompost-interactions.mjsto resolve embedded notes/profiles - The preview div uses the same CSS classes as
.divPostContentso it looks identical to rendered posts
Preview div placement:
- Sits below the compose area inside the same wrapper
- Has a subtle top border or label like "Preview" in muted text
- Hidden when compose area is empty
3. @ Mention Autocomplete
Trigger: When the user types @ followed by at least 1 character, search the followedProfiles array.
Search logic:
- Filter profiles where
name,display_name, ornip05contains the typed substring (case-insensitive) - Show up to 8 results in a dropdown positioned near the cursor
Dropdown UI:
- Absolutely positioned dropdown with avatar thumbnail, display name, and @name
- Keyboard navigation: arrow keys to select, Enter/Tab to confirm, Escape to dismiss
- Click to select
Insertion:
- On selection, replace the
@querytext withnostr:npub1…(the bech32-encoded npub of the selected user) - The preview will then render this as a clickable mention link via
htmlFormatText
Profile data source:
post.htmlalready fetches the contact list (kind 3) and hasfollowedPubkeys- The composer receives pre-fetched profile data via
options.followedProfiles - Profiles are lazily enriched: if a followed pubkey has no cached profile yet, the dropdown shows the truncated npub
4. Integration with post.html
Current flow in post.html:
divPostis acontenteditabledivhandleKeyDownlistens for Enter (without Shift) to callhandlePost()handlePost()readsdivPost.innerText, publishes kind 1 event
New flow:
- After
divPostis set up andinteractionsis initialized, callmountComposer(divPost, { ... }) - The composer adds upload, preview, and mention features
handlePost()remains the same — it readsdivPost.innerTextwhich now may containnostr:npub1…references and image URLs- The composer's
onSubmitcallback is optional —post.htmlcan continue using its own Enter key handler
DOM changes to post.html:
- Wrap
divPostin a newdiv#divPostWrapper(or the composer creates this wrapper dynamically) - The preview div and upload icon are children of this wrapper
- Minimal CSS additions for the wrapper, upload icon positioning, preview styling, and mention dropdown
File Changes Summary
| File | Change |
|---|---|
www/js/post-composer.mjs |
NEW — the reusable composer module |
www/css/post-composer.css |
NEW — styles for upload icon, preview, mention dropdown, drag overlay |
www/post.html |
MODIFY — import and mount the composer; add CSS link; minor DOM adjustments |
www/js/utilities.mjs |
No changes needed — htmlFormatText already handles images and nostr entities |
www/js/blossom-api.mjs |
No changes needed — uploadToAllServers, getBlobUrl, getDefaultBlossomServer already exist |
www/js/post-interactions.mjs |
No changes needed — hydrateNostrEntities is already exported |
Implementation Steps
Step 1: Create www/css/post-composer.css
.post-composer-wrapper— relative positioning container.post-composer-upload-icon— absolute bottom-right, clickable, hover state.post-composer-drag-overlay— full overlay on dragover with dashed border and upload icon.post-composer-preview— preview area below compose, uses.divPostContentbase styles.post-composer-preview-label— small muted "Preview" label.post-composer-mention-dropdown— absolutely positioned autocomplete list.post-composer-mention-item— individual mention result with avatar, name.post-composer-mention-item.active— highlighted/selected item.post-composer-uploading— upload-in-progress indicator
Step 2: Create www/js/post-composer.mjs
- Export
mountComposer(hostEl, options)function - Implement drag-and-drop upload with visual feedback
- Implement click-to-upload via hidden file input
- Implement debounced live preview using
htmlFormatText+hydrateNostrEntities - Implement @ mention detection, search, dropdown rendering, and npub insertion
- Return
{ destroy, refresh }for cleanup and profile list updates
Step 3: Integrate into www/post.html
- Add
<link rel="stylesheet" href="./css/post-composer.css" />in<head> - Import
mountComposerfrom./js/post-composer.mjs - After
interactionsis initialized and contact list is loaded, buildfollowedProfilesarray - Call
mountComposer(divPost, { currentPubkey, followedProfiles, fetchCachedProfile }) - Update
followedProfileswhen new profiles arrive (viacomposer.refresh({ followedProfiles }))
Step 4: Test all features
- Drag an image file onto divPost — verify upload to all blossom servers, URL inserted with default server
- Click upload icon — verify file picker opens, same upload flow
- Type text with an image URL — verify preview shows the image
- Type
nostr:npub1...— verify preview shows mention link - Type
@jack— verify dropdown appears with matching follows, selection inserts npub - Press Enter — verify post publishes with correct content including URLs and npub references
Mermaid: Composer Interaction Flow
sequenceDiagram
participant U as User
participant C as Composer
participant B as Blossom API
participant P as Preview
U->>C: Drops image file on divPost
C->>C: Show drag overlay + uploading indicator
C->>B: uploadToAllServers - file
B-->>C: blobDescriptor + sha256
C->>C: getBlobUrl - sha256, ext - default server URL
C->>C: Insert URL at cursor in divPost
C->>P: Debounced preview update
P->>P: htmlFormatText renders img tag
U->>C: Types @jac
C->>C: Detect @ trigger, search followedProfiles
C->>C: Show mention dropdown with matches
U->>C: Selects jack from dropdown
C->>C: Replace @jac with nostr:npub1abc...
C->>P: Debounced preview update
P->>P: htmlFormatText renders mention link
U->>C: Presses Enter
C->>C: onSubmit callback or native handler
Mermaid: Module Dependency Graph
graph LR
PC[post-composer.mjs] --> BA[blossom-api.mjs]
PC --> BU[blossom-ui.mjs]
PC --> UT[utilities.mjs]
PC --> PI[post-interactions.mjs]
BA --> BU
PH[post.html] --> PC
PH --> PI
PH --> BA
PH --> BU
PH --> UT
Key Design Decisions
-
Module, not Web Component — Using a plain ES module with
mountComposer()rather than a custom element, because the existing codebase uses ES modules throughout and the contenteditable div is already in the HTML. This avoids Shadow DOM complexity and keeps CSS consistent. -
Default server for URLs —
getBlobUrl()inblossom-api.mjsalready implements BUD-03 logic (first healthy upload server). We use this for the URL inserted into the post, whileuploadToAllServers()handles redundant storage. -
Preview reuses existing rendering — By calling
htmlFormatText()+hydrateNostrEntities(), the preview is guaranteed to match how posts look in the feed. No duplicate rendering logic. -
@ mentions use npub, not display name — The inserted text is
nostr:npub1…which is the Nostr standard. The preview and feed rendering will resolve this to a clickable name viahtmlFormatText. -
Contact list as input — The composer receives the followed profiles list from the host page rather than fetching it independently. This avoids duplicate subscriptions and lets the host page control when/how profiles are loaded.