Files
BizGaze_Remote/mobile/ios-share/ShareViewController.swift
T
Sravan 4d446a5425 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>
2026-07-24 18:00:01 +05:30

551 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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.
struct ShareChat {
let kind: String // "dm" | "group"
let id: String
let name: String
let avatar: String?
var key: String { kind + ":" + id }
}
struct ShareItem {
let name: String
let url: URL // staged copy in the App Group container
let mime: String
let isText: Bool
let text: String
}
class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
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 = ""
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)
private let search = UISearchBar()
private let statusLabel = UILabel()
private let spinner = UIActivityIndicatorView(style: .medium)
private let subtitle = UILabel()
private var sendButton: UIBarButtonItem!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
readAuth()
buildUI()
ingest()
}
private func readAuth() {
if let d = UserDefaults(suiteName: appGroup) {
token = d.string(forKey: "bzc_token") ?? ""
let b = d.string(forKey: "bzc_base") ?? ""
if !b.isEmpty { apiBase = b }
}
}
// MARK: - UI
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)
subtitle.font = .systemFont(ofSize: 12)
subtitle.textColor = .secondaryLabel
subtitle.textAlignment = .center
subtitle.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(subtitle)
search.placeholder = "Search chats…"
search.delegate = self
search.searchBarStyle = .minimal
search.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(search)
table.dataSource = self
table.delegate = self
table.rowHeight = 56
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat")
table.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(table)
statusLabel.font = .systemFont(ofSize: 15)
statusLabel.textColor = .secondaryLabel
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
statusLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(statusLabel)
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.hidesWhenStopped = true
view.addSubview(spinner)
NSLayoutConstraint.activate([
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),
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),
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
spinner.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 14)
])
}
private func setStatus(_ text: String?, busy: Bool = false) {
DispatchQueue.main.async {
self.statusLabel.text = text
self.statusLabel.isHidden = (text == nil)
if busy { self.spinner.startAnimating() } else { self.spinner.stopAnimating() }
}
}
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() {
setStatus("Preparing…", busy: true)
let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
.flatMap { $0.attachments ?? [] }
.prefix(maxItems)
guard !providers.isEmpty else { return finishFail("Nothing to share.") }
var collected: [ShareItem] = []
let lock = NSLock()
let group = DispatchGroup()
for provider in providers {
group.enter()
load(provider) { item in
if let item = item { lock.lock(); collected.append(item); lock.unlock() }
group.leave()
}
}
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.loadChats()
}
}
private func load(_ provider: NSItemProvider, completion: @escaping (ShareItem?) -> Void) {
let fileTypes: [UTType] = [.movie, .image, .audio, .pdf, .item]
if let type = fileTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) {
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
if let u = item as? URL { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: u.absoluteString)) }
else { completion(nil) }
}
return
}
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in
if let s = item as? String { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: s)) }
else { completion(nil) }
}
return
}
completion(nil)
}
private func stage(_ src: URL) -> ShareItem? {
guard let dir = sharedDir() 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 }
return ShareItem(name: dest.lastPathComponent, url: dest, mime: Self.mimeType(for: dest), isText: false, text: "")
}
// MARK: - Chat list
private func loadChats() {
guard !token.isEmpty else {
setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.")
return
}
setStatus("Loading your chats…", busy: true)
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 {
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)
}
DispatchQueue.main.async {
self.chats = parsed
self.filtered = parsed
self.setStatus(parsed.isEmpty ? "No chats yet." : nil)
self.table.reloadData()
}
}
}
// MARK: - Table
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath)
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.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 }
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: - Avatars
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 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("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 {
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()
self.setStatus("✓ Sent to \(label)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
} else {
self.sending = false
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() }
}
}
}
}
private func messageBody(chat: ShareChat, attachmentId: String?, text: String?) -> [String: Any] {
var b: [String: Any] = [:]
if chat.kind == "group" { b["group"] = chat.id } else { b["to"] = chat.id }
if let a = attachmentId { b["attachmentId"] = a }
if let t = text { b["body"] = t }
return b
}
// MARK: - Networking
private func uploadSync(name: String, mime: String, data: Data) -> String? {
guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
req.setValue(mime.isEmpty ? "application/octet-stream" : mime, forHTTPHeaderField: "Content-Type")
req.setValue(name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? name, forHTTPHeaderField: "X-Filename")
req.httpBody = data
req.timeoutInterval = 300
var out: String? = nil
let sem = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: req) { d, resp, _ in
defer { sem.signal() }
guard let d = d, let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let obj = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] else { return }
out = idString(obj["id"])
}.resume()
sem.wait()
return out
}
private func sendMessageSync(_ body: [String: Any]) -> Bool {
guard let url = URL(string: apiBase + "/api/messages"),
let json = try? JSONSerialization.data(withJSONObject: body) else { return false }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = json
req.timeoutInterval = 60
var ok = false
let sem = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: req) { _, resp, _ in
if let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) { ok = true }
sem.signal()
}.resume()
sem.wait()
return ok
}
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")
req.timeoutInterval = 30
URLSession.shared.dataTask(with: req) { d, resp, _ in completion(d, resp as? HTTPURLResponse) }.resume()
}
// MARK: - App Group storage
private func sharedDir() -> 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
}
private func writeManifest(_ its: [ShareItem]) {
guard let dir = sharedDir() else { return }
let records: [[String: Any]] = its.map { it in
it.isText ? ["kind": "text", "text": it.text]
: ["kind": "file", "name": it.name, "path": it.url.path, "mime": it.mime]
}
if let data = try? JSONSerialization.data(withJSONObject: records) {
try? data.write(to: dir.appendingPathComponent("manifest.json"), options: .atomic)
}
}
private func clearManifest() {
guard let dir = sharedDir() else { return }
let fm = FileManager.default
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
for e in entries { try? fm.removeItem(at: e) }
}
}
private static func mimeType(for url: URL) -> String {
if let t = UTType(filenameExtension: url.pathExtension.lowercased()), let m = t.preferredMIMEType { return m }
return "application/octet-stream"
}
// MARK: - Finish
@objc private func cancelTapped() {
extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0))
}
private func finish() {
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
private func finishFail(_ msg: String) {
setStatus(msg)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { self.finish() }
}
}
// 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 }
return nil
}
extension ShareViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
let q = searchText.trimmingCharacters(in: .whitespaces).lowercased()
filtered = q.isEmpty ? chats : chats.filter { $0.name.lowercased().contains(q) }
table.reloadData()
}
}