polish(share): picker avatars + target name + pre-warm video poster

Follow-ups from testing the share flow (which now works end-to-end):
- Send-to picker showed only initials — now shows the real profile photo when the
  chat has one (matching the sidebar/forward avatars), coloured initials otherwise.
- During send it only said "Uploading 60%" with no idea WHO to — now the header and
  progress name the target ("Sending to Manasa Rapolu · 60%").
- After sending, a video bubble sat blank (just a timestamp) for a second or two
  while the poster generated on first view. media.js now warms the poster thumbnail
  at UPLOAD (temp-then-rename), and the on-demand /thumbs handler also writes via a
  temp, so the two can't serve a half-written JPEG. The bubble shows its poster
  right away.

Web + server only — live on deploy. Does NOT address the share extension failing to
auto-open the app (an iOS limitation, handled next in the native build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 16:21:02 +05:30
parent 4836b1e197
commit 0c3487fdb0
3 changed files with 38 additions and 7 deletions
+20
View File
@@ -151,10 +151,30 @@ function transcode(id, done) {
}); });
} }
// Poster frame, generated to a temp then renamed so a reader never sees a half-written JPEG (the /thumbs
// handler and this can both target the same file). Warming it at upload means the chat bubble shows the
// poster immediately instead of a blank tile while ffmpeg runs on the first view.
function ensureThumb(id) {
const thumb = path.join(UPLOADS_DIR, id + '.thumb.jpg');
const src = path.join(UPLOADS_DIR, id);
if (!src.startsWith(UPLOADS_DIR)) return;
if (fs.existsSync(thumb)) return;
fs.stat(src, (e) => {
if (e) return;
const tmp = thumb + '.part';
execFile('ffmpeg', ['-y', '-ss', '0.5', '-i', src, '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', tmp],
{ timeout: 15000 }, (err) => {
if (err) { try { fs.unlinkSync(tmp); } catch (_) {} return; }
try { fs.renameSync(tmp, thumb); } catch (_) { try { fs.unlinkSync(tmp); } catch (__) {} }
});
});
}
// Queue a freshly uploaded (or first-played) video. Cheap and idempotent: safe to call on every // Queue a freshly uploaded (or first-played) video. Cheap and idempotent: safe to call on every
// /stream hit, which is also how pre-existing uploads get backfilled. // /stream hit, which is also how pre-existing uploads get backfilled.
function ensureWebRendition(id, mime) { function ensureWebRendition(id, mime) {
if (!/^video\//.test(mime || '')) return; if (!/^video\//.test(mime || '')) return;
ensureThumb(id); // warm the poster so the bubble isn't blank
if (pending.has(id) || failed.has(id) || hasWebRendition(id)) return; if (pending.has(id) || failed.has(id) || hasWebRendition(id)) return;
pending.add(id); pending.add(id);
queue.push(id); queue.push(id);
+12 -5
View File
@@ -1197,7 +1197,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-24-batch167';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-24-batch168';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network. // Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
// //
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -3599,7 +3599,10 @@ function openSharePicker(items){
q=(q||'').toLowerCase().trim(); q=(q||'').toLowerCase().trim();
const shown=rows.filter(r=>!q||String(r.name||'').toLowerCase().includes(q)); const shown=rows.filter(r=>!q||String(r.name||'').toLowerCase().includes(q));
listEl.innerHTML=shown.length?shown.map(r=>{ listEl.innerHTML=shown.length?shown.map(r=>{
const av='<span class="sr-ic">'+(r.kind==='group'?ic('users',16):initials(r.name))+'</span>'; // Match the sidebar/forward avatars: real photo if the row has one, else coloured initials.
const inner = r.kind==='group' ? ic('users',16)
: (r.avatar ? '<img src="'+pEsc(r.avatar)+'" alt="" onerror="this.remove()">' : pEsc(initials(r.name)));
const av='<span class="sr-ic" style="background:'+avColor(r.name||'?')+';color:#334155">'+inner+'</span>';
return '<div class="stor-row share-to" data-kind="'+pEsc(r.kind)+'" data-id="'+pEsc(r.id)+'">'+av return '<div class="stor-row share-to" data-kind="'+pEsc(r.kind)+'" data-id="'+pEsc(r.id)+'">'+av
+'<span class="sr-m"><span class="sr-n">'+pEsc(r.name||'Chat')+'</span>' +'<span class="sr-m"><span class="sr-n">'+pEsc(r.name||'Chat')+'</span>'
+'<span class="sr-s">'+(r.kind==='group'?'Group':'Direct message')+'</span></span>' +'<span class="sr-s">'+(r.kind==='group'?'Group':'Direct message')+'</span></span>'
@@ -3614,17 +3617,21 @@ function openSharePicker(items){
} }
async function sendSharedTo(kind, id, files, texts, ov, close){ async function sendSharedTo(kind, id, files, texts, ov, close){
if(ov.dataset.busy) return; ov.dataset.busy='1'; if(ov.dataset.busy) return; ov.dataset.busy='1';
const target=((rowFor(kind,id)||{}).name)||'chat';
const sub=ov.querySelector('.gi-sub'); if(sub) sub.textContent='Sending to '+target;
const body=ov.querySelector('#shareList'); const body=ov.querySelector('#shareList');
const prog=document.createElement('div'); prog.className='stor-empty'; prog.textContent='Sending…'; const prog=document.createElement('div'); prog.className='stor-empty'; prog.textContent='Sending to '+target+'…';
if(body){ body.innerHTML=''; body.appendChild(prog); } if(body){ body.innerHTML=''; body.appendChild(prog); }
const many=files.length>1;
const post=(payload)=>postJSON('/api/messages', kind==='group'?Object.assign({group:id},payload):Object.assign({to:id},payload)); const post=(payload)=>postJSON('/api/messages', kind==='group'?Object.assign({group:id},payload):Object.assign({to:id},payload));
try{ try{
let first=true; let first=true;
// Files first (each its own message), then any shared text/link as a final message. // Files first (each its own message), then any shared text/link as a final message.
for(let i=0;i<files.length;i++){ for(let i=0;i<files.length;i++){
prog.textContent='Sending '+(i+1)+' of '+files.length+''; const of=many?(' ('+(i+1)+' of '+files.length+')'):'';
prog.textContent='Sending to '+target+of+'…';
const f=await bzShareItemToFile(files[i]); const f=await bzShareItemToFile(files[i]);
const meta=await bzUploadBlob(f, p=>{ prog.textContent='Uploading '+(i+1)+' of '+files.length+' · '+p+'%'; }); const meta=await bzUploadBlob(f, p=>{ prog.textContent='Uploading to '+target+of+' · '+p+'%'; });
await post({ body:(first&&texts.length&&files.length===1)?texts.join('\n'):'', attachmentId:meta.id, mentions:[] }); await post({ body:(first&&texts.length&&files.length===1)?texts.join('\n'):'', attachmentId:meta.id, mentions:[] });
first=false; first=false;
} }
+6 -2
View File
@@ -223,8 +223,12 @@ function handleGet(req, res) {
const rs = fs.createReadStream(thumb); rs.on('error', () => { try { res.destroy(); } catch (e2) {} }); rs.pipe(res); const rs = fs.createReadStream(thumb); rs.on('error', () => { try { res.destroy(); } catch (e2) {} }); rs.pipe(res);
}); });
if (fs.existsSync(thumb)) return send(); if (fs.existsSync(thumb)) return send();
return require('child_process').execFile('ffmpeg', ['-y', '-ss', '0.5', '-i', src, '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', thumb], { timeout: 15000 }, (err) => { // Write to a temp then rename — media.js may pre-generate the same poster at upload, and two writers
if (err) { try { fs.unlinkSync(thumb); } catch (_) {} return json(res, 404, { error: 'thumbnail unavailable' }); } // to the same path could otherwise serve a half-written JPEG.
const tmp = thumb + '.req.part';
return require('child_process').execFile('ffmpeg', ['-y', '-ss', '0.5', '-i', src, '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', tmp], { timeout: 15000 }, (err) => {
if (err) { try { fs.unlinkSync(tmp); } catch (_) {} return json(res, 404, { error: 'thumbnail unavailable' }); }
try { if (!fs.existsSync(thumb)) fs.renameSync(tmp, thumb); else fs.unlinkSync(tmp); } catch (_) {}
send(); send();
}); });
} }