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:
@@ -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("Couldn’t 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("Couldn’t 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("Couldn’t 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
|
||||
|
||||
Reference in New Issue
Block a user