feat(share): "Biz Connect" in the iOS share sheet — share a photo/file into a chat
Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect conversation. An app can only appear in the iOS share sheet as an app-extension target, so this is real native work, not a web change. Pieces: - mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages the shared items into the App Group container and opens bizconnect://share. It deliberately does NOT reimplement the chat picker — that lives in the app, which already has the chat list, search and upload progress. Appends to the manifest (never overwrites), so sharing twice before opening the app loses nothing. - mobile/scripts/add-share-extension.rb: injects the extension target into the Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh), using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id <app>.share, and MERGES the App Group into the app's entitlements rather than clobbering them (push's aps-environment must survive). Idempotent. - mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App Group container isn't one of Filesystem's known directories, so it needs a bridge. - home.html: on bizconnect://share (and every resume, and cold-launch), read the inbox and show a "Send to…" picker over the chat list; chosen files run the SAME upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read the staged bytes with no base64 marshalling. - ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile for the .share bundle id too. One-time manual gate (CI cannot toggle App capabilities): the App Group group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see each other's files and sharing silently no-ops; everything else still works. Validated cross-file: pod-name/jsName/method wiring for all three plugins, App Group id identical in all 4 files, URL scheme consistent across extension/plist/web, entitlement-merge preserves push. Needs a new iOS build (new targets + plugins). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+112
-1
@@ -1197,7 +1197,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<script src="/icons.js?v=6"></script>
|
||||
<script>window.__BUILD='2026-07-23-batch165';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-23-batch166';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
|
||||
@@ -3521,6 +3521,114 @@ async function sendMessage(){
|
||||
composeMentions=new Map();
|
||||
renderChats(searchVal());
|
||||
}
|
||||
// ---------- Incoming share (iOS share sheet → Biz Connect) ----------
|
||||
// The Share Extension stages files into the App Group and opens bizconnect://share; here we read them and
|
||||
// let the user pick a conversation to send them to. Reuses the normal upload + /api/messages send, so a
|
||||
// shared file behaves exactly like one attached in-app. Best-effort: if the plugin isn't present (older
|
||||
// build) or there's nothing pending, this is a no-op.
|
||||
let _shareBusy=false;
|
||||
function bzShareInbox(){ const P=window.Capacitor&&window.Capacitor.Plugins; return P&&P.ShareInbox||null; }
|
||||
async function bzCheckSharedInbox(){
|
||||
const SI=bzShareInbox(); if(!SI||_shareBusy) return;
|
||||
if(!ME||!ME.id) return; // must be signed in to choose a conversation
|
||||
let items=[];
|
||||
try{ const r=await SI.getPending(); items=(r&&r.items)||[]; }catch(_){ return; }
|
||||
if(!items.length) return;
|
||||
_shareBusy=true;
|
||||
openSharePicker(items);
|
||||
}
|
||||
// Upload one Blob/File through the same endpoint as an in-app attachment; resolves to the attachment meta.
|
||||
function bzUploadBlob(file, onPct){
|
||||
return new Promise((resolve,reject)=>{
|
||||
const xhr=new XMLHttpRequest();
|
||||
xhr.open('POST','/api/messages/upload',true);
|
||||
xhr.setRequestHeader('Content-Type', file.type||'application/octet-stream');
|
||||
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name||'file'));
|
||||
xhr.upload.onprogress=(e)=>{ if(e.lengthComputable&&onPct) onPct(Math.round(e.loaded/e.total*100)); };
|
||||
xhr.onload=()=>{ let d={}; try{ d=JSON.parse(xhr.responseText||'{}'); }catch(_){}
|
||||
if(xhr.status>=200&&xhr.status<300) resolve(d); else reject(new Error(d.error||'Upload failed')); };
|
||||
xhr.onerror=()=>reject(new Error('Upload failed'));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
async function bzShareItemToFile(it){
|
||||
// Pull the staged file's bytes into a File the upload endpoint accepts. convertFileSrc turns the
|
||||
// App Group file:// URI into something the WebView can fetch.
|
||||
const src=window.Capacitor.convertFileSrc(it.uri);
|
||||
const res=await fetch(src); if(!res.ok) throw new Error('read failed');
|
||||
const blob=await res.blob();
|
||||
return new File([blob], it.name||'file', { type: it.mime||blob.type||'application/octet-stream' });
|
||||
}
|
||||
function openSharePicker(items){
|
||||
const files=items.filter(i=>i.kind==='file');
|
||||
const texts=items.filter(i=>i.kind==='text').map(i=>i.text).filter(Boolean);
|
||||
const done=()=>{ const SI=bzShareInbox(); if(SI){ try{ SI.clear(); }catch(_){} } _shareBusy=false; };
|
||||
if(document.getElementById('shareModal')){ done(); return; }
|
||||
const rows=(ROWS||[]).slice(0,200);
|
||||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='shareModal';
|
||||
const summary = files.length
|
||||
? (files.length+' file'+(files.length>1?'s':'')+(texts.length?' + text':''))
|
||||
: (texts.length?'a link or text':'');
|
||||
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.5rem">'
|
||||
+'<div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('send',20)+'</div>'
|
||||
+'<div class="gi-name"><div class="gi-title">Send to…</div><div class="gi-sub">Sharing '+pEsc(summary)+'</div></div>'
|
||||
+'<button class="iconbtn" id="shareClose" title="Cancel">'+ic('x',18)+'</button></div>'
|
||||
+'<div class="gi-search"><input id="shareSearch" placeholder="Search chats…" autocomplete="off"></div>'
|
||||
+'<div class="stor-list" id="shareList"></div></div>';
|
||||
document.body.appendChild(ov);
|
||||
const close=()=>{ ov.remove(); done(); };
|
||||
ov.onclick=e=>{ if(e.target===ov) close(); };
|
||||
ov.querySelector('#shareClose').onclick=close;
|
||||
const listEl=ov.querySelector('#shareList');
|
||||
const paint=(q)=>{
|
||||
q=(q||'').toLowerCase().trim();
|
||||
const shown=rows.filter(r=>!q||String(r.name||'').toLowerCase().includes(q));
|
||||
listEl.innerHTML=shown.length?shown.map(r=>{
|
||||
const av='<span class="sr-ic">'+(r.kind==='group'?ic('users',16):initials(r.name))+'</span>';
|
||||
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-s">'+(r.kind==='group'?'Group':'Direct message')+'</span></span>'
|
||||
+ic('chevronRight',16)+'</div>';
|
||||
}).join(''):'<div class="stor-empty">No matching chats.</div>';
|
||||
listEl.querySelectorAll('.share-to').forEach(row=>{
|
||||
row.onclick=()=>sendSharedTo(row.getAttribute('data-kind'), row.getAttribute('data-id'), files, texts, ov, close);
|
||||
});
|
||||
};
|
||||
paint('');
|
||||
const s=ov.querySelector('#shareSearch'); if(s) s.oninput=()=>paint(s.value);
|
||||
}
|
||||
async function sendSharedTo(kind, id, files, texts, ov, close){
|
||||
if(ov.dataset.busy) return; ov.dataset.busy='1';
|
||||
const body=ov.querySelector('#shareList');
|
||||
const prog=document.createElement('div'); prog.className='stor-empty'; prog.textContent='Sending…';
|
||||
if(body){ body.innerHTML=''; body.appendChild(prog); }
|
||||
const post=(payload)=>postJSON('/api/messages', kind==='group'?Object.assign({group:id},payload):Object.assign({to:id},payload));
|
||||
try{
|
||||
let first=true;
|
||||
// Files first (each its own message), then any shared text/link as a final message.
|
||||
for(let i=0;i<files.length;i++){
|
||||
prog.textContent='Sending '+(i+1)+' of '+files.length+'…';
|
||||
const f=await bzShareItemToFile(files[i]);
|
||||
const meta=await bzUploadBlob(f, p=>{ prog.textContent='Uploading '+(i+1)+' of '+files.length+' · '+p+'%'; });
|
||||
await post({ body:(first&&texts.length&&files.length===1)?texts.join('\n'):'', attachmentId:meta.id, mentions:[] });
|
||||
first=false;
|
||||
}
|
||||
if(texts.length && !(files.length===1)){ await post({ body:texts.join('\n'), mentions:[] }); }
|
||||
close();
|
||||
toast('Shared to '+((rowFor(kind,id)||{}).name||'chat'));
|
||||
try{ await loadSidebar(); }catch(_){}
|
||||
selectChat(kind, id);
|
||||
}catch(e){ delete ov.dataset.busy; toast(e.message||'Could not send'); if(body) paintShareError(body, e); }
|
||||
}
|
||||
function paintShareError(body, e){ body.innerHTML='<div class="stor-empty">Couldn’t send. Please try again.</div>'; }
|
||||
// Trigger points: the extension opens bizconnect://share (foreground), and we also sweep on every
|
||||
// resume, in case iOS delivered the share while the app was backgrounded.
|
||||
(function(){
|
||||
const C=window.Capacitor; if(!C||!C.Plugins||!C.Plugins.App) return;
|
||||
const App=C.Plugins.App;
|
||||
try{ App.addListener('appUrlOpen', (d)=>{ if(d&&/^bizconnect:\/\/share/i.test(d.url||'')) setTimeout(bzCheckSharedInbox, 150); }); }catch(_){}
|
||||
try{ App.addListener('appStateChange', (s)=>{ if(s&&s.isActive) setTimeout(bzCheckSharedInbox, 300); }); }catch(_){}
|
||||
})();
|
||||
// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the
|
||||
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
|
||||
// won't work until a subscription exists.
|
||||
@@ -5530,6 +5638,9 @@ window.addEventListener('message',(e)=>{
|
||||
}
|
||||
// Signed-in user opened a meeting link → jump straight into that meeting.
|
||||
if(_meet && /^\d{6}$/.test(_meet)){ try{ history.replaceState(null,'','/home'); }catch(_){} switchTab('meeting'); enterMeeting(_meet); }
|
||||
// Cold launch FROM the share sheet: the extension staged files before the app was even running, so
|
||||
// the appUrlOpen listener may have missed it. Sweep once now that ME + the chat list are ready.
|
||||
setTimeout(bzCheckSharedInbox, 600);
|
||||
})();
|
||||
|
||||
// GUEST meeting: a lightweight pre-join (name) then join the call with a throwaway guest identity —
|
||||
|
||||
Reference in New Issue
Block a user