feat(share-ext): fix double "Send to", Teams-style layout, clearer branding

Two things from testing (the in-sheet picker with avatars + multi-select works):

1. Double "Send to": after the extension sent, opening the app ALSO popped the web
   "Send to" modal for the same file. Cause: the extension wrote a safety-net
   manifest up front, which the app then picked up. Now the manifest represents an
   UNSENT share only — written solely when the extension can't send (no token) or a
   send fails. A successful in-sheet send clears the staged files and leaves nothing,
   so the app never re-offers it. Cancel also clears staged files (no orphans).

2. Layout aligned to the Teams reference:
   - Preview strip of thumbnails for what's being shared (image → the image, video →
     first frame via AVAssetImageGenerator, else a doc icon).
   - Radio selectors on the right — an always-visible empty circle that fills to a
     navy check when selected (clearer multi-select than an appear-on-select tick).
   - "Recent chats" section header; subtitle under each name (Direct message /
     Group · N members).
   - Clearer branding: bold white "Share to Biz Connect" on the navy bar.

Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 18:25:15 +05:30
parent 4d446a5425
commit 854bc86a6e
+123 -47
View File
@@ -1,4 +1,5 @@
import UIKit
import AVFoundation
import UniformTypeIdentifiers
// Share Extension puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way
@@ -7,23 +8,30 @@ import UniformTypeIdentifiers
// How it can send without the app: the app writes a bearer token + API base into the App Group each launch
// (via /api/share/token ShareInbox.setAuth). This extension reads that token and calls the same server
// API the native client uses GET /api/messages/conversations, POST /api/messages/upload, POST
// /api/messages. If there's no token (user never opened/signed in), we say so and still stage the files so
// opening the app later picks them up.
// /api/messages.
//
// Manifest lifecycle (why the app doesn't also pop a "Send to" sheet): the App Group manifest represents an
// UNSENT share. We only write it when this extension can't send (no token) or a send fails so a
// successful in-sheet send leaves nothing behind and the app never re-offers it. Cancel/success clear any
// staged files too, so the App Group doesn't accumulate orphans.
struct ShareChat {
let kind: String // "dm" | "group"
let id: String
let name: String
let avatar: String?
let subtitle: String
var key: String { kind + ":" + id }
}
struct ShareItem {
let name: String
let url: URL // staged copy in the App Group container
let url: URL
let mime: String
let isText: Bool
let text: String
var isImage: Bool { mime.hasPrefix("image/") }
var isVideo: Bool { mime.hasPrefix("video/") }
}
class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
@@ -37,15 +45,16 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private var items: [ShareItem] = []
private var chats: [ShareChat] = []
private var filtered: [ShareChat] = []
private var selected = Set<String>() // chat keys chosen for multi-send
private var selected = Set<String>()
private var avatarCache: [String: UIImage] = [:]
private var sending = false
private let table = UITableView(frame: .zero, style: .plain)
private let table = UITableView(frame: .zero, style: .grouped)
private let search = UISearchBar()
private let statusLabel = UILabel()
private let spinner = UIActivityIndicatorView(style: .medium)
private let subtitle = UILabel()
private let previewStack = UIStackView()
private let previewScroll = UIScrollView()
private var sendButton: UIBarButtonItem!
// MARK: - Lifecycle
@@ -68,21 +77,18 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
// MARK: - UI
private func buildUI() {
view.backgroundColor = .systemBackground
view.backgroundColor = .systemGroupedBackground
// Branded navigation bar (Biz Connect navy, white title + buttons).
let nav = UINavigationBar()
nav.translatesAutoresizingMaskIntoConstraints = false
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = brandNavy
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
appearance.titleTextAttributes = [.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 17, weight: .bold)]
nav.standardAppearance = appearance
nav.scrollEdgeAppearance = appearance
nav.tintColor = .white
let navItem = UINavigationItem(title: "Send to…")
navItem.prompt = "Biz Connect" // small brand line above the title
let navItem = UINavigationItem(title: "Share to Biz Connect")
navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
sendButton = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendTapped))
sendButton.isEnabled = false
@@ -90,13 +96,17 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
nav.setItems([navItem], animated: false)
view.addSubview(nav)
subtitle.font = .systemFont(ofSize: 12)
subtitle.textColor = .secondaryLabel
subtitle.textAlignment = .center
subtitle.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(subtitle)
// Preview strip thumbnails of what's being shared (like Teams' attachment row).
previewScroll.translatesAutoresizingMaskIntoConstraints = false
previewScroll.showsHorizontalScrollIndicator = false
previewStack.axis = .horizontal
previewStack.spacing = 8
previewStack.alignment = .center
previewStack.translatesAutoresizingMaskIntoConstraints = false
previewScroll.addSubview(previewStack)
view.addSubview(previewScroll)
search.placeholder = "Search chats…"
search.placeholder = "Search for people or groups"
search.delegate = self
search.searchBarStyle = .minimal
search.translatesAutoresizingMaskIntoConstraints = false
@@ -104,8 +114,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
table.dataSource = self
table.delegate = self
table.rowHeight = 56
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat")
table.rowHeight = 60
table.backgroundColor = .systemGroupedBackground
table.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(table)
@@ -124,16 +134,26 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),
subtitle.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 4),
subtitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
subtitle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
search.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
previewScroll.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 10),
previewScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
previewScroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
previewScroll.heightAnchor.constraint(equalToConstant: 56),
previewStack.topAnchor.constraint(equalTo: previewScroll.topAnchor),
previewStack.bottomAnchor.constraint(equalTo: previewScroll.bottomAnchor),
previewStack.leadingAnchor.constraint(equalTo: previewScroll.leadingAnchor),
previewStack.trailingAnchor.constraint(equalTo: previewScroll.trailingAnchor),
previewStack.heightAnchor.constraint(equalTo: previewScroll.heightAnchor),
search.topAnchor.constraint(equalTo: previewScroll.bottomAnchor, constant: 8),
search.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6),
search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),
table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2),
table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
table.bottomAnchor.constraint(equalTo: view.bottomAnchor),
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
@@ -157,7 +177,51 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
sendButton.isEnabled = n > 0 && !sending
}
// MARK: - Ingest shared items
// MARK: - Preview thumbnails
private func buildPreviews() {
for f in items where !f.isText {
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
iv.layer.cornerRadius = 8
iv.backgroundColor = .tertiarySystemFill
iv.tintColor = .secondaryLabel
iv.widthAnchor.constraint(equalToConstant: 56).isActive = true
iv.heightAnchor.constraint(equalToConstant: 56).isActive = true
iv.image = UIImage(systemName: f.isVideo ? "video.fill" : "doc.fill")
previewStack.addArrangedSubview(iv)
thumbnail(for: f) { img in if let img = img { DispatchQueue.main.async { iv.image = img; iv.contentMode = .scaleAspectFill } } }
}
if items.contains(where: { $0.isText }) {
let lbl = UILabel()
lbl.text = "🔗 link"
lbl.font = .systemFont(ofSize: 13)
lbl.textColor = .secondaryLabel
previewStack.addArrangedSubview(lbl)
}
}
private func thumbnail(for item: ShareItem, completion: @escaping (UIImage?) -> Void) {
DispatchQueue.global(qos: .userInitiated).async {
if item.isImage, let img = UIImage(contentsOfFile: item.url.path) {
return completion(img)
}
if item.isVideo {
let asset = AVURLAsset(url: item.url)
let gen = AVAssetImageGenerator(asset: asset)
gen.appliesPreferredTrackTransform = true
gen.maximumSize = CGSize(width: 168, height: 168)
if let cg = try? gen.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) {
return completion(UIImage(cgImage: cg))
}
}
completion(nil)
}
}
// MARK: - Ingest
private func ingest() {
setStatus("Preparing…", busy: true)
@@ -179,10 +243,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
group.notify(queue: .main) { [weak self] in
guard let self = self else { return }
self.items = collected
self.writeManifest(collected) // safety net: app can still collect if send here fails
if collected.isEmpty { return self.finishFail("Couldnt read the shared file.") }
let fileCount = collected.filter { !$0.isText }.count
self.subtitle.text = fileCount > 0 ? "Sharing \(fileCount) file\(fileCount == 1 ? "" : "s")" : "Sharing a link"
self.buildPreviews()
self.loadChats()
}
}
@@ -225,6 +287,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private func loadChats() {
guard !token.isEmpty else {
writeManifest(items) // no token let the app pick these up
setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.")
return
}
@@ -233,12 +296,14 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
guard let self = self else { return }
guard let data = data,
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
self.writeManifest(self.items)
self.setStatus("Couldnt load your chats.\n\nOpen Biz Connect once, then try sharing again.")
return
}
let parsed: [ShareChat] = arr.compactMap { row in
guard let kind = row["kind"] as? String, let id = idString(row["id"]), let name = row["name"] as? String else { return nil }
return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String)
let sub = kind == "group" ? "Group" + ((row["members"] as? Int).map { " · \($0) members" } ?? "") : "Direct message"
return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String, subtitle: sub)
}
DispatchQueue.main.async {
self.chats = parsed
@@ -253,15 +318,26 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count }
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
filtered.isEmpty ? nil : "Recent chats"
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath)
let cell = tableView.dequeueReusableCell(withIdentifier: "chat") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "chat")
let c = filtered[indexPath.row]
cell.textLabel?.text = c.name
cell.textLabel?.font = .systemFont(ofSize: 16)
cell.accessoryType = selected.contains(c.key) ? .checkmark : .none
cell.tintColor = brandNavy
// Round avatar: cached image, else initials placeholder + async load.
cell.imageView?.layer.cornerRadius = 18
cell.detailTextLabel?.text = c.subtitle
cell.detailTextLabel?.textColor = .secondaryLabel
// Radio selector on the right (empty circle filled navy check), always visible like Teams.
let selImg = selected.contains(c.key)
? UIImage(systemName: "checkmark.circle.fill")?.withTintColor(brandNavy, renderingMode: .alwaysOriginal)
: UIImage(systemName: "circle")?.withTintColor(.systemGray3, renderingMode: .alwaysOriginal)
let selView = UIImageView(image: selImg)
selView.frame = CGRect(x: 0, y: 0, width: 26, height: 26)
cell.accessoryView = selView
// Round avatar
cell.imageView?.layer.cornerRadius = 20
cell.imageView?.layer.masksToBounds = true
if let img = avatarCache[c.key] {
cell.imageView?.image = img
@@ -285,7 +361,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private func loadAvatar(for chat: ShareChat) {
guard let av = chat.avatar, !av.isEmpty else { return }
// Data URL decode inline.
if av.hasPrefix("data:") {
if let comma = av.firstIndex(of: ","),
let d = Data(base64Encoded: String(av[av.index(after: comma)...])),
@@ -297,7 +372,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
let urlStr = av.hasPrefix("/") ? (apiBase + av) : av
guard let url = URL(string: urlStr) else { return }
var req = URLRequest(url: url)
if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } // /files needs auth
if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") }
URLSession.shared.dataTask(with: req) { [weak self] d, _, _ in
guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return }
self.cache(img, for: chat)
@@ -313,7 +388,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
}
}
private func initialsImage(_ name: String, size: CGFloat = 36) -> UIImage {
private func initialsImage(_ name: String, size: CGFloat = 40) -> UIImage {
let parts = name.split(separator: " ")
let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased()
let bg = Self.color(for: name)
@@ -333,12 +408,11 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
}
}
private static func circularImage(from data: Data, size: CGFloat = 36) -> UIImage? {
private static func circularImage(from data: Data, size: CGFloat = 40) -> UIImage? {
guard let img = UIImage(data: data) else { return nil }
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
return renderer.image { _ in
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).addClip()
// Aspect-fill into the circle.
let scale = max(size / img.size.width, size / img.size.height)
let w = img.size.width * scale, h = img.size.height * scale
img.draw(in: CGRect(x: (size - w) / 2, y: (size - h) / 2, width: w, height: h))
@@ -359,7 +433,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return palette[Int(h % UInt32(palette.count))]
}
// MARK: - Send (to every selected chat)
// MARK: - Send
@objc private func sendTapped() {
guard !sending else { return }
@@ -369,6 +443,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
updateSendButton()
search.isHidden = true
table.isHidden = true
previewScroll.isHidden = true
let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
setStatus("Sending to \(label)", busy: true)
@@ -376,9 +451,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
let files = self.items.filter { !$0.isText }
let texts = self.items.filter { $0.isText }.map { $0.text }
var ok = true
// Upload each file ONCE; reuse its attachment id for every selected chat (server allows the
// uploader to attach the same id repeatedly).
var attIds: [String] = []
for (i, f) in files.enumerated() {
if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)", busy: true) }
@@ -395,16 +467,17 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
}
}
}
DispatchQueue.main.async {
if ok {
self.clearManifest()
self.clearStaged() // sent leave nothing for the app to re-offer
self.setStatus("✓ Sent to \(label)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
} else {
self.writeManifest(self.items) // failed let the app pick these up
self.sending = false
self.search.isHidden = false
self.table.isHidden = false
self.previewScroll.isHidden = false
self.updateSendButton()
self.setStatus("Couldnt send. Your file is saved — open Biz Connect to send it.")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() }
@@ -494,6 +567,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return candidate
}
// Written only when this extension can't send it marks an UNSENT share for the app to pick up.
private func writeManifest(_ its: [ShareItem]) {
guard let dir = sharedDir() else { return }
let records: [[String: Any]] = its.map { it in
@@ -505,7 +579,9 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
}
}
private func clearManifest() {
// Remove the staged files + any manifest, so the App Group doesn't accumulate and the app has nothing
// to re-offer after a successful send or a cancel.
private func clearStaged() {
guard let dir = sharedDir() else { return }
let fm = FileManager.default
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
@@ -521,6 +597,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
// MARK: - Finish
@objc private func cancelTapped() {
clearStaged() // nothing sent don't leave orphans or let the app re-offer
extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0))
}
@@ -534,7 +611,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
}
}
// A JSON id may decode as String or NSNumber depending on the value normalise to String.
private func idString(_ v: Any?) -> String? {
if let s = v as? String { return s }
if let n = v as? NSNumber { return n.stringValue }