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 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<String>() // chat keys chosen for multi-send
|
||||
private var selected = Set<String>()
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user