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:
@@ -53,6 +53,14 @@ workflows:
|
|||||||
script: |
|
script: |
|
||||||
bash mobile/scripts/ios-patch.sh
|
bash mobile/scripts/ios-patch.sh
|
||||||
|
|
||||||
|
- name: Add the Share Extension target
|
||||||
|
script: |
|
||||||
|
# Inject the second target (Biz Connect in the iOS share sheet) into the freshly-generated
|
||||||
|
# Xcode project. Uses the `xcodeproj` gem that ships with CocoaPods, so no extra install.
|
||||||
|
# Runs BEFORE pod install: the extension uses no pods, and this way the workspace that pods
|
||||||
|
# generates already contains the new target.
|
||||||
|
ruby mobile/scripts/add-share-extension.rb
|
||||||
|
|
||||||
- name: Set up code signing
|
- name: Set up code signing
|
||||||
script: |
|
script: |
|
||||||
# Create the distribution certificate + provisioning profile from the ASC API key and add the
|
# Create the distribution certificate + provisioning profile from the ASC API key and add the
|
||||||
@@ -65,11 +73,21 @@ workflows:
|
|||||||
# cert it has no key for ("Cannot save Signing Certificates without certificate private key").
|
# cert it has no key for ("Cannot save Signing Certificates without certificate private key").
|
||||||
# By passing our own fixed private key (CERTIFICATE_PRIVATE_KEY, a secure var in the
|
# By passing our own fixed private key (CERTIFICATE_PRIVATE_KEY, a secure var in the
|
||||||
# `ios_signing` group), the cert is created once from that key and reused by every build.
|
# `ios_signing` group), the cert is created once from that key and reused by every build.
|
||||||
|
#
|
||||||
|
# TWO bundle ids now need signing: the app AND the share extension (<app>.share). Each gets its
|
||||||
|
# own App Store profile. The App Group capability (group.com.bizgaze.connect) must be enabled on
|
||||||
|
# BOTH App IDs in the Apple Developer portal — see mobile/IOS_SETUP.md. fetch-signing-files
|
||||||
|
# registers a missing bundle id and creates its profile, but does NOT toggle the App Group
|
||||||
|
# capability, so that stays a one-time manual step.
|
||||||
keychain initialize
|
keychain initialize
|
||||||
app-store-connect fetch-signing-files "$BUNDLE_ID" \
|
app-store-connect fetch-signing-files "$BUNDLE_ID" \
|
||||||
--type IOS_APP_STORE \
|
--type IOS_APP_STORE \
|
||||||
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
|
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
|
||||||
--create
|
--create
|
||||||
|
app-store-connect fetch-signing-files "${BUNDLE_ID}.share" \
|
||||||
|
--type IOS_APP_STORE \
|
||||||
|
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
|
||||||
|
--create
|
||||||
keychain add-certificates
|
keychain add-certificates
|
||||||
|
|
||||||
- name: Install CocoaPods
|
- name: Install CocoaPods
|
||||||
|
|||||||
@@ -89,3 +89,29 @@ listing yet — see the follow-up below (you can still *view* a screen someone e
|
|||||||
- Mobile **web** can't switch the audio output route (`setSinkId` is unimplemented on iOS/Android), so the
|
- Mobile **web** can't switch the audio output route (`setSinkId` is unimplemented on iOS/Android), so the
|
||||||
in-meeting speaker/earpiece/Bluetooth control is web-only where it works and hidden where it doesn't.
|
in-meeting speaker/earpiece/Bluetooth control is web-only where it works and hidden where it doesn't.
|
||||||
- True routing on iOS needs a small native plugin driving `AVAudioSession`. Phase-2 native task.
|
- True routing on iOS needs a small native plugin driving `AVAudioSession`. Phase-2 native task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Share Extension ("Biz Connect" in the iOS share sheet) — one-time Apple portal setup
|
||||||
|
|
||||||
|
The app now has a **Share Extension** target (`com.bizgaze.connect.share`) so users can share a photo /
|
||||||
|
video / file FROM the Photos or Files app INTO a Biz Connect conversation. The Codemagic build injects the
|
||||||
|
target and fetches a profile for it automatically, but two things can ONLY be done once, by hand, in the
|
||||||
|
Apple Developer portal — CI cannot toggle App capabilities:
|
||||||
|
|
||||||
|
1. **Create the App Group** (developer.apple.com → Identifiers → App Groups → +):
|
||||||
|
identifier **`group.com.bizgaze.connect`**.
|
||||||
|
2. **Enable the App Groups capability on BOTH App IDs** and assign them to that group:
|
||||||
|
- `com.bizgaze.connect` (the app)
|
||||||
|
- `com.bizgaze.connect.share` (the extension — create this App ID if the first build hasn't yet;
|
||||||
|
`fetch-signing-files --create` will register it, then edit it to add App Groups)
|
||||||
|
After enabling the capability, the provisioning profiles must be regenerated — the next Codemagic build
|
||||||
|
does that via `fetch-signing-files`, so just re-run it once the capability is on.
|
||||||
|
|
||||||
|
If the App Group isn't set up, the app and the extension can't see each other's files: sharing will appear
|
||||||
|
to do nothing (the extension stages the file, but the app finds an empty inbox). Everything else — download
|
||||||
|
to the Files folder, the Photos "Connect" album, Manage storage — works without it.
|
||||||
|
|
||||||
|
Also enabled by this change (main app Info.plist, done automatically by `ios-patch.sh`):
|
||||||
|
- `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` → the **Biz Connect** folder in Files.
|
||||||
|
- `CFBundleURLTypes` scheme **`bizconnect`** → lets the extension bounce back into the app after staging.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.bizgaze.connect</string>
|
||||||
|
</array>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>production</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Biz Connect</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<!-- No storyboard: the extension has no UI of its own (see ShareViewController). -->
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<!-- What Biz Connect offers to accept from the share sheet. Without a matching rule here the
|
||||||
|
app simply does not appear for that content type. -->
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||||
|
<integer>20</integer>
|
||||||
|
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||||
|
<integer>20</integer>
|
||||||
|
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NSExtensionActivationSupportsText</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.bizgaze.connect</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
// Share Extension — this is what puts "Biz Connect" in the iOS share sheet (Photos, Files, Safari, …).
|
||||||
|
// An app CANNOT appear there any other way: the share sheet only lists app extensions, so this has to be a
|
||||||
|
// second target inside the app bundle, with its own bundle id and provisioning profile.
|
||||||
|
//
|
||||||
|
// It deliberately has NO UI. Picking the conversation happens in the app, where the chat list, search and
|
||||||
|
// upload progress already exist — re-implementing all of that natively here would be a second, divergent
|
||||||
|
// copy of the app. So this extension only does the part that must happen inside it:
|
||||||
|
// 1. copy the shared items into the App Group container (the only storage both processes can see), and
|
||||||
|
// 2. bounce the user into the app, which picks them up and shows "Send to…".
|
||||||
|
// If step 2 is blocked, the files still sit staged and are collected the next time the app is opened —
|
||||||
|
// nothing is lost, the hand-off is just deferred.
|
||||||
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
|
private let appGroup = "group.com.bizgaze.connect"
|
||||||
|
private let urlScheme = "bizconnect"
|
||||||
|
private let maxItems = 20
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
handleShare()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ingest
|
||||||
|
|
||||||
|
private func handleShare() {
|
||||||
|
let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
|
||||||
|
.flatMap { $0.attachments ?? [] }
|
||||||
|
.prefix(maxItems)
|
||||||
|
guard !providers.isEmpty else { return finish() }
|
||||||
|
|
||||||
|
var staged: [[String: Any]] = []
|
||||||
|
let lock = NSLock()
|
||||||
|
let group = DispatchGroup()
|
||||||
|
|
||||||
|
for provider in providers {
|
||||||
|
group.enter()
|
||||||
|
load(provider) { record in
|
||||||
|
if let record = record { lock.lock(); staged.append(record); lock.unlock() }
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.notify(queue: .main) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if !staged.isEmpty { self.appendToManifest(staged) }
|
||||||
|
self.openHostApp()
|
||||||
|
self.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one attachment to a staged file (or a text/link record).
|
||||||
|
private func load(_ provider: NSItemProvider, completion: @escaping ([String: Any]?) -> Void) {
|
||||||
|
// Most specific first; .item is the catch-all for arbitrary documents.
|
||||||
|
let fileTypes: [UTType] = [.movie, .image, .audio, .pdf, .item]
|
||||||
|
if let type = fileTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) {
|
||||||
|
// loadFileRepresentation hands back a URL that is deleted the moment this closure returns,
|
||||||
|
// so the copy has to happen synchronously inside it.
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { [weak self] url, _ in
|
||||||
|
guard let self = self, let url = url else { return completion(nil) }
|
||||||
|
completion(self.stage(url))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
|
||||||
|
completion((item as? URL).map { ["kind": "text", "text": $0.absoluteString] })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in
|
||||||
|
completion((item as? String).map { ["kind": "text", "text": $0] })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stage(_ src: URL) -> [String: Any]? {
|
||||||
|
guard let dir = sharedDirectory() else { return nil }
|
||||||
|
let name = src.lastPathComponent.isEmpty ? "shared-file" : src.lastPathComponent
|
||||||
|
let dest = uniqueURL(in: dir, preferred: name)
|
||||||
|
do { try FileManager.default.copyItem(at: src, to: dest) } catch { return nil }
|
||||||
|
let size = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0
|
||||||
|
return [
|
||||||
|
"kind": "file",
|
||||||
|
"name": dest.lastPathComponent,
|
||||||
|
"path": dest.path,
|
||||||
|
"mime": Self.mimeType(for: dest),
|
||||||
|
"size": size ?? 0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App Group storage
|
||||||
|
|
||||||
|
private func sharedDirectory() -> URL? {
|
||||||
|
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil }
|
||||||
|
let dir = base.appendingPathComponent("Shared", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueURL(in dir: URL, preferred: String) -> URL {
|
||||||
|
let ext = (preferred as NSString).pathExtension
|
||||||
|
let stem = (preferred as NSString).deletingPathExtension
|
||||||
|
var candidate = dir.appendingPathComponent(preferred)
|
||||||
|
var i = 2
|
||||||
|
while FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
let next = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)"
|
||||||
|
candidate = dir.appendingPathComponent(next)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// APPEND, never overwrite: the user can share twice before opening the app, and the second share
|
||||||
|
/// must not discard the first.
|
||||||
|
private func appendToManifest(_ records: [[String: Any]]) {
|
||||||
|
guard let dir = sharedDirectory() else { return }
|
||||||
|
let manifest = dir.appendingPathComponent("manifest.json")
|
||||||
|
var all: [[String: Any]] = []
|
||||||
|
if let data = try? Data(contentsOf: manifest),
|
||||||
|
let existing = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
|
||||||
|
all = existing
|
||||||
|
}
|
||||||
|
all.append(contentsOf: records)
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: all) {
|
||||||
|
try? data.write(to: manifest, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hand-off
|
||||||
|
|
||||||
|
/// Share extensions have no public API to launch their container app, so walk the responder chain to
|
||||||
|
/// UIApplication and use openURL:. If it fails the files stay staged and the app collects them on next
|
||||||
|
/// launch, so this is an optimisation, never a requirement.
|
||||||
|
private func openHostApp() {
|
||||||
|
guard let url = URL(string: "\(urlScheme)://share") else { return }
|
||||||
|
let selector = NSSelectorFromString("openURL:")
|
||||||
|
var responder: UIResponder? = self
|
||||||
|
while let current = responder {
|
||||||
|
if current.responds(to: selector) && current is UIApplication {
|
||||||
|
_ = current.perform(selector, with: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
responder = current.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish() {
|
||||||
|
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mimeType(for url: URL) -> String {
|
||||||
|
if let type = UTType(filenameExtension: url.pathExtension.lowercased()),
|
||||||
|
let mime = type.preferredMIMEType {
|
||||||
|
return mime
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"audio-route": "file:plugins/audio-route",
|
"audio-route": "file:plugins/audio-route",
|
||||||
"media-library": "file:plugins/media-library",
|
"media-library": "file:plugins/media-library",
|
||||||
|
"share-inbox": "file:plugins/share-inbox",
|
||||||
"@capacitor-community/safe-area": "^7.0.0",
|
"@capacitor-community/safe-area": "^7.0.0",
|
||||||
"@capacitor/android": "^7.0.0",
|
"@capacitor/android": "^7.0.0",
|
||||||
"@capacitor/app": "^7.0.0",
|
"@capacitor/app": "^7.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
# Pod name MUST be 'ShareInbox' (PascalCase of 'share-inbox') — same rule as the other plugins, or
|
||||||
|
# pod install fails with "No podspec found for `ShareInbox`".
|
||||||
|
s.name = 'ShareInbox'
|
||||||
|
s.version = package['version']
|
||||||
|
s.summary = package['description']
|
||||||
|
s.license = package['license']
|
||||||
|
s.homepage = 'https://bizgaze.com'
|
||||||
|
s.author = 'BizGaze'
|
||||||
|
s.source = { :git => 'https://bizgaze.com/share-inbox.git', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
|
s.ios.deployment_target = '14.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
|
||||||
|
// Reads the files the Share Extension staged into the App Group container, so the web app can pick a
|
||||||
|
// conversation and send them. The extension and the app are separate processes; the App Group's shared
|
||||||
|
// container is the only place both can read/write, and it is NOT one of @capacitor/filesystem's known
|
||||||
|
// directories — hence this small bridge.
|
||||||
|
//
|
||||||
|
// getPending() → { items: [ {kind, name, path, uri, mime, size} | {kind:"text", text} ] }
|
||||||
|
// `uri` is a file:// URL the web layer turns into a fetchable source with Capacitor.convertFileSrc,
|
||||||
|
// so the existing upload path can read the bytes without base64 marshalling.
|
||||||
|
// clear() removes the manifest and every staged file, once the app has taken them.
|
||||||
|
@objc(ShareInboxPlugin)
|
||||||
|
public class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public let identifier = "ShareInboxPlugin"
|
||||||
|
public let jsName = "ShareInbox"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "getPending", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
private let appGroup = "group.com.bizgaze.connect"
|
||||||
|
|
||||||
|
@objc func getPending(_ call: CAPPluginCall) {
|
||||||
|
guard let dir = sharedDir() else { return call.resolve(["items": []]) }
|
||||||
|
let manifest = dir.appendingPathComponent("manifest.json")
|
||||||
|
guard let data = try? Data(contentsOf: manifest),
|
||||||
|
let records = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
||||||
|
return call.resolve(["items": []])
|
||||||
|
}
|
||||||
|
var items: [[String: Any]] = []
|
||||||
|
for r in records {
|
||||||
|
let kind = r["kind"] as? String ?? "file"
|
||||||
|
if kind == "text" {
|
||||||
|
if let text = r["text"] as? String { items.append(["kind": "text", "text": text]) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A file record is only usable if its staged copy is still on disk.
|
||||||
|
guard let path = r["path"] as? String,
|
||||||
|
FileManager.default.fileExists(atPath: path) else { continue }
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
items.append([
|
||||||
|
"kind": "file",
|
||||||
|
"name": r["name"] as? String ?? url.lastPathComponent,
|
||||||
|
"path": path,
|
||||||
|
"uri": url.absoluteString,
|
||||||
|
"mime": r["mime"] as? String ?? "application/octet-stream",
|
||||||
|
"size": r["size"] as? Int64 ?? (r["size"] as? Int ?? 0)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
call.resolve(["items": items])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func clear(_ call: CAPPluginCall) {
|
||||||
|
if let dir = sharedDir() {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
|
||||||
|
for e in entries { try? fm.removeItem(at: e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sharedDir() -> URL? {
|
||||||
|
guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil }
|
||||||
|
return base.appendingPathComponent("Shared", isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "share-inbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Read files handed to Biz Connect from the iOS share sheet (via the App Group)",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"author": "BizGaze",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/",
|
||||||
|
"ShareInbox.podspec"
|
||||||
|
],
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
#
|
||||||
|
# Inject the Share Extension target into the Capacitor-generated Xcode project.
|
||||||
|
#
|
||||||
|
# WHY A SCRIPT: `npx cap add ios` scaffolds mobile/ios/App from a template that knows nothing about our
|
||||||
|
# extension, and Codemagic runs on a fresh checkout every time, so the target has to be (re)created on each
|
||||||
|
# build. This uses the `xcodeproj` gem, which ships with CocoaPods (already installed for `pod install`),
|
||||||
|
# so there is no extra dependency to add.
|
||||||
|
#
|
||||||
|
# WHAT IT WIRES:
|
||||||
|
# * a new app-extension target "ShareExtension" (bundle id <app>.share) whose sources are our
|
||||||
|
# ShareViewController.swift + Info.plist, copied in from mobile/ios-share/
|
||||||
|
# * the App Group entitlement on BOTH the App target and the extension (the only storage both processes
|
||||||
|
# can see), via the two .entitlements files
|
||||||
|
# * the extension embedded into the app ("Embed App Extensions" phase) and set as a build dependency
|
||||||
|
#
|
||||||
|
# Idempotent: if the target already exists it is removed and rebuilt, so re-runs never duplicate it.
|
||||||
|
|
||||||
|
require 'xcodeproj'
|
||||||
|
require 'fileutils'
|
||||||
|
|
||||||
|
ROOT = File.expand_path('../..', __dir__) # repo/mobile
|
||||||
|
PROJECT = File.join(ROOT, 'ios', 'App', 'App.xcodeproj')
|
||||||
|
SRC_DIR = File.join(ROOT, 'ios-share') # our checked-in extension sources
|
||||||
|
APP_DIR = File.join(ROOT, 'ios', 'App')
|
||||||
|
EXT_NAME = 'ShareExtension'
|
||||||
|
EXT_DIR = File.join(APP_DIR, EXT_NAME)
|
||||||
|
APP_TARGET = 'App'
|
||||||
|
APP_BUNDLE = ENV.fetch('BUNDLE_ID', 'com.bizgaze.connect')
|
||||||
|
EXT_BUNDLE = "#{APP_BUNDLE}.share"
|
||||||
|
|
||||||
|
abort "xcodeproj not found at #{PROJECT}" unless File.directory?(PROJECT)
|
||||||
|
|
||||||
|
project = Xcodeproj::Project.open(PROJECT)
|
||||||
|
app = project.targets.find { |t| t.name == APP_TARGET }
|
||||||
|
abort "App target not found" unless app
|
||||||
|
|
||||||
|
# ── Clean any previous injection so this is idempotent ──────────────────────────────────────────────
|
||||||
|
project.targets.select { |t| t.name == EXT_NAME }.each do |t|
|
||||||
|
t.build_configuration_list&.build_configurations&.each { |c| c.remove_from_project }
|
||||||
|
t.remove_from_project
|
||||||
|
end
|
||||||
|
if (grp = project.main_group.children.find { |g| g.respond_to?(:display_name) && g.display_name == EXT_NAME })
|
||||||
|
grp.remove_from_project
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Copy our sources into the app project so paths inside the .xcodeproj are stable ──────────────────
|
||||||
|
FileUtils.mkdir_p(EXT_DIR)
|
||||||
|
%w[ShareViewController.swift Info.plist ShareExtension.entitlements].each do |f|
|
||||||
|
FileUtils.cp(File.join(SRC_DIR, f), File.join(EXT_DIR, f))
|
||||||
|
end
|
||||||
|
|
||||||
|
# The App Group entitlement for the MAIN app. MERGE, don't overwrite: the push-notifications plugin may
|
||||||
|
# already have written App/App.entitlements (aps-environment), and clobbering it would break push. We add
|
||||||
|
# the app-group array into whatever is there (or create the file if it's absent).
|
||||||
|
APP_GROUP = 'group.com.bizgaze.connect'
|
||||||
|
app_ent_path = File.join(APP_DIR, 'App', 'App.entitlements')
|
||||||
|
app_ent = File.exist?(app_ent_path) ? (Xcodeproj::Plist.read_from_path(app_ent_path) || {}) : {}
|
||||||
|
groups = app_ent['com.apple.security.application-groups'] || []
|
||||||
|
groups << APP_GROUP unless groups.include?(APP_GROUP)
|
||||||
|
app_ent['com.apple.security.application-groups'] = groups
|
||||||
|
Xcodeproj::Plist.write_to_path(app_ent, app_ent_path)
|
||||||
|
puts "App entitlements: app-group ensured (kept #{(app_ent.keys - ['com.apple.security.application-groups']).join(', ')})"
|
||||||
|
|
||||||
|
# ── Create the extension target ──────────────────────────────────────────────────────────────────────
|
||||||
|
# deployment_target can be nil when it's only set at the project level — fall back so we never create a
|
||||||
|
# target with an empty minimum-OS (which Xcode then flags).
|
||||||
|
deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '14.0'
|
||||||
|
ext = project.new_target(
|
||||||
|
:app_extension, EXT_NAME, :ios,
|
||||||
|
deployment, project.products_group, :swift
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source file + resources
|
||||||
|
group = project.main_group.new_group(EXT_NAME, "#{EXT_NAME}")
|
||||||
|
swift_ref = group.new_reference(File.join(EXT_DIR, 'ShareViewController.swift'))
|
||||||
|
ext.add_file_references([swift_ref])
|
||||||
|
|
||||||
|
# Build settings for every configuration (Debug/Release)
|
||||||
|
ext.build_configurations.each do |cfg|
|
||||||
|
s = cfg.build_settings
|
||||||
|
s['PRODUCT_BUNDLE_IDENTIFIER'] = EXT_BUNDLE
|
||||||
|
s['PRODUCT_NAME'] = '$(TARGET_NAME)'
|
||||||
|
s['INFOPLIST_FILE'] = "#{EXT_NAME}/Info.plist"
|
||||||
|
s['CODE_SIGN_ENTITLEMENTS'] = "#{EXT_NAME}/ShareExtension.entitlements"
|
||||||
|
s['IPHONEOS_DEPLOYMENT_TARGET'] = deployment
|
||||||
|
s['SWIFT_VERSION'] = '5.0'
|
||||||
|
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||||
|
s['GENERATE_INFOPLIST_FILE'] = 'NO'
|
||||||
|
s['SKIP_INSTALL'] = 'YES'
|
||||||
|
s['CODE_SIGN_STYLE'] = 'Manual'
|
||||||
|
s['MARKETING_VERSION'] = '1.0'
|
||||||
|
s['CURRENT_PROJECT_VERSION'] = ENV.fetch('BUILD_NUMBER', '1')
|
||||||
|
s['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks'
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── App Group entitlement on the MAIN app target too ─────────────────────────────────────────────────
|
||||||
|
app.build_configurations.each do |cfg|
|
||||||
|
cfg.build_settings['CODE_SIGN_ENTITLEMENTS'] = 'App/App.entitlements'
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Embed the extension into the app + depend on it ──────────────────────────────────────────────────
|
||||||
|
app.add_dependency(ext)
|
||||||
|
embed = app.build_phases.find { |p| p.respond_to?(:symbol_dst_subfolder_spec) && p.symbol_dst_subfolder_spec == :plug_ins }
|
||||||
|
embed ||= begin
|
||||||
|
phase = app.new_copy_files_build_phase('Embed App Extensions')
|
||||||
|
phase.symbol_dst_subfolder_spec = :plug_ins
|
||||||
|
phase
|
||||||
|
end
|
||||||
|
appex = ext.product_reference
|
||||||
|
build_file = embed.add_file_reference(appex)
|
||||||
|
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
||||||
|
|
||||||
|
project.save
|
||||||
|
puts "Share Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) embedded in #{APP_TARGET}"
|
||||||
@@ -40,6 +40,17 @@ set_bool() { "$PB" -c "Add :$1 bool $2" "$PLIST" 2>/dev/null || "$PB" -c "Set :$
|
|||||||
set_bool UIFileSharingEnabled true
|
set_bool UIFileSharingEnabled true
|
||||||
set_bool LSSupportsOpeningDocumentsInPlace true
|
set_bool LSSupportsOpeningDocumentsInPlace true
|
||||||
|
|
||||||
|
# ── Custom URL scheme so the Share Extension can bounce the user back into the app ──────────────────
|
||||||
|
# The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads
|
||||||
|
# the staged files and shows "Send to…". Registering the scheme is what makes that openURL succeed.
|
||||||
|
if ! "$PB" -c "Print :CFBundleURLTypes" "$PLIST" >/dev/null 2>&1; then
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes array" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0 dict" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLName string com.bizgaze.connect" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST"
|
||||||
|
"$PB" -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string bizconnect" "$PLIST"
|
||||||
|
fi
|
||||||
|
|
||||||
# We use only standard encryption (HTTPS/TLS), which is exempt — declaring this up front stops App Store
|
# We use only standard encryption (HTTPS/TLS), which is exempt — declaring this up front stops App Store
|
||||||
# Connect from asking the "export compliance" question on every single build/TestFlight upload.
|
# Connect from asking the "export compliance" question on every single build/TestFlight upload.
|
||||||
"$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \
|
"$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \
|
||||||
|
|||||||
+112
-1
@@ -1197,7 +1197,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=6"></script>
|
<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.
|
// 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
|
// 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();
|
composeMentions=new Map();
|
||||||
renderChats(searchVal());
|
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
|
// 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
|
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
|
||||||
// won't work until a subscription exists.
|
// 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.
|
// 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); }
|
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 —
|
// GUEST meeting: a lightweight pre-join (name) then join the call with a throwaway guest identity —
|
||||||
|
|||||||
Reference in New Issue
Block a user