diff options
| author | Adam Soltys <[email protected]> | 2026-09-30 10:23:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-30 10:23:20 -0700 |
| commit | 0076c591df9432e2bb5b2281afc1fb4106500297 (patch) | |
| tree | 7b9ef49079158af7e6e2545f1e847c4c2baeb428 /src | |
| parent | 505cd84813a80e0a69b2cc95aa93c68cf2856cb2 (diff) | |
| download | coinosv3-0076c591df9432e2bb5b2281afc1fb4106500297.tar.gz coinosv3-0076c591df9432e2bb5b2281afc1fb4106500297.zip | |
A picture or video on its way shows a bar with its percentage — shrinking, then uploading; the pencil reads "New post".
ui.mediaProgress { stage, pct } (a render per whole percent) drawn as a
labelled bar under the reply and post composers, instead of only the
paperclip turning into a spinner. Upload progress comes from
XMLHttpRequest upload events (fetch() has none) in uploadPublicMedia's
new onProgress; injected fetchers (tests) keep the fetch path.
upload-progress-test: 1.2 MB at 400 KB/s climbs 0→100 in steps.
video-compress-test: the bar counts the shrink, then the upload.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 32 | ||||
| -rw-r--r-- | src/features/messages.js | 18 | ||||
| -rw-r--r-- | src/i18n.js | 4 | ||||
| -rw-r--r-- | src/media-upload.js | 26 | ||||
| -rw-r--r-- | src/style.css | 5 |
5 files changed, 67 insertions, 18 deletions
@@ -3482,24 +3482,32 @@ async function onbUpload(file) { return url; } +// ui.mediaProgress = { stage: 'shrink' | 'upload', pct } while a picture or +// video is on its way; the composers draw it as a bar under the field. +// A render per whole percent, no more. +function mediaProgress(stage, fraction) { + const pct = Math.max(0, Math.min(100, Math.floor(fraction * 100))); + const cur = ui.mediaProgress; + if (cur && cur.stage === stage && cur.pct === pct) return; + ui.mediaProgress = { stage, pct }; + render(); +} async function publicMediaUpload(file) { - // a phone video is shrunk on the device first (see video-compress.js); - // the toast counts it down, since a long clip takes a while - if (/^video\//.test(file?.type || '')) { - let shown = -1; - const { compressVideo } = await import('./video-compress.js'); - file = await compressVideo(file, (p) => { - const pct = Math.floor(p * 100); - if (pct !== shown && pct % 5 === 0) { shown = pct; toast(t('mediaShrinking', { pct }), 60_000); } - }); - if (shown >= 0) toast(t('mediaUploading'), 4000); - } try { - return await uploadPublicMedia(file, (event) => wallet.nostrSign(event)); + // a phone video is shrunk on the device first (see video-compress.js) + if (/^video\//.test(file?.type || '')) { + mediaProgress('shrink', 0); + const { compressVideo } = await import('./video-compress.js'); + file = await compressVideo(file, (p) => mediaProgress('shrink', p)); + } + mediaProgress('upload', 0); + return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) }); } catch (error) { if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); throw new Error(t('msgUploadFailed')); + } finally { + ui.mediaProgress = null; } } diff --git a/src/features/messages.js b/src/features/messages.js index 6a68c38..0c0acf4 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7127,6 +7127,7 @@ export function messagesFeature(ctx) { style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'), }, s.preview ? draftPreview(s.draft) : null, + mediaBar(), // A textarea that grows with the reply, like the chat composer: Enter // sends, Shift+Enter (or Ctrl+J) breaks the line — a reply used to be // a one-line field with no way to write a paragraph. @@ -7833,6 +7834,16 @@ export function messagesFeature(ctx) { ? h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(body)) : h('div', { class: 'small faint' }, t('composePreviewEmpty'))); } + // Shrinking, then uploading, as a labelled bar with its percentage — the + // paperclip's spinner said only "busy", however long a video took. + function mediaBar() { + const m = ui.mediaProgress; + if (!m) return null; + const label = t(m.stage === 'shrink' ? 'mediaShrinking' : 'mediaUploading'); + return h('div', { class: 'media-progress', role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(m.pct), 'aria-label': label }, + h('div', { class: 'row between small' }, h('span', { class: 'muted' }, label + '…'), h('span', { class: 'media-progress-pct' }, m.pct + '%')), + h('div', { class: 'media-progress-track' }, h('div', { class: 'media-progress-fill', style: 'width:' + m.pct + '%' }))); + } const previewBtn = (on, toggle) => h('button', { class: 'attach-btn' + (on ? ' on' : ''), title: on ? t('composePreviewHide') : t('composePreview'), @@ -7859,6 +7870,7 @@ export function messagesFeature(ctx) { }, }), ui.postPreview ? draftPreview(text) : null, + mediaBar(), h('div', { class: 'row gap6' }, h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => { const body = composeText().trim(); @@ -8105,9 +8117,11 @@ export function messagesFeature(ctx) { onClick: () => { ui.feedMenu = true; render(); }, }, '\u22ef'), visitor ? null : h('button', { - class: 'btn-sm', style: 'flex-shrink:0', title: t('profNewPost'), 'aria-label': t('profNewPost'), + // the pencil alone wasn't read as "write a post": it says so + class: 'btn-sm feed-new-post', style: 'flex-shrink:0;display:inline-flex;align-items:center;gap:6px;white-space:nowrap', title: t('profNewPost'), 'aria-label': t('profNewPost'), onClick: () => { ui.profCompose = ui.profCompose == null ? (draftFor(POST_DRAFT) || '') : null; render(); }, - html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' })), + }, h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' }), + h('span', {}, t('profNewPost')))), ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null, visitor ? null : feedChips(), visitor ? null : postComposer(), diff --git a/src/i18n.js b/src/i18n.js index f54e760..9932ced 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -245,8 +245,8 @@ const STR = { feedDelete: 'Delete feed', feedSaveAdhoc: 'Save feed', feedSaved: 'Feed saved', - mediaShrinking: 'Shrinking the video for streaming… {pct}%', - mediaUploading: 'Uploading…', + mediaShrinking: 'Shrinking video for streaming', + mediaUploading: 'Uploading', feedNoQuery: 'Add people or topics to this feed.', feedOfTitle: '{name}’s timeline', feedOfLoading: 'Reading who they follow…', diff --git a/src/media-upload.js b/src/media-upload.js index 8e5e8ed..9301939 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -43,13 +43,32 @@ function mediaUrl(url, server, file) { return out.href; } +// A PUT that reports how much of the body has gone out: fetch() can't, so in +// the browser (with the real fetch) it's XMLHttpRequest. Answers like fetch. +function putWithProgress(url, { body, headers, signal }, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', url); + for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); }; + xhr.onload = () => resolve(new Response(xhr.responseText, { + status: xhr.status, headers: { 'x-reason': xhr.getResponseHeader('x-reason') || '' } })); + xhr.onerror = () => reject(new Error('network error')); + xhr.ontimeout = () => reject(new Error('timeout')); + if (signal) signal.addEventListener('abort', () => { xhr.abort(); reject(new Error('timeout')); }); + xhr.send(body); + }); +} + // BUD-02 upload. `signEvent` is deliberately injected: the app can use its // always-available wallet identity, while tests need no secret key. export async function uploadPublicMedia(file, signEvent, { servers = PUBLIC_MEDIA_SERVERS, fetcher = globalThis.fetch, timeoutMs = 5 * 60_000, + onProgress = null, // (fraction 0..1) as the bytes go out } = {}) { + const tracked = onProgress && typeof XMLHttpRequest !== 'undefined' && fetcher === globalThis.fetch; if (!file || !file.size || typeof signEvent !== 'function') throw new MediaUploadError('failed'); const x = await fileHash(file); const now = Math.floor(Date.now() / 1000); @@ -68,7 +87,7 @@ export async function uploadPublicMedia(file, signEvent, { catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; } } try { - const response = await fetcher(server.replace(/\/$/, '') + '/upload', { + const init = { method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs), headers: { authorization, @@ -76,7 +95,10 @@ export async function uploadPublicMedia(file, signEvent, { 'x-sha-256': x, 'x-content-length': String(file.size), }, - }); + }; + if (onProgress) onProgress(0); + const target = server.replace(/\/$/, '') + '/upload'; + const response = tracked ? await putWithProgress(target, init, onProgress) : await fetcher(target, init); let body = null; try { body = await response.json(); } catch {} const url = response.ok && responseUrl(body); diff --git a/src/style.css b/src/style.css index 9bdaf3c..60d850c 100644 --- a/src/style.css +++ b/src/style.css @@ -256,6 +256,11 @@ coinos-text { white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; line-height: 1.4; cursor: text; } coinos-text:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } +/* a picture or video on its way: shrinking, then uploading (mediaBar) */ +.media-progress { display: flex; flex-direction: column; gap: 4px; } +.media-progress-pct { font-variant-numeric: tabular-nums; } +.media-progress-track { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; } +.media-progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width .2s linear; } coinos-text[data-empty]::before { content: attr(placeholder); color: var(--muted); pointer-events: none; } .chat-compose coinos-text { flex: 1; min-width: 0; padding: 10px 13px; max-height: 120px; } .mono-input { font-family: var(--mono); } |
