feat(share-ext): avatars, multi-select, and Biz Connect branding in the picker

Three follow-ups on the in-sheet picker:
- Profile photos: rows now show the real avatar (fetched from the conversations
  API's `avatar` field with the bearer token, or a data-URL decoded inline),
  rendered as a circle; coloured initials as the fallback — matching the app.
- Multi-select: tap toggles a checkmark instead of sending immediately; a "Send (N)"
  button in the nav bar sends to every selected chat. Each file is uploaded ONCE and
  its attachment id reused across all targets (the server allows the uploader to
  reattach the same id), so multi-send doesn't re-upload.
- Branding: navy (#1F3B73) navigation bar with a white "Biz Connect" prompt over the
  "Send to…" title and white controls.

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:00:01 +05:30
parent d664dde798
commit 4d446a5425
+164 -36
View File
@@ -2,20 +2,20 @@ import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
// Share Extension puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way // Share Extension puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way
// Teams/WhatsApp do: pick a chat in the sheet, it uploads and sends, no app-open needed. // Teams/WhatsApp do: pick one or more chats in the sheet, it uploads and sends, no app-open needed.
// //
// How it can send without the app: the app writes a bearer token + API base into the App Group each launch // 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 // (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 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 // /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. // opening the app later picks them up.
//
// The App Group is the only storage the app and this separate process share.
struct ShareChat { struct ShareChat {
let kind: String // "dm" | "group" let kind: String // "dm" | "group"
let id: String let id: String
let name: String let name: String
let avatar: String?
var key: String { kind + ":" + id }
} }
struct ShareItem { struct ShareItem {
@@ -30,12 +30,15 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private let appGroup = "group.com.bizgaze.connect" private let appGroup = "group.com.bizgaze.connect"
private let maxItems = 20 private let maxItems = 20
private let brandNavy = UIColor(red: 0x1F/255.0, green: 0x3B/255.0, blue: 0x73/255.0, alpha: 1)
private var token: String = "" private var token = ""
private var apiBase: String = "https://remote.bizgaze.com" private var apiBase = "https://remote.bizgaze.com"
private var items: [ShareItem] = [] private var items: [ShareItem] = []
private var chats: [ShareChat] = [] private var chats: [ShareChat] = []
private var filtered: [ShareChat] = [] private var filtered: [ShareChat] = []
private var selected = Set<String>() // chat keys chosen for multi-send
private var avatarCache: [String: UIImage] = [:]
private var sending = false private var sending = false
private let table = UITableView(frame: .zero, style: .plain) private let table = UITableView(frame: .zero, style: .plain)
@@ -43,6 +46,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let spinner = UIActivityIndicatorView(style: .medium) private let spinner = UIActivityIndicatorView(style: .medium)
private let subtitle = UILabel() private let subtitle = UILabel()
private var sendButton: UIBarButtonItem!
// MARK: - Lifecycle // MARK: - Lifecycle
@@ -50,7 +54,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
super.viewDidLoad() super.viewDidLoad()
readAuth() readAuth()
buildUI() buildUI()
ingest() // stage the shared items, then load the chat list ingest()
} }
private func readAuth() { private func readAuth() {
@@ -66,10 +70,23 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private func buildUI() { private func buildUI() {
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
// Branded navigation bar (Biz Connect navy, white title + buttons).
let nav = UINavigationBar() let nav = UINavigationBar()
nav.translatesAutoresizingMaskIntoConstraints = false nav.translatesAutoresizingMaskIntoConstraints = false
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = brandNavy
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
nav.standardAppearance = appearance
nav.scrollEdgeAppearance = appearance
nav.tintColor = .white
let navItem = UINavigationItem(title: "Send to…") let navItem = UINavigationItem(title: "Send to…")
navItem.prompt = "Biz Connect" // small brand line above the title
navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
sendButton = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendTapped))
sendButton.isEnabled = false
navItem.rightBarButtonItem = sendButton
nav.setItems([navItem], animated: false) nav.setItems([navItem], animated: false)
view.addSubview(nav) view.addSubview(nav)
@@ -87,6 +104,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
table.dataSource = self table.dataSource = self
table.delegate = self table.delegate = self
table.rowHeight = 56
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat") table.register(UITableViewCell.self, forCellReuseIdentifier: "chat")
table.translatesAutoresizingMaskIntoConstraints = false table.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(table) view.addSubview(table)
@@ -106,7 +124,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
nav.leadingAnchor.constraint(equalTo: view.leadingAnchor), nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
nav.trailingAnchor.constraint(equalTo: view.trailingAnchor), nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),
subtitle.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 2), subtitle.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 4),
subtitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), subtitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
subtitle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), subtitle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
search.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4), search.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
@@ -133,6 +151,12 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
} }
} }
private func updateSendButton() {
let n = selected.count
sendButton.title = n > 0 ? "Send (\(n))" : "Send"
sendButton.isEnabled = n > 0 && !sending
}
// MARK: - Ingest shared items // MARK: - Ingest shared items
private func ingest() { private func ingest() {
@@ -155,8 +179,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
group.notify(queue: .main) { [weak self] in group.notify(queue: .main) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.items = collected self.items = collected
// Always ALSO leave a manifest, so if the send here fails the app can still collect them later. self.writeManifest(collected) // safety net: app can still collect if send here fails
self.writeManifest(collected)
if collected.isEmpty { return self.finishFail("Couldnt read the shared file.") } if collected.isEmpty { return self.finishFail("Couldnt read the shared file.") }
let fileCount = collected.filter { !$0.isText }.count let fileCount = collected.filter { !$0.isText }.count
self.subtitle.text = fileCount > 0 ? "Sharing \(fileCount) file\(fileCount == 1 ? "" : "s")" : "Sharing a link" self.subtitle.text = fileCount > 0 ? "Sharing \(fileCount) file\(fileCount == 1 ? "" : "s")" : "Sharing a link"
@@ -206,7 +229,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return return
} }
setStatus("Loading your chats…", busy: true) setStatus("Loading your chats…", busy: true)
api("GET", "/api/messages/conversations", body: nil) { [weak self] data, _ in api("GET", "/api/messages/conversations") { [weak self] data, _ in
guard let self = self else { return } guard let self = self else { return }
guard let data = data, guard let data = data,
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
@@ -215,7 +238,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
} }
let parsed: [ShareChat] = arr.compactMap { row in 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 } 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) return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String)
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self.chats = parsed self.chats = parsed
@@ -234,49 +257,157 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath)
let c = filtered[indexPath.row] let c = filtered[indexPath.row]
cell.textLabel?.text = c.name cell.textLabel?.text = c.name
cell.detailTextLabel?.text = nil cell.textLabel?.font = .systemFont(ofSize: 16)
cell.imageView?.image = nil cell.accessoryType = selected.contains(c.key) ? .checkmark : .none
cell.accessoryType = .disclosureIndicator cell.tintColor = brandNavy
// Round avatar: cached image, else initials placeholder + async load.
cell.imageView?.layer.cornerRadius = 18
cell.imageView?.layer.masksToBounds = true
if let img = avatarCache[c.key] {
cell.imageView?.image = img
} else {
cell.imageView?.image = initialsImage(c.name)
loadAvatar(for: c)
}
return cell return cell
} }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
guard !sending else { return } guard !sending else { return }
sendAll(to: filtered[indexPath.row]) let key = filtered[indexPath.row].key
if selected.contains(key) { selected.remove(key) } else { selected.insert(key) }
tableView.reloadRows(at: [indexPath], with: .none)
updateSendButton()
} }
// MARK: - Send // MARK: - Avatars
private func sendAll(to chat: ShareChat) { 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)...])),
let img = Self.circularImage(from: d) {
cache(img, for: chat)
}
return
}
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
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)
}.resume()
}
private func cache(_ img: UIImage, for chat: ShareChat) {
DispatchQueue.main.async {
self.avatarCache[chat.key] = img
if let idx = self.filtered.firstIndex(where: { $0.key == chat.key }) {
self.table.reloadRows(at: [IndexPath(row: idx, section: 0)], with: .none)
}
}
}
private func initialsImage(_ name: String, size: CGFloat = 36) -> 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)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
return renderer.image { _ in
bg.setFill()
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).fill()
let para = NSMutableParagraphStyle(); para.alignment = .center
let attrs: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: size * 0.4, weight: .semibold),
.foregroundColor: UIColor.white,
.paragraphStyle: para
]
let s = (initials.isEmpty ? "?" : initials) as NSString
let ts = s.size(withAttributes: attrs)
s.draw(in: CGRect(x: 0, y: (size - ts.height) / 2, width: size, height: ts.height), withAttributes: attrs)
}
}
private static func circularImage(from data: Data, size: CGFloat = 36) -> 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))
}
}
private static let palette: [UIColor] = [
UIColor(red: 0.20, green: 0.45, blue: 0.85, alpha: 1),
UIColor(red: 0.85, green: 0.35, blue: 0.45, alpha: 1),
UIColor(red: 0.30, green: 0.65, blue: 0.45, alpha: 1),
UIColor(red: 0.75, green: 0.55, blue: 0.20, alpha: 1),
UIColor(red: 0.50, green: 0.40, blue: 0.75, alpha: 1),
UIColor(red: 0.25, green: 0.60, blue: 0.70, alpha: 1)
]
private static func color(for name: String) -> UIColor {
var h: UInt32 = 0
for c in name.unicodeScalars { h = h &* 31 &+ c.value }
return palette[Int(h % UInt32(palette.count))]
}
// MARK: - Send (to every selected chat)
@objc private func sendTapped() {
guard !sending else { return }
let targets = chats.filter { selected.contains($0.key) }
guard !targets.isEmpty else { return }
sending = true sending = true
updateSendButton()
search.isHidden = true search.isHidden = true
table.isHidden = true table.isHidden = true
let files = items.filter { !$0.isText } let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
let texts = items.filter { $0.isText }.map { $0.text } setStatus("Sending to \(label)", busy: true)
setStatus("Sending to \(chat.name)", busy: true)
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let files = self.items.filter { !$0.isText }
let texts = self.items.filter { $0.isText }.map { $0.text }
var ok = true 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() { for (i, f) in files.enumerated() {
if files.count > 1 { self.setStatus("Sending \(i + 1) of \(files.count) to \(chat.name)", busy: true) } if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)", busy: true) }
guard let data = try? Data(contentsOf: f.url), let attId = self.uploadSync(name: f.name, mime: f.mime, data: data) else { ok = false; break } guard let data = try? Data(contentsOf: f.url), let id = self.uploadSync(name: f.name, mime: f.mime, data: data) else { ok = false; break }
let body = self.messageBody(chat: chat, attachmentId: attId, text: nil) attIds.append(id)
if !self.sendMessageSync(body) { ok = false; break }
} }
if ok && !texts.isEmpty { if ok {
let body = self.messageBody(chat: chat, attachmentId: nil, text: texts.joined(separator: "\n")) sendLoop: for chat in targets {
if !self.sendMessageSync(body) { ok = false } for id in attIds {
if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: id, text: nil)) { ok = false; break sendLoop }
}
if !texts.isEmpty {
if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: nil, text: texts.joined(separator: "\n"))) { ok = false; break sendLoop }
}
}
} }
DispatchQueue.main.async { DispatchQueue.main.async {
if ok { if ok {
self.clearManifest() // sent from here don't let the app re-offer them self.clearManifest()
self.setStatus("✓ Sent to \(chat.name)") self.setStatus("✓ Sent to \(label)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
} else { } else {
self.sending = false self.sending = false
self.setStatus("Couldnt send. Your file is saved — open Biz Connect to send it.", busy: false) self.search.isHidden = false
DispatchQueue.main.asyncAfter(deadline: .now() + 2.2) { self.finish() } self.table.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() }
} }
} }
} }
@@ -290,7 +421,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return b return b
} }
// MARK: - Networking (synchronous helpers, always called off the main thread) // MARK: - Networking
private func uploadSync(name: String, mime: String, data: Data) -> String? { private func uploadSync(name: String, mime: String, data: Data) -> String? {
guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil } guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil }
@@ -332,13 +463,11 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return ok return ok
} }
// GET/generic async helper for loading the chat list. private func api(_ method: String, _ path: String, completion: @escaping (Data?, HTTPURLResponse?) -> Void) {
private func api(_ method: String, _ path: String, body: Data?, completion: @escaping (Data?, HTTPURLResponse?) -> Void) {
guard let url = URL(string: apiBase + path) else { return completion(nil, nil) } guard let url = URL(string: apiBase + path) else { return completion(nil, nil) }
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = method req.httpMethod = method
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
if let body = body { req.httpBody = body; req.setValue("application/json", forHTTPHeaderField: "Content-Type") }
req.timeoutInterval = 30 req.timeoutInterval = 30
URLSession.shared.dataTask(with: req) { d, resp, _ in completion(d, resp as? HTTPURLResponse) }.resume() URLSession.shared.dataTask(with: req) { d, resp, _ in completion(d, resp as? HTTPURLResponse) }.resume()
} }
@@ -365,7 +494,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return candidate return candidate
} }
// Fallback manifest so the app can still pick these up if sending from here fails.
private func writeManifest(_ its: [ShareItem]) { private func writeManifest(_ its: [ShareItem]) {
guard let dir = sharedDir() else { return } guard let dir = sharedDir() else { return }
let records: [[String: Any]] = its.map { it in let records: [[String: Any]] = its.map { it in