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:
2026-07-23 22:58:08 +05:30
parent 00ea140280
commit 0a2c7376b9
13 changed files with 632 additions and 1 deletions
+112 -1
View File
@@ -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">Couldnt 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 —