4d446a5425
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>
551 lines
24 KiB
Swift
551 lines
24 KiB
Swift
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("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.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("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)
|
||
}
|
||
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("Couldn’t 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()
|
||
}
|
||
}
|