feat(share-ext): fix double "Send to", Teams-style layout, clearer branding
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
// Share Extension — puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way
|
// 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
|
// 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
|
// (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 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
|
// /api/messages.
|
||||||
// opening the app later picks them up.
|
//
|
||||||
|
// 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 {
|
struct ShareChat {
|
||||||
let kind: String // "dm" | "group"
|
let kind: String // "dm" | "group"
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let avatar: String?
|
let avatar: String?
|
||||||
|
let subtitle: String
|
||||||
var key: String { kind + ":" + id }
|
var key: String { kind + ":" + id }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ShareItem {
|
struct ShareItem {
|
||||||
let name: String
|
let name: String
|
||||||
let url: URL // staged copy in the App Group container
|
let url: URL
|
||||||
let mime: String
|
let mime: String
|
||||||
let isText: Bool
|
let isText: Bool
|
||||||
let text: String
|
let text: String
|
||||||
|
var isImage: Bool { mime.hasPrefix("image/") }
|
||||||
|
var isVideo: Bool { mime.hasPrefix("video/") }
|
||||||
}
|
}
|
||||||
|
|
||||||
class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
@@ -37,15 +45,16 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
private var items: [ShareItem] = []
|
private var items: [ShareItem] = []
|
||||||
private var chats: [ShareChat] = []
|
private var chats: [ShareChat] = []
|
||||||
private var filtered: [ShareChat] = []
|
private var filtered: [ShareChat] = []
|
||||||
private var selected = Set<String>() // chat keys chosen for multi-send
|
private var selected = Set<String>()
|
||||||
private var avatarCache: [String: UIImage] = [:]
|
private var avatarCache: [String: UIImage] = [:]
|
||||||
private var sending = false
|
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 search = UISearchBar()
|
||||||
private let statusLabel = UILabel()
|
private let statusLabel = UILabel()
|
||||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
private let subtitle = UILabel()
|
private let previewStack = UIStackView()
|
||||||
|
private let previewScroll = UIScrollView()
|
||||||
private var sendButton: UIBarButtonItem!
|
private var sendButton: UIBarButtonItem!
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
@@ -68,21 +77,18 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
private func buildUI() {
|
private func buildUI() {
|
||||||
view.backgroundColor = .systemBackground
|
view.backgroundColor = .systemGroupedBackground
|
||||||
|
|
||||||
// Branded navigation bar (Biz Connect navy, white title + buttons).
|
|
||||||
let nav = UINavigationBar()
|
let nav = UINavigationBar()
|
||||||
nav.translatesAutoresizingMaskIntoConstraints = false
|
nav.translatesAutoresizingMaskIntoConstraints = false
|
||||||
let appearance = UINavigationBarAppearance()
|
let appearance = UINavigationBarAppearance()
|
||||||
appearance.configureWithOpaqueBackground()
|
appearance.configureWithOpaqueBackground()
|
||||||
appearance.backgroundColor = brandNavy
|
appearance.backgroundColor = brandNavy
|
||||||
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
|
appearance.titleTextAttributes = [.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 17, weight: .bold)]
|
||||||
appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
|
|
||||||
nav.standardAppearance = appearance
|
nav.standardAppearance = appearance
|
||||||
nav.scrollEdgeAppearance = appearance
|
nav.scrollEdgeAppearance = appearance
|
||||||
nav.tintColor = .white
|
nav.tintColor = .white
|
||||||
let navItem = UINavigationItem(title: "Send to…")
|
let navItem = UINavigationItem(title: "Share to Biz Connect")
|
||||||
navItem.prompt = "Biz Connect" // small brand line above the title
|
|
||||||
navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
|
navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
|
||||||
sendButton = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendTapped))
|
sendButton = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendTapped))
|
||||||
sendButton.isEnabled = false
|
sendButton.isEnabled = false
|
||||||
@@ -90,13 +96,17 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
nav.setItems([navItem], animated: false)
|
nav.setItems([navItem], animated: false)
|
||||||
view.addSubview(nav)
|
view.addSubview(nav)
|
||||||
|
|
||||||
subtitle.font = .systemFont(ofSize: 12)
|
// Preview strip — thumbnails of what's being shared (like Teams' attachment row).
|
||||||
subtitle.textColor = .secondaryLabel
|
previewScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
subtitle.textAlignment = .center
|
previewScroll.showsHorizontalScrollIndicator = false
|
||||||
subtitle.translatesAutoresizingMaskIntoConstraints = false
|
previewStack.axis = .horizontal
|
||||||
view.addSubview(subtitle)
|
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.delegate = self
|
||||||
search.searchBarStyle = .minimal
|
search.searchBarStyle = .minimal
|
||||||
search.translatesAutoresizingMaskIntoConstraints = false
|
search.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -104,8 +114,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
|
|
||||||
table.dataSource = self
|
table.dataSource = self
|
||||||
table.delegate = self
|
table.delegate = self
|
||||||
table.rowHeight = 56
|
table.rowHeight = 60
|
||||||
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat")
|
table.backgroundColor = .systemGroupedBackground
|
||||||
table.translatesAutoresizingMaskIntoConstraints = false
|
table.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(table)
|
view.addSubview(table)
|
||||||
|
|
||||||
@@ -124,16 +134,26 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
subtitle.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 4),
|
|
||||||
subtitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
previewScroll.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 10),
|
||||||
subtitle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
previewScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||||
search.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
|
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.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6),
|
||||||
search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),
|
search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),
|
||||||
|
|
||||||
table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2),
|
table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2),
|
||||||
table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
table.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
table.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
|
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
|
||||||
@@ -157,7 +177,51 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
sendButton.isEnabled = n > 0 && !sending
|
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() {
|
private func ingest() {
|
||||||
setStatus("Preparing…", busy: true)
|
setStatus("Preparing…", busy: true)
|
||||||
@@ -179,10 +243,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
group.notify(queue: .main) { [weak self] in
|
group.notify(queue: .main) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.items = collected
|
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.") }
|
if collected.isEmpty { return self.finishFail("Couldn’t read the shared file.") }
|
||||||
let fileCount = collected.filter { !$0.isText }.count
|
self.buildPreviews()
|
||||||
self.subtitle.text = fileCount > 0 ? "Sharing \(fileCount) file\(fileCount == 1 ? "" : "s")" : "Sharing a link"
|
|
||||||
self.loadChats()
|
self.loadChats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +287,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
|
|
||||||
private func loadChats() {
|
private func loadChats() {
|
||||||
guard !token.isEmpty else {
|
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.")
|
setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,12 +296,14 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard let data = data,
|
guard let data = data,
|
||||||
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
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.")
|
self.setStatus("Couldn’t load your chats.\n\nOpen Biz Connect once, then try sharing again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let parsed: [ShareChat] = arr.compactMap { row in
|
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 }
|
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 {
|
DispatchQueue.main.async {
|
||||||
self.chats = parsed
|
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, 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 {
|
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]
|
let c = filtered[indexPath.row]
|
||||||
cell.textLabel?.text = c.name
|
cell.textLabel?.text = c.name
|
||||||
cell.textLabel?.font = .systemFont(ofSize: 16)
|
cell.textLabel?.font = .systemFont(ofSize: 16)
|
||||||
cell.accessoryType = selected.contains(c.key) ? .checkmark : .none
|
cell.detailTextLabel?.text = c.subtitle
|
||||||
cell.tintColor = brandNavy
|
cell.detailTextLabel?.textColor = .secondaryLabel
|
||||||
// Round avatar: cached image, else initials placeholder + async load.
|
// Radio selector on the right (empty circle → filled navy check), always visible like Teams.
|
||||||
cell.imageView?.layer.cornerRadius = 18
|
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
|
cell.imageView?.layer.masksToBounds = true
|
||||||
if let img = avatarCache[c.key] {
|
if let img = avatarCache[c.key] {
|
||||||
cell.imageView?.image = img
|
cell.imageView?.image = img
|
||||||
@@ -285,7 +361,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
|
|
||||||
private func loadAvatar(for chat: ShareChat) {
|
private func loadAvatar(for chat: ShareChat) {
|
||||||
guard let av = chat.avatar, !av.isEmpty else { return }
|
guard let av = chat.avatar, !av.isEmpty else { return }
|
||||||
// Data URL → decode inline.
|
|
||||||
if av.hasPrefix("data:") {
|
if av.hasPrefix("data:") {
|
||||||
if let comma = av.firstIndex(of: ","),
|
if let comma = av.firstIndex(of: ","),
|
||||||
let d = Data(base64Encoded: String(av[av.index(after: comma)...])),
|
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
|
let urlStr = av.hasPrefix("/") ? (apiBase + av) : av
|
||||||
guard let url = URL(string: urlStr) else { return }
|
guard let url = URL(string: urlStr) else { return }
|
||||||
var req = URLRequest(url: url)
|
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
|
URLSession.shared.dataTask(with: req) { [weak self] d, _, _ in
|
||||||
guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return }
|
guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return }
|
||||||
self.cache(img, for: chat)
|
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 parts = name.split(separator: " ")
|
||||||
let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased()
|
let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased()
|
||||||
let bg = Self.color(for: name)
|
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 }
|
guard let img = UIImage(data: data) else { return nil }
|
||||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
|
||||||
return renderer.image { _ in
|
return renderer.image { _ in
|
||||||
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).addClip()
|
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 scale = max(size / img.size.width, size / img.size.height)
|
||||||
let w = img.size.width * scale, h = img.size.height * scale
|
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))
|
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))]
|
return palette[Int(h % UInt32(palette.count))]
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Send (to every selected chat)
|
// MARK: - Send
|
||||||
|
|
||||||
@objc private func sendTapped() {
|
@objc private func sendTapped() {
|
||||||
guard !sending else { return }
|
guard !sending else { return }
|
||||||
@@ -369,6 +443,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
updateSendButton()
|
updateSendButton()
|
||||||
search.isHidden = true
|
search.isHidden = true
|
||||||
table.isHidden = true
|
table.isHidden = true
|
||||||
|
previewScroll.isHidden = true
|
||||||
let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
|
let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
|
||||||
setStatus("Sending to \(label)…", busy: true)
|
setStatus("Sending to \(label)…", busy: true)
|
||||||
|
|
||||||
@@ -376,9 +451,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
let files = self.items.filter { !$0.isText }
|
let files = self.items.filter { !$0.isText }
|
||||||
let texts = self.items.filter { $0.isText }.map { $0.text }
|
let texts = self.items.filter { $0.isText }.map { $0.text }
|
||||||
var ok = true
|
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] = []
|
var attIds: [String] = []
|
||||||
for (i, f) in files.enumerated() {
|
for (i, f) in files.enumerated() {
|
||||||
if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)…", busy: true) }
|
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 {
|
DispatchQueue.main.async {
|
||||||
if ok {
|
if ok {
|
||||||
self.clearManifest()
|
self.clearStaged() // sent → leave nothing for the app to re-offer
|
||||||
self.setStatus("✓ Sent to \(label)")
|
self.setStatus("✓ Sent to \(label)")
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
|
||||||
} else {
|
} else {
|
||||||
|
self.writeManifest(self.items) // failed → let the app pick these up
|
||||||
self.sending = false
|
self.sending = false
|
||||||
self.search.isHidden = false
|
self.search.isHidden = false
|
||||||
self.table.isHidden = false
|
self.table.isHidden = false
|
||||||
|
self.previewScroll.isHidden = false
|
||||||
self.updateSendButton()
|
self.updateSendButton()
|
||||||
self.setStatus("Couldn’t send. Your file is saved — open Biz Connect to send it.")
|
self.setStatus("Couldn’t send. Your file is saved — open Biz Connect to send it.")
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() }
|
||||||
@@ -494,6 +567,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
return candidate
|
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]) {
|
private func writeManifest(_ its: [ShareItem]) {
|
||||||
guard let dir = sharedDir() else { return }
|
guard let dir = sharedDir() else { return }
|
||||||
let records: [[String: Any]] = its.map { it in
|
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 }
|
guard let dir = sharedDir() else { return }
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
|
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
|
||||||
@@ -521,6 +597,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
|
|||||||
// MARK: - Finish
|
// MARK: - Finish
|
||||||
|
|
||||||
@objc private func cancelTapped() {
|
@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))
|
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? {
|
private func idString(_ v: Any?) -> String? {
|
||||||
if let s = v as? String { return s }
|
if let s = v as? String { return s }
|
||||||
if let n = v as? NSNumber { return n.stringValue }
|
if let n = v as? NSNumber { return n.stringValue }
|
||||||
|
|||||||
Reference in New Issue
Block a user