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
// 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
// (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.
//
// The App Group is the only storage the app and this separate process share.
struct ShareChat {
let kind: String // "dm" | "group"
let id: String
let name: String
let avatar: String?
var key: String { kind + ":" + id }
}
struct ShareItem {
@@ -30,12 +30,15 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private let appGroup = "group.com.bizgaze.connect"
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 apiBase: String = "https://remote.bizgaze.com"
private var token = ""
private var apiBase = "https://remote.bizgaze.com"
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 avatarCache: [String: UIImage] = [:]
private var sending = false
private let table = UITableView(frame: .zero, style: .plain)
@@ -43,6 +46,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private let statusLabel = UILabel()
private let spinner = UIActivityIndicatorView(style: .medium)
private let subtitle = UILabel()
private var sendButton: UIBarButtonItem!
// MARK: - Lifecycle
@@ -50,7 +54,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
super.viewDidLoad()
readAuth()
buildUI()
ingest() // stage the shared items, then load the chat list
ingest()
}
private func readAuth() {
@@ -66,10 +70,23 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private func buildUI() {
view.backgroundColor = .systemBackground
// 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]
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
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)
view.addSubview(nav)
@@ -87,6 +104,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
table.dataSource = self
table.delegate = self
table.rowHeight = 56
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat")
table.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(table)
@@ -106,7 +124,7 @@ 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: 2),
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),
@@ -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
private func ingest() {
@@ -155,8 +179,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
group.notify(queue: .main) { [weak self] in
guard let self = self else { return }
self.items = collected
// Always ALSO leave a manifest, so if the send here fails the app can still collect them later.
self.writeManifest(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"
@@ -206,7 +229,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return
}
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 data = data,
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
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 {
self.chats = parsed
@@ -234,49 +257,157 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath)
let c = filtered[indexPath.row]
cell.textLabel?.text = c.name
cell.detailTextLabel?.text = nil
cell.imageView?.image = nil
cell.accessoryType = .disclosureIndicator
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.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
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
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
updateSendButton()
search.isHidden = true
table.isHidden = true
let files = items.filter { !$0.isText }
let texts = items.filter { $0.isText }.map { $0.text }
setStatus("Sending to \(chat.name)", busy: true)
let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
setStatus("Sending to \(label)", busy: true)
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
// 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("Sending \(i + 1) of \(files.count) to \(chat.name)", 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 }
let body = self.messageBody(chat: chat, attachmentId: attId, text: nil)
if !self.sendMessageSync(body) { ok = false; break }
if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)", busy: true) }
guard let data = try? Data(contentsOf: f.url), let id = self.uploadSync(name: f.name, mime: f.mime, data: data) else { ok = false; break }
attIds.append(id)
}
if ok && !texts.isEmpty {
let body = self.messageBody(chat: chat, attachmentId: nil, text: texts.joined(separator: "\n"))
if !self.sendMessageSync(body) { ok = false }
if ok {
sendLoop: for chat in targets {
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 {
if ok {
self.clearManifest() // sent from here don't let the app re-offer them
self.setStatus("✓ Sent to \(chat.name)")
self.clearManifest()
self.setStatus("✓ Sent to \(label)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
} else {
self.sending = false
self.setStatus("Couldnt send. Your file is saved — open Biz Connect to send it.", busy: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 2.2) { self.finish() }
self.search.isHidden = false
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
}
// MARK: - Networking (synchronous helpers, always called off the main thread)
// MARK: - Networking
private func uploadSync(name: String, mime: String, data: Data) -> String? {
guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil }
@@ -332,13 +463,11 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return ok
}
// GET/generic async helper for loading the chat list.
private func api(_ method: String, _ path: String, body: Data?, completion: @escaping (Data?, HTTPURLResponse?) -> Void) {
private func api(_ method: String, _ path: String, completion: @escaping (Data?, HTTPURLResponse?) -> Void) {
guard let url = URL(string: apiBase + path) else { return completion(nil, nil) }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
if let body = body { req.httpBody = body; req.setValue("application/json", forHTTPHeaderField: "Content-Type") }
req.timeoutInterval = 30
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
}
// Fallback manifest so the app can still pick these up if sending from here fails.
private func writeManifest(_ its: [ShareItem]) {
guard let dir = sharedDir() else { return }
let records: [[String: Any]] = its.map { it in