ee95f594c6
The top-bar buttons looked heavy against the navy bar (thick grey X-circle, bold "Send" pill). Swapped for light SF Symbols on the navy bar: a thin `xmark` for cancel and a `paperplane.fill` for send (semibold, enables when ≥1 chat is picked). Icon-only send matches the Teams reference — the radio checks already show what's selected, so the "(N)" count text is dropped. Native-only — needs a build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
629 lines
28 KiB
Swift
629 lines
28 KiB
Swift
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
|
||
// 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.
|
||
//
|
||
// 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
|
||
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 {
|
||
|
||
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>()
|
||
private var avatarCache: [String: UIImage] = [:]
|
||
private var sending = false
|
||
|
||
private let table = UITableView(frame: .zero, style: .grouped)
|
||
private let search = UISearchBar()
|
||
private let statusLabel = UILabel()
|
||
private let spinner = UIActivityIndicatorView(style: .medium)
|
||
private let previewStack = UIStackView()
|
||
private let previewScroll = UIScrollView()
|
||
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 = .systemGroupedBackground
|
||
|
||
let nav = UINavigationBar()
|
||
nav.translatesAutoresizingMaskIntoConstraints = false
|
||
let appearance = UINavigationBarAppearance()
|
||
appearance.configureWithOpaqueBackground()
|
||
appearance.backgroundColor = brandNavy
|
||
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: "Share to Biz Connect")
|
||
// Clean, light SF Symbols instead of a heavy grey X-circle / bold pill (thin xmark + paper-plane).
|
||
let symCfg = UIImage.SymbolConfiguration(pointSize: 16, weight: .regular)
|
||
navItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark", withConfiguration: symCfg), style: .plain, target: self, action: #selector(cancelTapped))
|
||
let sendCfg = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
||
sendButton = UIBarButtonItem(image: UIImage(systemName: "paperplane.fill", withConfiguration: sendCfg), style: .plain, target: self, action: #selector(sendTapped))
|
||
sendButton.isEnabled = false
|
||
navItem.rightBarButtonItem = sendButton
|
||
nav.setItems([navItem], animated: false)
|
||
view.addSubview(nav)
|
||
|
||
// 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 for people or groups"
|
||
search.delegate = self
|
||
search.searchBarStyle = .minimal
|
||
search.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(search)
|
||
|
||
table.dataSource = self
|
||
table.delegate = self
|
||
table.rowHeight = 60
|
||
table.backgroundColor = .systemGroupedBackground
|
||
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),
|
||
|
||
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),
|
||
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() {
|
||
// Icon-only paper-plane (like Teams); the radio checks show what's selected. Just enable/disable.
|
||
sendButton.isEnabled = !selected.isEmpty && !sending
|
||
}
|
||
|
||
// 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)
|
||
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
|
||
if collected.isEmpty { return self.finishFail("Couldn’t read the shared file.") }
|
||
self.buildPreviews()
|
||
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 {
|
||
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
|
||
}
|
||
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.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 }
|
||
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
|
||
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, titleForHeaderInSection section: Int) -> String? {
|
||
filtered.isEmpty ? nil : "Recent chats"
|
||
}
|
||
|
||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
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.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
|
||
} 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 }
|
||
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") }
|
||
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 = 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)
|
||
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 = 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()
|
||
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
|
||
|
||
@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
|
||
previewScroll.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
|
||
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.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() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
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() {
|
||
clearStaged() // nothing sent → don't leave orphans or let the app re-offer
|
||
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() }
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|