From 854bc86a6e11a81254e242f5e77246701d854a3d Mon Sep 17 00:00:00 2001 From: sravan Date: Fri, 24 Jul 2026 18:25:15 +0530 Subject: [PATCH] feat(share-ext): fix double "Send to", Teams-style layout, clearer branding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things from testing (the in-sheet picker with avatars + multi-select works): 1. Double "Send to": after the extension sent, opening the app ALSO popped the web "Send to" modal for the same file. Cause: the extension wrote a safety-net manifest up front, which the app then picked up. Now the manifest represents an UNSENT share only — written solely when the extension can't send (no token) or a send fails. A successful in-sheet send clears the staged files and leaves nothing, so the app never re-offers it. Cancel also clears staged files (no orphans). 2. Layout aligned to the Teams reference: - Preview strip of thumbnails for what's being shared (image → the image, video → first frame via AVAssetImageGenerator, else a doc icon). - Radio selectors on the right — an always-visible empty circle that fills to a navy check when selected (clearer multi-select than an appear-on-select tick). - "Recent chats" section header; subtitle under each name (Direct message / Group · N members). - Clearer branding: bold white "Share to Biz Connect" on the navy bar. Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked. Co-Authored-By: Claude Opus 4.8 --- mobile/ios-share/ShareViewController.swift | 170 +++++++++++++++------ 1 file changed, 123 insertions(+), 47 deletions(-) diff --git a/mobile/ios-share/ShareViewController.swift b/mobile/ios-share/ShareViewController.swift index 349529e..545421d 100644 --- a/mobile/ios-share/ShareViewController.swift +++ b/mobile/ios-share/ShareViewController.swift @@ -1,4 +1,5 @@ import UIKit +import AVFoundation import UniformTypeIdentifiers // Share Extension — puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way @@ -7,23 +8,30 @@ import UniformTypeIdentifiers // 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. +// /api/messages. +// +// Manifest lifecycle (why the app doesn't also pop a "Send to" sheet): the App Group manifest represents an +// UNSENT share. We only write it when this extension can't send (no token) or a send fails — so a +// successful in-sheet send leaves nothing behind and the app never re-offers it. Cancel/success clear any +// staged files too, so the App Group doesn't accumulate orphans. struct ShareChat { let kind: String // "dm" | "group" let id: String let name: String let avatar: String? + let subtitle: String var key: String { kind + ":" + id } } struct ShareItem { let name: String - let url: URL // staged copy in the App Group container + let url: URL let mime: String let isText: Bool let text: String + var isImage: Bool { mime.hasPrefix("image/") } + var isVideo: Bool { mime.hasPrefix("video/") } } class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { @@ -37,15 +45,16 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD private var items: [ShareItem] = [] private var chats: [ShareChat] = [] private var filtered: [ShareChat] = [] - private var selected = Set() // chat keys chosen for multi-send + private var selected = Set() private var avatarCache: [String: UIImage] = [:] private var sending = false - private let table = UITableView(frame: .zero, style: .plain) + private let table = UITableView(frame: .zero, style: .grouped) private let search = UISearchBar() private let statusLabel = UILabel() private let spinner = UIActivityIndicatorView(style: .medium) - private let subtitle = UILabel() + private let previewStack = UIStackView() + private let previewScroll = UIScrollView() private var sendButton: UIBarButtonItem! // MARK: - Lifecycle @@ -68,21 +77,18 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD // MARK: - UI private func buildUI() { - view.backgroundColor = .systemBackground + view.backgroundColor = .systemGroupedBackground - // 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] + appearance.titleTextAttributes = [.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 17, weight: .bold)] 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 + let navItem = UINavigationItem(title: "Share to Biz Connect") navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) sendButton = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendTapped)) sendButton.isEnabled = false @@ -90,13 +96,17 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD 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) + // Preview strip — thumbnails of what's being shared (like Teams' attachment row). + previewScroll.translatesAutoresizingMaskIntoConstraints = false + previewScroll.showsHorizontalScrollIndicator = false + previewStack.axis = .horizontal + previewStack.spacing = 8 + previewStack.alignment = .center + previewStack.translatesAutoresizingMaskIntoConstraints = false + previewScroll.addSubview(previewStack) + view.addSubview(previewScroll) - search.placeholder = "Search chats…" + search.placeholder = "Search for people or groups" search.delegate = self search.searchBarStyle = .minimal search.translatesAutoresizingMaskIntoConstraints = false @@ -104,8 +114,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD table.dataSource = self table.delegate = self - table.rowHeight = 56 - table.register(UITableViewCell.self, forCellReuseIdentifier: "chat") + table.rowHeight = 60 + table.backgroundColor = .systemGroupedBackground table.translatesAutoresizingMaskIntoConstraints = false view.addSubview(table) @@ -124,16 +134,26 @@ 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: 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), + + previewScroll.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 10), + previewScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + previewScroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + previewScroll.heightAnchor.constraint(equalToConstant: 56), + previewStack.topAnchor.constraint(equalTo: previewScroll.topAnchor), + previewStack.bottomAnchor.constraint(equalTo: previewScroll.bottomAnchor), + previewStack.leadingAnchor.constraint(equalTo: previewScroll.leadingAnchor), + previewStack.trailingAnchor.constraint(equalTo: previewScroll.trailingAnchor), + previewStack.heightAnchor.constraint(equalTo: previewScroll.heightAnchor), + + search.topAnchor.constraint(equalTo: previewScroll.bottomAnchor, constant: 8), 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), @@ -157,7 +177,51 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD sendButton.isEnabled = n > 0 && !sending } - // MARK: - Ingest shared items + // MARK: - Preview thumbnails + + private func buildPreviews() { + for f in items where !f.isText { + let iv = UIImageView() + iv.translatesAutoresizingMaskIntoConstraints = false + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.layer.cornerRadius = 8 + iv.backgroundColor = .tertiarySystemFill + iv.tintColor = .secondaryLabel + iv.widthAnchor.constraint(equalToConstant: 56).isActive = true + iv.heightAnchor.constraint(equalToConstant: 56).isActive = true + iv.image = UIImage(systemName: f.isVideo ? "video.fill" : "doc.fill") + previewStack.addArrangedSubview(iv) + thumbnail(for: f) { img in if let img = img { DispatchQueue.main.async { iv.image = img; iv.contentMode = .scaleAspectFill } } } + } + if items.contains(where: { $0.isText }) { + let lbl = UILabel() + lbl.text = "🔗 link" + lbl.font = .systemFont(ofSize: 13) + lbl.textColor = .secondaryLabel + previewStack.addArrangedSubview(lbl) + } + } + + private func thumbnail(for item: ShareItem, completion: @escaping (UIImage?) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + if item.isImage, let img = UIImage(contentsOfFile: item.url.path) { + return completion(img) + } + if item.isVideo { + let asset = AVURLAsset(url: item.url) + let gen = AVAssetImageGenerator(asset: asset) + gen.appliesPreferredTrackTransform = true + gen.maximumSize = CGSize(width: 168, height: 168) + if let cg = try? gen.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) { + return completion(UIImage(cgImage: cg)) + } + } + completion(nil) + } + } + + // MARK: - Ingest private func ingest() { setStatus("Preparing…", busy: true) @@ -179,10 +243,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD 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("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" + self.buildPreviews() self.loadChats() } } @@ -225,6 +287,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD private func loadChats() { guard !token.isEmpty else { + writeManifest(items) // no token → let the app pick these up setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.") return } @@ -233,12 +296,14 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD guard let self = self else { return } guard let data = data, let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { + self.writeManifest(self.items) self.setStatus("Couldn’t 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) + let sub = kind == "group" ? "Group" + ((row["members"] as? Int).map { " · \($0) members" } ?? "") : "Direct message" + return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String, subtitle: sub) } DispatchQueue.main.async { self.chats = parsed @@ -253,15 +318,26 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count } + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + filtered.isEmpty ? nil : "Recent chats" + } + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath) + let cell = tableView.dequeueReusableCell(withIdentifier: "chat") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "chat") 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.detailTextLabel?.text = c.subtitle + cell.detailTextLabel?.textColor = .secondaryLabel + // Radio selector on the right (empty circle → filled navy check), always visible like Teams. + let selImg = selected.contains(c.key) + ? UIImage(systemName: "checkmark.circle.fill")?.withTintColor(brandNavy, renderingMode: .alwaysOriginal) + : UIImage(systemName: "circle")?.withTintColor(.systemGray3, renderingMode: .alwaysOriginal) + let selView = UIImageView(image: selImg) + selView.frame = CGRect(x: 0, y: 0, width: 26, height: 26) + cell.accessoryView = selView + // Round avatar + cell.imageView?.layer.cornerRadius = 20 cell.imageView?.layer.masksToBounds = true if let img = avatarCache[c.key] { cell.imageView?.image = img @@ -285,7 +361,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD 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)...])), @@ -297,7 +372,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD 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 + if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } 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) @@ -313,7 +388,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD } } - private func initialsImage(_ name: String, size: CGFloat = 36) -> UIImage { + private func initialsImage(_ name: String, size: CGFloat = 40) -> 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) @@ -333,12 +408,11 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD } } - private static func circularImage(from data: Data, size: CGFloat = 36) -> UIImage? { + private static func circularImage(from data: Data, size: CGFloat = 40) -> 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)) @@ -359,7 +433,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD return palette[Int(h % UInt32(palette.count))] } - // MARK: - Send (to every selected chat) + // MARK: - Send @objc private func sendTapped() { guard !sending else { return } @@ -369,6 +443,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD updateSendButton() search.isHidden = true table.isHidden = true + previewScroll.isHidden = true let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats" setStatus("Sending to \(label)…", busy: true) @@ -376,9 +451,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD 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) } @@ -395,16 +467,17 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD } } } - DispatchQueue.main.async { if ok { - self.clearManifest() + self.clearStaged() // sent → leave nothing for the app to re-offer self.setStatus("✓ Sent to \(label)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() } } else { + self.writeManifest(self.items) // failed → let the app pick these up self.sending = false self.search.isHidden = false self.table.isHidden = false + self.previewScroll.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() } @@ -494,6 +567,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD return candidate } + // Written only when this extension can't send — it marks an UNSENT share for the app to pick up. private func writeManifest(_ its: [ShareItem]) { guard let dir = sharedDir() else { return } let records: [[String: Any]] = its.map { it in @@ -505,7 +579,9 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD } } - private func clearManifest() { + // Remove the staged files + any manifest, so the App Group doesn't accumulate and the app has nothing + // to re-offer after a successful send or a cancel. + private func clearStaged() { guard let dir = sharedDir() else { return } let fm = FileManager.default if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) { @@ -521,6 +597,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD // MARK: - Finish @objc private func cancelTapped() { + clearStaged() // nothing sent → don't leave orphans or let the app re-offer extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0)) } @@ -534,7 +611,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD } } -// 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 }