aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js32
-rw-r--r--src/features/messages.js18
-rw-r--r--src/i18n.js4
-rw-r--r--src/media-upload.js26
-rw-r--r--src/style.css5
-rw-r--r--tools/upload-progress-test.js37
-rw-r--r--tools/video-compress-test.js43
7 files changed, 138 insertions, 27 deletions
diff --git a/src/app.js b/src/app.js
index 5b0bdb6..b6721da 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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); }
diff --git a/tools/upload-progress-test.js b/tools/upload-progress-test.js
new file mode 100644
index 0000000..25bf91c
--- /dev/null
+++ b/tools/upload-progress-test.js
@@ -0,0 +1,37 @@
+// uploadPublicMedia reports how much of the file has gone out (XMLHttpRequest
+// upload events — fetch() can't), from 0 to 1, on a slow uplink.
+//
+// Run: bun tools/upload-progress-test.js
+import puppeteer from 'puppeteer-core';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const js = await Bun.build({ entrypoints: ['src/media-upload.js'], target: 'browser' }).then((b) => b.outputs[0].text());
+let got = 0;
+const server = Bun.serve({ port: 5289, async fetch(req) {
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD', 'access-control-allow-headers': '*', 'access-control-expose-headers': 'x-reason' };
+ const p = new URL(req.url).pathname;
+ if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
+ if (p === '/upload' && req.method === 'PUT') { got = (await req.arrayBuffer()).byteLength; return Response.json({ url: 'http://localhost:5289/' + 'a'.repeat(64) }, { status: 201, headers: cors }); }
+ if (p === '/m.js') return new Response(js, { headers: { 'content-type': 'text/javascript' } });
+ return new Response('<script type=module>import * as m from "/m.js"; window.m = m;</script>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+try {
+ await page.goto('http://localhost:5289/');
+ await page.waitForFunction(() => !!window.m);
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 20, downloadThroughput: 5e6, uploadThroughput: 400e3 });
+ const r = await page.evaluate(async () => {
+ const seen = [];
+ const file = new File([new Uint8Array(1_200_000)], 'v.mp4', { type: 'video/mp4' });
+ const sign = async (e) => ({ ...e, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) });
+ const url = await window.m.uploadPublicMedia(file, sign, { servers: ['http://localhost:5289'], onProgress: (p) => seen.push(Math.round(p * 100)) });
+ return { url, seen };
+ });
+ check('the upload lands', r.url.startsWith('http://localhost:5289/') && got === 1_200_000, got + ' bytes');
+ check('progress climbs from 0 to 100 as the bytes go', r.seen[0] === 0 && r.seen.at(-1) === 100 && r.seen.filter((p) => p > 0 && p < 100).length >= 3
+ && r.seen.every((p, i) => !i || p >= r.seen[i - 1]), JSON.stringify(r.seen.slice(0, 15)) + (r.seen.length > 15 ? '…' : ''));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ uploads report their progress' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js
index dc17036..ce1a936 100644
--- a/tools/video-compress-test.js
+++ b/tools/video-compress-test.js
@@ -34,16 +34,34 @@ const server = Bun.serve({ port: 5287, fetch: async (req) => {
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+// the upload goes out by XMLHttpRequest (for its progress events): keep a
+// copy of the body, and answer at the network layer — nothing leaves
await page.evaluateOnNewDocument(() => {
- const f = window.fetch;
- window.fetch = async (url, init) => {
- if (init && init.method === 'PUT' && /nostr\.build|nostr\.download/.test(String(url))) {
- window.__put = { size: init.body.size, type: init.body.type, buf: await init.body.arrayBuffer() };
- return new Response(JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }), { status: 201 });
+ const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (m, url) { this.__m = m; this.__url = String(url); return open.apply(this, arguments); };
+ XMLHttpRequest.prototype.send = function (body) {
+ if (this.__m === 'PUT' && /nostr\.build|nostr\.download/.test(this.__url) && body instanceof Blob) {
+ body.arrayBuffer().then((buf) => { window.__put = { size: body.size, type: body.type, buf }; });
}
- if (init && init.method === 'HEAD' && /nostr\.build|nostr\.download/.test(String(url))) return new Response('', { status: 200 });
- return f(url, init);
+ return send.apply(this, arguments);
};
+ // every state the progress bar shows
+ window.__prog = [];
+ new MutationObserver(() => {
+ const m = document.querySelector('.media-progress');
+ const s = m ? m.innerText.replace(/\s+/g, ' ').trim() : null;
+ if (s && window.__prog.at(-1) !== s) window.__prog.push(s);
+ }).observe(document, { childList: true, subtree: true, characterData: true, attributes: true });
+});
+await page.setRequestInterception(true);
+page.on('request', (req) => {
+ const u = req.url();
+ if (!/nostr\.build|nostr\.download/.test(u)) return req.continue();
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD, GET', 'access-control-allow-headers': '*' };
+ if (req.method() === 'OPTIONS') return req.respond({ status: 204, headers: cors });
+ if (req.method() === 'HEAD') return req.respond({ status: 200, headers: cors });
+ return req.respond({ status: 201, headers: cors, contentType: 'application/json',
+ body: JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }) });
});
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
@@ -72,9 +90,16 @@ try {
dt.items.add(new File([b], 'phone.mp4', { type: 'video/mp4' }));
document.querySelector('.post-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
});
- const sawShrink = await waitText('shrinking the video', 15000);
- check('the toast says it is shrinking the video', sawShrink);
const done = await waitFor(() => !!window.__put, 120000);
+ await waitFor(() => !document.querySelector('.media-progress'), 10000);
+ const prog = await page.evaluate(() => window.__prog);
+ const pcts = (stage) => prog.filter((x) => x.startsWith(stage)).map((x) => +x.match(/(\d+)%/)[1]);
+ const shrink = pcts('Shrinking video'), up = pcts('Uploading');
+ check('a bar shows the shrinking with its percentage', shrink.length >= 3 && shrink.some((p) => p > 0 && p < 100), JSON.stringify(shrink.slice(0, 12)));
+ // (the answer here is instant, so only its start shows — the percentages
+ // themselves are upload-progress-test's)
+ check('...then the upload with its own', up.length >= 1 && prog.findIndex((x) => x.startsWith('Uploading')) > prog.findIndex((x) => x.startsWith('Shrinking')), JSON.stringify(up));
+ check('...and goes away when it is done', await page.evaluate(() => !document.querySelector('.media-progress')));
const put = done && await page.evaluate(() => ({ size: window.__put.size, type: window.__put.type }));
const secs = ((Date.now() - t0) / 1000).toFixed(1);
check('the upload is the shrunk video', !!put && put.type === 'video/mp4' && put.size < srcSize * 0.35,