diff --git a/mobile/ios-share/ShareViewController.swift b/mobile/ios-share/ShareViewController.swift index 677b0d1..349529e 100644 --- a/mobile/ios-share/ShareViewController.swift +++ b/mobile/ios-share/ShareViewController.swift @@ -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() // 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