feat(storage): Biz Connect folder in Files, manage storage, videos save once
Three connected pieces, so downloads stop being write-only.
1. A folder the user can actually find. ios-patch.sh now sets UIFileSharingEnabled
and LSSupportsOpeningDocumentsInPlace, and downloads go to the app's Documents
folder in typed subfolders. iOS shows it as:
Files -> On My iPhone -> Biz Connect -> Images | Videos | Files
Previously everything was written to CACHE (private, and iOS purges it whenever
it likes) and pushed straight at the share sheet, so nothing was ever really
"kept" by the app.
2. Manage storage (Settings -> Storage). Lists what this device has downloaded,
grouped by type with per-group and total sizes; each row can be shared to the OS
sheet (this is where "Save to Photos" now lives) or deleted. Plus Delete all.
Deleting removes ONLY the local copy — the attachment stays on the server, so
anything deleted can be downloaded again from the chat.
3. A downloaded video never downloads twice. Images and files have their own
download link, but a video's tile IS the player, so it had no control at all and
re-streamed on every play. It now carries a download button; once saved, the
button becomes a tick and the tile plays from the local file — no network.
The index is treated as a cache of the filesystem, never as truth, because the user
can delete these from the Files app behind our back: every listing re-stats and
forgets what is gone, the library is reconciled at startup, and a local file that
has vanished by play time falls straight back to streaming instead of showing a
broken player.
Unit-checked the path allocator: collisions between different attachments with the
same filename resolve to "name (2)", re-downloading the SAME attachment reuses its
path, and path traversal / illegal characters are neutralised.
Note: the folder and the save location need a new iOS build to take effect. The web
side degrades cleanly — none of this UI appears outside the native app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+221
-14
@@ -678,6 +678,26 @@
|
||||
.dl-prog .dp-pct{font-weight:700;flex:0 0 auto;font-variant-numeric:tabular-nums;}
|
||||
.dl-prog .dp-bar{height:4px;border-radius:3px;background:rgba(255,255,255,.22);overflow:hidden;}
|
||||
.dl-prog .dp-fill{display:block;height:100%;width:0;background:#fff;transition:width .15s linear;}
|
||||
/* A video owns its whole tile (the player fills it), so its download control is an overlay rather than a
|
||||
link like images/files get. Once saved it becomes a tick and the tile plays from disk. */
|
||||
.att-vid-dl{position:absolute;top:8px;right:8px;z-index:4;width:34px;height:34px;border-radius:50%;border:0;background:rgba(0,0,0,.62);color:#fff;display:grid;place-items:center;cursor:pointer;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);}
|
||||
.att-vid-dl:active{transform:scale(.94);}
|
||||
.att-vid-saved{position:absolute;top:8px;right:8px;z-index:4;width:22px;height:22px;border-radius:50%;background:rgba(16,185,129,.92);color:#fff;display:grid;place-items:center;pointer-events:none;}
|
||||
/* Manage storage */
|
||||
.stor-tot{display:flex;align-items:center;gap:.45rem;margin:.1rem 0 .5rem;}
|
||||
.stor-tot b{font-size:1.3rem;}
|
||||
.stor-grp{font-size:.7rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin:.65rem 0 .25rem;}
|
||||
.stor-list{max-height:52vh;overflow:auto;}
|
||||
.stor-row{display:flex;align-items:center;gap:.55rem;padding:.4rem .1rem;border-bottom:1px solid var(--line);}
|
||||
.stor-row .sr-ic{width:34px;height:34px;flex:0 0 34px;border-radius:8px;background:rgba(0,0,0,.06);display:grid;place-items:center;overflow:hidden;color:var(--muted);}
|
||||
.stor-row .sr-ic img{width:100%;height:100%;object-fit:cover;}
|
||||
.stor-row .sr-m{flex:1;min-width:0;}
|
||||
.stor-row .sr-n{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.85rem;}
|
||||
.stor-row .sr-s{display:block;font-size:.72rem;color:var(--muted);}
|
||||
.stor-row .sr-b{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:.35rem;border-radius:6px;display:grid;place-items:center;}
|
||||
.stor-row .sr-b:hover{background:rgba(0,0,0,.06);color:var(--blue);}
|
||||
.stor-row .sr-b.del:hover{color:#dc2626;}
|
||||
.stor-empty{color:var(--muted);font-size:.85rem;padding:1.1rem .2rem;text-align:center;line-height:1.5;}
|
||||
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);}
|
||||
.att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
|
||||
/* groups */
|
||||
@@ -1175,7 +1195,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<script src="/icons.js?v=6"></script>
|
||||
<script>window.__BUILD='2026-07-23-batch162';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-23-batch163';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.
|
||||
//
|
||||
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
||||
@@ -1457,6 +1477,7 @@ function openSettings(){
|
||||
// NOTE: deliberately NO "web build" row here — users must not have to reason about an app version vs a
|
||||
// web build. New code applies itself (see checkWebBuild/scheduleAutoRefresh); the only version shown is
|
||||
// the desktop app's, which is what the auto-updater manages.
|
||||
+(bzLibOn()?('<label class="gi-setting"><span>Storage<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Photos, videos & files you downloaded to this device</div></span><button class="btn sm" id="setStor">Manage</button></label>'):'')
|
||||
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
|
||||
document.body.appendChild(ov);
|
||||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||||
@@ -1481,6 +1502,68 @@ function openSettings(){
|
||||
ov.querySelector('#setGroup').onchange=e=>setPref('group', e.target.checked);
|
||||
ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked);
|
||||
const permBtn=ov.querySelector('#setPerm'); if(permBtn) permBtn.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ try{ await subscribePush(); }catch(_){} } }catch(_){} ov.remove(); openSettings(); }; // just trigger the browser prompt + refresh the state (no toast)
|
||||
const storBtn=ov.querySelector('#setStor'); if(storBtn) storBtn.onclick=()=>{ ov.remove(); openStorage(); };
|
||||
}
|
||||
// Manage storage: everything this device has downloaded, with sizes, and a way to remove it. Deleting
|
||||
// here only removes the LOCAL copy — the message and its attachment stay on the server, so anything
|
||||
// deleted can simply be downloaded again from the chat.
|
||||
function openStorage(){
|
||||
if(document.getElementById('storModal')) return;
|
||||
const plat=(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'';
|
||||
const where = plat==='ios' ? 'Files → On My iPhone → Biz Connect' : 'your Documents folder → Biz Connect';
|
||||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='storModal';
|
||||
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem">'
|
||||
+'<div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('folder',20)+'</div>'
|
||||
+'<div class="gi-name"><div class="gi-title">Manage storage</div><div class="gi-sub">Downloads on this device</div></div>'
|
||||
+'<button class="iconbtn" id="storClose" title="Close">'+ic('x',18)+'</button></div>'
|
||||
+'<div id="storBody"><div class="stor-empty">Loading…</div></div>'
|
||||
+'<div class="hint" style="margin-top:.5rem">Saved to '+pEsc(where)+'. Deleting here removes only the copy on this device — the file stays in the chat and can be downloaded again.</div></div>';
|
||||
document.body.appendChild(ov);
|
||||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||||
ov.querySelector('#storClose').onclick=()=>ov.remove();
|
||||
const render=async()=>{
|
||||
const body=ov.querySelector('#storBody'); if(!body) return;
|
||||
const items=await bzLibList(); // verifies each file still exists first
|
||||
bzSyncVideoTiles(); // anything deleted outside the app goes back to streaming
|
||||
if(!items.length){ body.innerHTML='<div class="stor-empty">Nothing downloaded yet.<br>Downloads appear here and in '+pEsc(where)+'.</div>'; return; }
|
||||
const groups={ Images:[], Videos:[], Files:[] };
|
||||
items.forEach(i=>{ (groups[bzLibFolder(i.mime)]||groups.Files).push(i); });
|
||||
const total=items.reduce((s,i)=>s+(i.size||0),0);
|
||||
let h='<div class="stor-tot"><b>'+fmtSize(total)+'</b><span style="color:var(--muted);font-size:.8rem">in '+items.length+' item'+(items.length===1?'':'s')+'</span>'
|
||||
+'<button class="btn sm" id="storAll" style="margin-left:auto">Delete all</button></div><div class="stor-list">';
|
||||
for(const g of ['Images','Videos','Files']){
|
||||
const arr=groups[g]; if(!arr.length) continue;
|
||||
h+='<div class="stor-grp">'+g+' · '+fmtSize(arr.reduce((s,i)=>s+(i.size||0),0))+'</div>';
|
||||
for(const it of arr){
|
||||
const src=bzLibSrc(it);
|
||||
const lead=(/^image\//.test(it.mime||'')&&src) ? '<img src="'+pEsc(src)+'" alt="">'
|
||||
: ic(/^video\//.test(it.mime||'')?'video':(/^audio\//.test(it.mime||'')?'headphones':'file'),16);
|
||||
h+='<div class="stor-row" data-id="'+pEsc(it.id)+'"><span class="sr-ic">'+lead+'</span>'
|
||||
+'<span class="sr-m"><span class="sr-n">'+pEsc(it.name)+'</span><span class="sr-s">'+fmtSize(it.size)+'</span></span>'
|
||||
+'<button class="sr-b share" title="Share or save elsewhere">'+ic('link',16)+'</button>'
|
||||
+'<button class="sr-b del" title="Delete from this device">'+ic('trash',16)+'</button></div>';
|
||||
}
|
||||
}
|
||||
body.innerHTML=h+'</div>';
|
||||
const all=body.querySelector('#storAll');
|
||||
if(all) all.onclick=async()=>{
|
||||
if(!confirm('Delete all '+items.length+' downloaded item'+(items.length===1?'':'s')+' from this device?\n\nThey stay in your chats and can be downloaded again.')) return;
|
||||
for(const it of items) await bzLibDelete(it.id);
|
||||
bzSyncVideoTiles(); render(); toast('Downloads cleared');
|
||||
};
|
||||
body.querySelectorAll('.stor-row').forEach(row=>{
|
||||
const id=row.getAttribute('data-id');
|
||||
const del=row.querySelector('.del');
|
||||
if(del) del.onclick=async()=>{ await bzLibDelete(id); bzSyncVideoTiles(); render(); };
|
||||
const sh=row.querySelector('.share');
|
||||
if(sh) sh.onclick=async()=>{ // hand the local file to the OS sheet (Save to Photos, etc.)
|
||||
const r=bzLibGet(id), S=window.Capacitor&&window.Capacitor.Plugins&&window.Capacitor.Plugins.Share;
|
||||
if(!r||!S) return toast('Sharing needs the latest app update.');
|
||||
try{ await S.share({ url:r.uri, title:r.name }); }catch(_){}
|
||||
};
|
||||
});
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------- Chat (1:1 + groups) ----------
|
||||
@@ -2023,7 +2106,19 @@ function bubbleHTML(m){
|
||||
const A = m.attachment;
|
||||
const att = A ? (
|
||||
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
|
||||
: A.isVideo ? '<div class="att-vid"><video class="att-vid-v" src="/stream/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="none" title="'+pEsc(A.name)+'"></video><span class="att-vid-spin" aria-hidden="true"></span></div>'
|
||||
: A.isVideo ? (function(){
|
||||
// A video has no download button of its own the way an image or file link does, so once it is
|
||||
// saved it would keep re-streaming on every play. If we have a local copy, play THAT — no network
|
||||
// at all — and swap the download control for a saved tick.
|
||||
const rec=bzLibOn()?bzLibGet(A.id):null, loc=rec?bzLibSrc(rec):null;
|
||||
return '<div class="att-vid'+(loc?' saved':'')+'" data-aid="'+pEsc(A.id)+'" data-name="'+pEsc(A.name)+'" data-mime="'+pEsc(A.mime||'video/mp4')+'">'
|
||||
+'<video class="att-vid-v" src="'+pEsc(loc||('/stream/'+A.id+'#t=0.1'))+'" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="none" title="'+pEsc(A.name)+'"></video>'
|
||||
+'<span class="att-vid-spin" aria-hidden="true"></span>'
|
||||
+(!bzLibOn() ? ''
|
||||
: loc ? '<span class="att-vid-saved" title="Saved on this device">'+ic('check',14)+'</span>'
|
||||
: '<button class="att-vid-dl" title="Download to this device">'+ic('download',16)+'</button>')
|
||||
+'</div>';
|
||||
})()
|
||||
: '<a class="att-file" href="/files/'+pEsc(A.id)+'" download="'+pEsc(A.name)+'">'+ic('file',15)+' <span>'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span></a>') : '';
|
||||
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
|
||||
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
|
||||
@@ -2663,15 +2758,96 @@ function bzDlSet(el,pct){
|
||||
}
|
||||
function bzDlHide(el){ if(el&&el.parentNode) el.parentNode.removeChild(el); }
|
||||
function bzB64(u8){ let s=''; const CH=0x8000; for(let i=0;i<u8.length;i+=CH) s+=String.fromCharCode.apply(null,u8.subarray(i,i+CH)); return btoa(s); }
|
||||
async function nativeSaveFile(url, name){
|
||||
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem, Sh=P&&P.Share;
|
||||
if(!Fs||!Sh) return false;
|
||||
const fname=(name||url.split('/').pop()||'file').replace(/[^\w.\- ]+/g,'_');
|
||||
|
||||
// ---------- Local library (native app only) ----------
|
||||
// Downloads land in the app's own Documents folder, which iOS exposes in the Files app as
|
||||
// Files → On My iPhone → Biz Connect → Images | Videos | Files
|
||||
// (turned on by UIFileSharingEnabled in mobile/scripts/ios-patch.sh). We keep a small index of what
|
||||
// was saved: that is what lets an already-downloaded video play from disk instead of streaming again,
|
||||
// and what powers Manage storage. The index is only a CACHE of the filesystem — the user can delete
|
||||
// anything from the Files app behind our back, so every read is reconciled by bzLibVerify.
|
||||
const BZ_LIB_KEY='bzc.lib.v1';
|
||||
const BZ_DIR='DOCUMENTS';
|
||||
function bzLibOn(){ const C=window.Capacitor; if(!C) return false; const p=C.getPlatform?C.getPlatform():''; return (p==='ios'||p==='android') && !!(C.Plugins&&C.Plugins.Filesystem); }
|
||||
function bzLibAll(){ try{ return JSON.parse(localStorage.getItem(BZ_LIB_KEY)||'{}')||{}; }catch(_){ return {}; } }
|
||||
function bzLibWrite(m){ try{ localStorage.setItem(BZ_LIB_KEY, JSON.stringify(m)); }catch(_){} }
|
||||
function bzLibGet(id){ return bzLibAll()[id]||null; }
|
||||
function bzLibPut(id,rec){ const m=bzLibAll(); m[id]=rec; bzLibWrite(m); }
|
||||
function bzLibDrop(id){ const m=bzLibAll(); delete m[id]; bzLibWrite(m); }
|
||||
function bzLibFolder(mime){ return /^image\//.test(mime||'') ? 'Images' : /^video\//.test(mime||'') ? 'Videos' : 'Files'; }
|
||||
function bzMimeFromName(n){
|
||||
const e=String(n||'').toLowerCase().split('.').pop();
|
||||
if(/^(png|jpg|jpeg|gif|webp|heic|heif|bmp|svg)$/.test(e)) return 'image/'+(e==='jpg'?'jpeg':e);
|
||||
if(/^(mp4|mov|m4v|webm|3gp|mkv|avi)$/.test(e)) return 'video/'+(e==='mov'?'quicktime':e);
|
||||
if(/^(mp3|wav|m4a|aac|ogg|opus)$/.test(e)) return 'audio/'+e;
|
||||
return '';
|
||||
}
|
||||
function bzLibSrc(r){ try{ return r&&r.uri ? window.Capacitor.convertFileSrc(r.uri) : null; }catch(_){ return null; } }
|
||||
// Confirm the file is still on disk. Returns null (and forgets it) if the user deleted it in Files.
|
||||
async function bzLibVerify(id){
|
||||
const r=bzLibGet(id); if(!r) return null;
|
||||
try{
|
||||
const st=await window.Capacitor.Plugins.Filesystem.stat({ path:r.path, directory:BZ_DIR });
|
||||
if(st && st.size!=null && st.size!==r.size){ r.size=st.size; bzLibPut(id,r); }
|
||||
return r;
|
||||
}catch(_){ bzLibDrop(id); return null; }
|
||||
}
|
||||
async function bzLibDelete(id){
|
||||
const r=bzLibGet(id); if(!r) return;
|
||||
try{ await window.Capacitor.Plugins.Filesystem.deleteFile({ path:r.path, directory:BZ_DIR }); }catch(_){}
|
||||
bzLibDrop(id);
|
||||
}
|
||||
async function bzLibList(){
|
||||
const out=[];
|
||||
for(const id of Object.keys(bzLibAll())){ const r=await bzLibVerify(id); if(r) out.push(Object.assign({ id }, r)); }
|
||||
out.sort((a,b)=>(b.at||0)-(a.at||0));
|
||||
return out;
|
||||
}
|
||||
// Pick a human filename inside the right subfolder, never colliding with something already saved.
|
||||
function bzLibPath(name, mime, id){
|
||||
const folder=bzLibFolder(mime);
|
||||
const safe=(String(name||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_').trim().slice(0,120))||'file';
|
||||
const dot=safe.lastIndexOf('.'); const stem=dot>0?safe.slice(0,dot):safe; const ext=dot>0?safe.slice(dot):'';
|
||||
const taken={}; const all=bzLibAll();
|
||||
for(const k of Object.keys(all)) if(k!==id && all[k] && all[k].path) taken[all[k].path]=1;
|
||||
let p=folder+'/'+safe, i=2;
|
||||
while(taken[p]){ p=folder+'/'+stem+' ('+i+')'+ext; i++; }
|
||||
return p;
|
||||
}
|
||||
// Re-sync the video tiles already on screen with the library, in BOTH directions — a fresh download
|
||||
// swaps the tile to the local file, and deleting it in Manage storage swaps it back to streaming. Done
|
||||
// in place rather than by re-rendering the thread, which would lose scroll position.
|
||||
function bzSyncVideoTiles(only){
|
||||
if(!bzLibOn()) return;
|
||||
document.querySelectorAll('.att-vid[data-aid]').forEach(w=>{
|
||||
const id=w.getAttribute('data-aid'); if(only && id!==only) return;
|
||||
const v=w.querySelector('.att-vid-v'); if(!v) return;
|
||||
const rec=bzLibGet(id), loc=rec?bzLibSrc(rec):null;
|
||||
const want=loc||('/stream/'+id+'#t=0.1');
|
||||
if(v.getAttribute('src')!==want){ try{ if(!v.paused) v.pause(); }catch(_){} v.setAttribute('src',want); }
|
||||
w.classList.toggle('saved', !!loc);
|
||||
const btn=w.querySelector('.att-vid-dl'), tick=w.querySelector('.att-vid-saved');
|
||||
if(loc){
|
||||
if(btn) btn.remove();
|
||||
if(!tick){ const s=document.createElement('span'); s.className='att-vid-saved'; s.title='Saved on this device'; s.innerHTML=ic('check',14); w.appendChild(s); }
|
||||
} else {
|
||||
if(tick) tick.remove();
|
||||
if(!btn){ const b=document.createElement('button'); b.className='att-vid-dl'; b.title='Download to this device'; b.innerHTML=ic('download',16); w.appendChild(b); }
|
||||
}
|
||||
});
|
||||
}
|
||||
async function nativeSaveFile(url, name, meta){
|
||||
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem;
|
||||
if(!Fs) return false;
|
||||
const id=(meta&&meta.id) || (String(url).split('?')[0].split('/').pop()||'');
|
||||
const fname=(String(name||url.split('/').pop()||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_')).slice(0,120)||'file';
|
||||
const mime=(meta&&meta.mime)||bzMimeFromName(fname);
|
||||
const box=bzDlShow(fname);
|
||||
const lpath=bzLibPath(fname, mime, id);
|
||||
try{
|
||||
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
|
||||
const total=+(res.headers.get('Content-Length')||0);
|
||||
let uri=null;
|
||||
let uri=null, wrote=0;
|
||||
if(res.body && res.body.getReader){
|
||||
// Stream to disk instead of buffering. The old path held blob + base64 at once — ~250 MB peak for a
|
||||
// 75 MB video, enough to get the WebView killed on a phone. Flush in 3-byte-aligned blocks so each
|
||||
@@ -2680,9 +2856,10 @@ async function nativeSaveFile(url, name){
|
||||
let pend=[], pendLen=0, got=0, first=true, shown=-1;
|
||||
const flush=async(u8)=>{
|
||||
const data=bzB64(u8);
|
||||
const w= first ? await Fs.writeFile({ path:fname, data, directory:'CACHE' })
|
||||
: await Fs.appendFile({ path:fname, data, directory:'CACHE' });
|
||||
first=false; if(w&&w.uri) uri=w.uri;
|
||||
// recursive:true creates Documents/<Images|Videos|Files> on first use.
|
||||
const w= first ? await Fs.writeFile({ path:lpath, data, directory:BZ_DIR, recursive:true })
|
||||
: await Fs.appendFile({ path:lpath, data, directory:BZ_DIR });
|
||||
first=false; wrote+=u8.length; if(w&&w.uri) uri=w.uri;
|
||||
};
|
||||
for(;;){
|
||||
const r=await reader.read(); if(r.done) break;
|
||||
@@ -2697,14 +2874,17 @@ async function nativeSaveFile(url, name){
|
||||
if(pct!==shown){ shown=pct; bzDlSet(box, pct); }
|
||||
}
|
||||
if(pendLen||first){ const all=new Uint8Array(pendLen); let o=0; for(const c of pend){ all.set(c,o); o+=c.length; } await flush(all); }
|
||||
if(!uri){ try{ const g=await Fs.getUri({ path:fname, directory:'CACHE' }); uri=g&&g.uri; }catch(_){} }
|
||||
if(!uri){ try{ const g=await Fs.getUri({ path:lpath, directory:BZ_DIR }); uri=g&&g.uri; }catch(_){} }
|
||||
} else { // no streams (older WebView) — original one-shot path
|
||||
const blob=await res.blob();
|
||||
const b64=await new Promise((ok,no)=>{ const r=new FileReader(); r.onloadend=()=>ok(String(r.result).split(',')[1]||''); r.onerror=no; r.readAsDataURL(blob); });
|
||||
const w=await Fs.writeFile({ path:fname, data:b64, directory:'CACHE' }); uri=w&&w.uri;
|
||||
const w=await Fs.writeFile({ path:lpath, data:b64, directory:BZ_DIR, recursive:true });
|
||||
uri=w&&w.uri; wrote=blob.size;
|
||||
}
|
||||
bzDlSet(box,100);
|
||||
await Sh.share({ url:uri, title:fname }); // iOS sheet: Save to Files / Save Image / share
|
||||
if(id) bzLibPut(id, { path:lpath, uri:uri||'', name:fname, mime, size:wrote||total||0, at:Date.now() });
|
||||
if(id) bzSyncVideoTiles(id);
|
||||
toast('Saved to Biz Connect · '+bzLibFolder(mime));
|
||||
}catch(e){ toast("Couldn't save the file"); }
|
||||
finally{ bzDlHide(box); }
|
||||
return true; // handled either way — never fall through to navigation
|
||||
@@ -2715,7 +2895,8 @@ async function nativeSaveFile(url, name){
|
||||
var href=a.getAttribute('href'); if(!href) return;
|
||||
e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
|
||||
var P=C.Plugins;
|
||||
if(P&&P.Filesystem&&P.Share){ nativeSaveFile(href, a.getAttribute('download')||''); return; } // real inline save
|
||||
// Real inline save into the app's Files folder (id comes from /files/<id>; mime refines the subfolder).
|
||||
if(P&&P.Filesystem){ nativeSaveFile(href, a.getAttribute('download')||'', { mime:a.getAttribute('data-mime')||'' }); return; }
|
||||
var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox');
|
||||
toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.');
|
||||
}, true); // capture phase so it wins over the lightbox / bubble handlers
|
||||
@@ -5121,6 +5302,32 @@ window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(
|
||||
'suspend','progress','timeupdate','seeking','seeked','pause','ended','error','emptied']
|
||||
.forEach(ev=>document.addEventListener(ev,onEv,true)); // media events don't bubble → capture phase
|
||||
})();
|
||||
// Download a video to this device. Images and files have their own download link; a video's tile is the
|
||||
// player, so the control is an overlay on it. Capture phase so it never reaches the video's own controls.
|
||||
(function(){
|
||||
document.addEventListener('click', function(e){
|
||||
const b=e.target&&e.target.closest&&e.target.closest('.att-vid-dl'); if(!b) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const w=b.closest('.att-vid'); if(!w || b.dataset.busy) return;
|
||||
const id=w.getAttribute('data-aid'); if(!id) return;
|
||||
b.dataset.busy='1';
|
||||
Promise.resolve(nativeSaveFile('/files/'+encodeURIComponent(id), w.getAttribute('data-name')||'video.mp4',
|
||||
{ id, mime:w.getAttribute('data-mime')||'video/mp4' }))
|
||||
.catch(()=>{}).then(()=>{ if(b) delete b.dataset.busy; });
|
||||
}, true);
|
||||
if(!bzLibOn()) return;
|
||||
// The user can delete these from the Files app at any time, so the index is never trusted blindly:
|
||||
// reconcile once at startup, and if a local file has vanished by the time it is played, fall straight
|
||||
// back to streaming instead of showing a broken player.
|
||||
setTimeout(()=>{ bzLibList().then(()=>bzSyncVideoTiles()).catch(()=>{}); }, 2500);
|
||||
document.addEventListener('error', function(e){
|
||||
const v=e.target; if(!v||!v.classList||!v.classList.contains('att-vid-v')) return;
|
||||
const w=v.parentElement; if(!w||!w.getAttribute) return;
|
||||
const id=w.getAttribute('data-aid'); if(!id) return;
|
||||
if((v.getAttribute('src')||'').indexOf('/stream/')>=0) return; // already streaming — nothing to recover to
|
||||
bzLibDrop(id); bzSyncVideoTiles(id);
|
||||
}, true);
|
||||
})();
|
||||
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble
|
||||
// follows the finger a little; releasing past the threshold opens the reply composer for that message.
|
||||
(function(){
|
||||
|
||||
Reference in New Issue
Block a user