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

11 KiB

Post Composer Component — Plan

Overview

Create a reusable post-composer module (www/js/post-composer.mjs) that encapsulates:

  1. File upload — drag-and-drop onto the compose area + click-to-upload icon
  2. Live preview — renders the post content as it will appear (images, nostr: entities, links)
  3. @ mention autocomplete — type @name to search followed users and insert their nostr:npub1… reference
  4. 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, drop on the host element
  • On drop: extract files, show a visual uploading indicator, call uploadToAllServers(file) from blossom-api.mjs
  • On success: construct the URL using getBlobUrl(sha256, ext) from blossom-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

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 input event (debounced ~300ms), take hostEl.innerText, run it through htmlFormatText() from utilities.mjs
  • Set the preview div's innerHTML to the result
  • Call hydrateNostrEntities(previewEl) from post-interactions.mjs to resolve embedded notes/profiles
  • The preview div uses the same CSS classes as .divPostContent so 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, or nip05 contains 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 @query text with nostr: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.html already fetches the contact list (kind 3) and has followedPubkeys
  • 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:

  1. divPost is a contenteditable div
  2. handleKeyDown listens for Enter (without Shift) to call handlePost()
  3. handlePost() reads divPost.innerText, publishes kind 1 event

New flow:

  1. After divPost is set up and interactions is initialized, call mountComposer(divPost, { ... })
  2. The composer adds upload, preview, and mention features
  3. handlePost() remains the same — it reads divPost.innerText which now may contain nostr:npub1… references and image URLs
  4. The composer's onSubmit callback is optional — post.html can continue using its own Enter key handler

DOM changes to post.html:

  • Wrap divPost in a new div#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 .divPostContent base 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 mountComposer from ./js/post-composer.mjs
  • After interactions is initialized and contact list is loaded, build followedProfiles array
  • Call mountComposer(divPost, { currentPubkey, followedProfiles, fetchCachedProfile })
  • Update followedProfiles when new profiles arrive (via composer.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

  1. 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.

  2. Default server for URLsgetBlobUrl() in blossom-api.mjs already implements BUD-03 logic (first healthy upload server). We use this for the URL inserted into the post, while uploadToAllServers() handles redundant storage.

  3. Preview reuses existing rendering — By calling htmlFormatText() + hydrateNostrEntities(), the preview is guaranteed to match how posts look in the feed. No duplicate rendering logic.

  4. @ 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 via htmlFormatText.

  5. 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.