Chat files: native download→open control; diagnose Photos-save failures
3. File attachments on native now work like videos: a download icon (with %), tap to download into the app's Files folder, then the icon becomes an "open" (external-link) control — tap again to open the file via the iOS share/preview sheet (@capacitor/share; Quick Look / open-in). State tracked in the local library (bzSyncFileTiles), reconciled at startup. Web/PWA keeps the plain <a download> link. Added an externalLink icon. 2. Photos save: bzSaveToPhotos now returns a reason; the toast tells the user to allow Photos access in Settings when it's permission-denied (the likely cause after repeated reinstall testing), and pdbg logs the reason otherwise (noplugin vs error) so we can pinpoint it. media-library plugin Swift is unchanged/correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+67
-9
@@ -661,6 +661,10 @@
|
||||
.convo-msgs img,.bubble img{max-height:340px;max-width:100%;height:auto;}
|
||||
.att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;}
|
||||
.att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.att-file-native{cursor:pointer;-webkit-user-select:none;user-select:none;}
|
||||
.att-file-name{flex:1;min-width:0;}
|
||||
.att-file-act{margin-left:.15rem;flex:0 0 auto;display:inline-flex;align-items:center;opacity:.75;font-variant-numeric:tabular-nums;}
|
||||
.att-file.saved .att-file-act{opacity:.95;}
|
||||
/* In-chat video: native <video controls> (poster from /thumbs, plays inline on one tap, HTTP-Range streamed).
|
||||
Wrapped so we can overlay a clear CENTER spinner while it buffers — the OS controls' own buffering hint is
|
||||
small/unclear. The spinner is display-only (pointer-events:none) so it never blocks the controls. */
|
||||
@@ -2127,7 +2131,17 @@ function bubbleHTML(m){
|
||||
+'<button class="att-vid-ov" title="'+(loc?'Play':'Download to this device')+'"><span class="att-vid-btn">'+ic(loc?'play':'download',26)+'</span></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>') : '';
|
||||
: (function(){
|
||||
// Web/PWA: a normal download link — the browser saves + opens it.
|
||||
if(!bzLibOn()) return '<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>';
|
||||
// Native app: like a video — a download control that becomes "open" once the file is on the device.
|
||||
// Tap to download (icon shows %), then tap again to open it. State is driven by the local library.
|
||||
const saved=!!bzLibGet(A.id);
|
||||
return '<div class="att-file att-file-native'+(saved?' saved':'')+'" data-aid="'+pEsc(A.id)+'" data-name="'+pEsc(A.name)+'" data-mime="'+pEsc(A.mime||'')+'" title="'+(saved?'Open':'Download to this device')+'">'
|
||||
+ic('file',15)+' <span class="att-file-name">'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span>'
|
||||
+'<span class="att-file-act">'+ic(saved?'externalLink':'download',16)+'</span>'
|
||||
+'</div>';
|
||||
})()) : '';
|
||||
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).
|
||||
let rcpt='';
|
||||
@@ -2803,13 +2817,13 @@ function bzPhotosOn(){ try{ return localStorage.getItem('bzc.photos')!=='off'; }
|
||||
// Best-effort throughout: a denied permission or an older app build must never fail a download that has
|
||||
// already landed safely in the app folder.
|
||||
async function bzSaveToPhotos(uri, mime){
|
||||
if(!uri || !bzPhotosOn()) return false;
|
||||
if(!uri || !bzPhotosOn()) return {ok:false, reason:'off'};
|
||||
const isVid=/^video\//.test(mime||'');
|
||||
if(!isVid && !/^image\//.test(mime||'')) return false; // documents don't belong in Photos
|
||||
if(!isVid && !/^image\//.test(mime||'')) return {ok:false, reason:'notmedia'}; // documents don't belong in Photos
|
||||
const ML=window.Capacitor&&window.Capacitor.Plugins&&window.Capacitor.Plugins.MediaLibrary;
|
||||
if(!ML||!ML.saveToAlbum) return false; // app build predates the plugin
|
||||
try{ await ML.saveToAlbum({ path:uri, album:'Connect', kind:isVid?'video':'photo' }); return true; }
|
||||
catch(e){ return false; }
|
||||
if(!ML||!ML.saveToAlbum) return {ok:false, reason:'noplugin'}; // plugin not loaded (SPM wiring)
|
||||
try{ await ML.saveToAlbum({ path:uri, album:'Connect', kind:isVid?'video':'photo' }); return {ok:true}; }
|
||||
catch(e){ const err=String((e&&e.message)||e); return {ok:false, reason:/den|permission|auth/i.test(err)?'denied':'error', err}; }
|
||||
}
|
||||
// Confirm the file is still on disk. Returns null (and forgets it) if the user deleted it in Files.
|
||||
async function bzLibVerify(id){
|
||||
@@ -2863,6 +2877,26 @@ function bzSyncVideoTiles(only){
|
||||
}
|
||||
});
|
||||
}
|
||||
// Files (native): mirror bzSyncVideoTiles — flip a file tile between "download" and "open" as the local copy
|
||||
// appears/disappears. Done in place so the open thread doesn't re-render (scroll position kept).
|
||||
function bzSyncFileTiles(only){
|
||||
if(!bzLibOn()) return;
|
||||
document.querySelectorAll('.att-file-native[data-aid]').forEach(el=>{
|
||||
const id=el.getAttribute('data-aid'); if(only && id!==only) return;
|
||||
const saved=!!bzLibGet(id);
|
||||
el.classList.toggle('saved', saved);
|
||||
const act=el.querySelector('.att-file-act'); if(act) act.innerHTML=ic(saved?'externalLink':'download',16);
|
||||
el.title = saved ? 'Open' : 'Download to this device';
|
||||
});
|
||||
}
|
||||
// Open an already-downloaded file via the iOS share/preview sheet (Quick Look / open in another app). We have
|
||||
// no dedicated file-opener plugin, so @capacitor/share on the local file URI is the reliable "open".
|
||||
async function bzOpenFile(rec, name){
|
||||
const uri=rec&&rec.uri; if(!uri) return;
|
||||
const P=window.Capacitor&&window.Capacitor.Plugins;
|
||||
if(P&&P.Share){ try{ await P.Share.share({ title:name||'File', url:uri }); }catch(_){} return; } // catch = user cancelled
|
||||
try{ const src=bzLibSrc(rec); if(src) window.open(src, '_blank'); }catch(_){}
|
||||
}
|
||||
async function nativeSaveFile(url, name, meta){
|
||||
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem;
|
||||
if(!Fs) return false;
|
||||
@@ -2912,8 +2946,12 @@ async function nativeSaveFile(url, name, meta){
|
||||
bzDlSet(box,100); if(onPct) onPct(100);
|
||||
if(id) bzLibPut(id, { path:lpath, uri:uri||'', name:fname, mime, size:wrote||total||0, at:Date.now() });
|
||||
if(id) bzSyncVideoTiles(id);
|
||||
const inPhotos=await bzSaveToPhotos(uri, mime);
|
||||
toast(inPhotos ? 'Saved to Photos · Connect album' : ('Saved to Biz Connect · '+bzLibFolder(mime)));
|
||||
const ph=await bzSaveToPhotos(uri, mime);
|
||||
const isMedia=/^(image|video)\//.test(mime||'');
|
||||
if(ph.ok) toast('Saved to Photos · Connect album');
|
||||
else if(isMedia && ph.reason==='denied') toast('Saved to Files. Allow Photos access in Settings to also save to your gallery.');
|
||||
else toast('Saved to Biz Connect · '+bzLibFolder(mime));
|
||||
if(isMedia && !ph.ok && ph.reason!=='off'){ try{ pdbg('photos-fail', { reason:ph.reason, err:ph.err||'' }); }catch(_){} } // telemetry: why Photos save failed
|
||||
}catch(e){ toast("Couldn't save the file"); }
|
||||
finally{ bzDlHide(box); }
|
||||
return true; // handled either way — never fall through to navigation
|
||||
@@ -5613,7 +5651,7 @@ window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(
|
||||
// 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);
|
||||
setTimeout(()=>{ bzLibList().then(()=>{ bzSyncVideoTiles(); bzSyncFileTiles(); }).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;
|
||||
@@ -5622,6 +5660,26 @@ window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(
|
||||
bzLibDrop(id); bzSyncVideoTiles(id);
|
||||
}, true);
|
||||
})();
|
||||
// Native file attachments: tap to download (the trailing icon shows %), tap again to open. Mirrors the video
|
||||
// control. Capture phase so it wins over the generic a[download] handler / bubble clicks.
|
||||
(function(){
|
||||
document.addEventListener('click', function(e){
|
||||
const el=e.target&&e.target.closest&&e.target.closest('.att-file-native'); if(!el) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if(el.dataset.busy) return;
|
||||
const id=el.getAttribute('data-aid'); if(!id) return;
|
||||
const name=el.getAttribute('data-name')||'file', mime=el.getAttribute('data-mime')||'';
|
||||
const rec=bzLibGet(id);
|
||||
if(rec){ bzOpenFile(rec, name); return; } // already on device → open it
|
||||
el.dataset.busy='1';
|
||||
const act=el.querySelector('.att-file-act'); const orig=act?act.innerHTML:'';
|
||||
const setPct=(p)=>{ if(act) act.textContent = p<0?'…':(p+'%'); };
|
||||
setPct(0);
|
||||
Promise.resolve(nativeSaveFile('/files/'+encodeURIComponent(id), name, { id, mime, onPct:setPct }))
|
||||
.catch(()=>{})
|
||||
.then(()=>{ delete el.dataset.busy; if(!bzLibGet(id)&&act) act.innerHTML=orig; bzSyncFileTiles(id); }); // saved → "open", else restore download
|
||||
}, 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(){
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
|
||||
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
|
||||
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||
externalLink:'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/>',
|
||||
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
|
||||
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
|
||||
Reference in New Issue
Block a user