diff --git a/server/public/home.html b/server/public/home.html index 2afd7a9..d3c27ab 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 (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){ +''+ic(loc?'play':'download',26)+'' +''; })() - : ''+ic('file',15)+' '+pEsc(A.name)+' '+fmtSize(A.size)+'') : ''; + : (function(){ + // Web/PWA: a normal download link — the browser saves + opens it. + if(!bzLibOn()) return ''+ic('file',15)+' '+pEsc(A.name)+' '+fmtSize(A.size)+''; + // 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 '' + +ic('file',15)+' '+pEsc(A.name)+' '+fmtSize(A.size)+'' + +''+ic(saved?'externalLink':'download',16)+'' + +''; + })()) : ''; 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(){ diff --git a/server/public/icons.js b/server/public/icons.js index bf4ae74..2225ce2 100644 --- a/server/public/icons.js +++ b/server/public/icons.js @@ -63,6 +63,7 @@ calendarX: '', calendarClock:'', fileText: '', + externalLink:'', record: '', callEnd: '', settings: '',