eleven / ocho Public

ios: paste/pick thumbnails on staged chips; own sends always pin (#298)

* ios: paste/pick thumbnails on staged chips; own sends always pin

- Staged image chips show a real thumbnail (pastes and picker choices;
  dimmed while uploading) instead of just the filename.
- Sending while flush at the bottom left the new message behind the
  composer: the reply banner collapsing at send shifts the inset under
  the scroll anchor, so the skip-when-flush gate now applies only to
  arrivals — your own send always pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* core,mac,ios: whisper-level wave; tame the post-search jump

The crash after a search jump left no report — the watchdog-kill
signature. Animating scrollTo across hundreds of freshly-paged
estimated rows is a main-thread layout storm: far jumps now snap
un-animated (near ones keep the glide), paging is bounded at 8 pages,
and a not-found target just stays put. The wave drops to ±1.2°/±1.6pt
— one shy minion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: the share sheet sends directly — chat picker in the extension

Sharing to Eleven now shows the chat list RIGHT IN the sheet (grouped
by server) and sends from there: LChatCore's ThreadStore/ChatFeedModel
run inside the extension, identities come from the shared keychain
group (the NSE pattern — Keychain gains group-first reads with
fallback), files encrypt + upload as usual, and the message goes out
over a new REST send (ChatFeedModel.sendViaREST — the extension has no
socket; the hub broadcasts identically). Attachments are parked in the
app-group inbox first, so Cancel or a failed send falls back to the
in-app picker on next open and nothing shared is ever lost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 51f8d74b49fbbfb6e0b5dc7e89a48354b6e4857b parent 7ccd623
8 files changed, +280 −78
  • ios/LChat/Views/NativeChatScreen.swift +37 −8
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +25 −0
  • ios/LChatCore/Sources/LChatCore/JumpWave.swift +10 −10
  • ios/LChatCore/Sources/LChatCore/Keychain.swift +30 −15
  • ios/LChatMac/NativeFeedView.swift +2 −1
  • ios/LChatShare/LChatShare.entitlements +4 −0
  • ios/LChatShare/ShareViewController.swift +170 −44
  • ios/project.yml +2 −0
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index aa72a89..d68101f 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -73,6 +73,7 @@ struct NativeChatScreen: View {
struct StagedUpload: Identifiable {
let id = UUID()
let name: String
+ var preview: UIImage? // image picks/pastes show a real thumbnail
var progress = 0.0
var attachment: Attachment?
}
@@ -244,13 +245,16 @@ struct NativeChatScreen: View {
Self.pendingJump = nil
settled = true
Task {
+ // Bounded: a hit deeper than ~8 pages stays un-jumped rather
+ // than risking a watchdog-length layout storm.
var tries = 0
while !model.messages.contains(where: { $0.id == j.message }),
- !model.exhausted, tries < 20 {
+ !model.exhausted, tries < 8 {
tries += 1
await model.loadOlder()
try? await Task.sleep(nanoseconds: 550_000_000)
}
+ guard model.messages.contains(where: { $0.id == j.message }) else { return }
try? await Task.sleep(nanoseconds: 150_000_000)
jumpAndWave(j.message, proxy: proxy)
}
@@ -351,10 +355,12 @@ struct NativeChatScreen: View {
// keyboard is up: composing means you're at the conversation's
// tail, and the bottom anchor doesn't hold through keyboard
// insets, so replies were landing hidden behind the keyboard.
- if id != nil, model.messages.last?.mine == true || composerFocused,
- !bottomVisible {
- // (Skipped when already flush — the anchor holds through
- // growth by itself, and the pin is a main-thread stall.)
+ // Your OWN send always pins — the reply banner collapsing at
+ // send shifts the inset under the anchor, which left the new
+ // message hiding behind the composer. Arrivals while composing
+ // keep the cheap skip-when-flush gate.
+ let mine = model.messages.last?.mine == true
+ if id != nil, mine || (composerFocused && !bottomVisible) {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
}
@@ -508,7 +514,16 @@ struct NativeChatScreen: View {
// Scroll to a message and wave it — quote-taps and search hits share this.
private func jumpAndWave(_ id: Int64, proxy: ScrollViewProxy) {
- withAnimation { proxy.scrollTo(id, anchor: .center) }
+ // Animated only for NEAR jumps: animating across hundreds of lazily
+ // estimated rows is a main-thread layout storm (the post-search
+ // watchdog kill); far targets snap instantly, then wave.
+ let distance = model.messages.count
+ - (model.messages.firstIndex { $0.id == id } ?? model.messages.count)
+ if distance <= 60 {
+ withAnimation { proxy.scrollTo(id, anchor: .center) }
+ } else {
+ proxy.scrollTo(id, anchor: .center)
+ }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
danceID = id
danceTick += 1
@@ -797,9 +812,16 @@ struct NativeChatScreen: View {
HStack(spacing: 6) {
ForEach(staged) { s in
HStack(spacing: 5) {
+ if let preview = s.preview {
+ Image(uiImage: preview)
+ .resizable().scaledToFill()
+ .frame(width: 26, height: 26)
+ .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+ .opacity(s.attachment == nil ? 0.55 : 1)
+ }
if s.attachment == nil {
ProgressView(value: s.progress).frame(width: 40)
- } else {
+ } else if s.preview == nil {
Text(fileIcon(s.name)).font(.system(size: 13))
}
Text(s.name).font(.system(size: 12)).lineLimit(1)
@@ -833,6 +855,10 @@ struct NativeChatScreen: View {
staged.removeAll { $0.id == entry.id }
return
}
+ if !isVideo, let thumb = processImage(data, maxDim: 288),
+ let i = staged.firstIndex(where: { $0.id == entry.id }) {
+ staged[i].preview = UIImage(data: thumb.bytes)
+ }
let att = isVideo
? await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress)
: await model.uploadImage(data, name: name, onProgress: onProgress)
@@ -846,7 +872,10 @@ struct NativeChatScreen: View {
// Stage raw image bytes (paste / share-sheet handoff) as an upload.
private func stageImageData(_ data: Data, name: String = "pasted.jpg") {
- let entry = StagedUpload(name: name)
+ var entry = StagedUpload(name: name)
+ if let thumb = processImage(data, maxDim: 288) {
+ entry.preview = UIImage(data: thumb.bytes)
+ }
staged.append(entry)
let onProgress: @MainActor (Double) -> Void = { p in
if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p }
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 8c1a379..db24a55 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -420,6 +420,31 @@ public final class ChatFeedModel: ObservableObject {
return sent
}
+ /// Send over REST — the share extension has no WebSocket. Same encrypted
+ /// payload as send(); the hub still broadcasts to every member.
+ public func sendViaREST(_ text: String, attachments: [Attachment]) async -> Bool {
+ guard let key, let base = instance.baseURL,
+ !text.isEmpty || !attachments.isEmpty else { return false }
+ var payload = Outgoing(text: emojify(text), name: instance.screenName,
+ ts: Int64(Date().timeIntervalSince1970 * 1000))
+ if !myUserId.isEmpty { payload.uid = myUserId }
+ if !attachments.isEmpty { payload.attachments = attachments }
+ guard let blob = try? key.encryptJSON(payload),
+ var comps = URLComponents(url: base.appending(path: "api/messages"),
+ resolvingAgainstBaseURL: false) else { return false }
+ comps.queryItems = [URLQueryItem(name: "token", value: instance.deviceToken)]
+ guard let url = comps.url,
+ let body = try? JSONSerialization.data(withJSONObject:
+ ["thread_id": threadID, "blob": blob]) else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = body
+ guard let (_, resp) = try? await URLSession.shared.data(for: req),
+ let http = resp as? HTTPURLResponse else { return false }
+ return http.statusCode < 300
+ }
+
private func loadHistory(_ api: LChatAPI) async {
// INSTANT open: the local store (decrypted once, at ingest) renders
// before the network answers; the fetch below then converges by
diff --git a/ios/LChatCore/Sources/LChatCore/JumpWave.swift b/ios/LChatCore/Sources/LChatCore/JumpWave.swift
index a74c174..6892bf9 100644
--- a/ios/LChatCore/Sources/LChatCore/JumpWave.swift
+++ b/ios/LChatCore/Sources/LChatCore/JumpWave.swift
@@ -27,19 +27,19 @@ public struct JumpWave: ViewModifier {
} keyframes: { _ in
KeyframeTrack(\.angle) { // a gentle side-to-side wag…
CubicKeyframe(0, duration: 0.10)
- CubicKeyframe(2.0, duration: 0.14)
- CubicKeyframe(-2.0, duration: 0.16)
- CubicKeyframe(1.4, duration: 0.16)
- CubicKeyframe(-0.8, duration: 0.16)
- CubicKeyframe(0, duration: 0.22)
+ CubicKeyframe(1.2, duration: 0.15)
+ CubicKeyframe(-1.2, duration: 0.17)
+ CubicKeyframe(0.8, duration: 0.17)
+ CubicKeyframe(-0.4, duration: 0.17)
+ CubicKeyframe(0, duration: 0.24)
}
KeyframeTrack(\.sway) { // …with the body following
CubicKeyframe(0, duration: 0.10)
- CubicKeyframe(3, duration: 0.14)
- CubicKeyframe(-3, duration: 0.16)
- CubicKeyframe(2, duration: 0.16)
- CubicKeyframe(-1, duration: 0.16)
- CubicKeyframe(0, duration: 0.22)
+ CubicKeyframe(1.6, duration: 0.15)
+ CubicKeyframe(-1.6, duration: 0.17)
+ CubicKeyframe(1.0, duration: 0.17)
+ CubicKeyframe(-0.5, duration: 0.17)
+ CubicKeyframe(0, duration: 0.24)
}
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/Keychain.swift b/ios/LChatCore/Sources/LChatCore/Keychain.swift
index 1ec3bff..6cfed30 100644
--- a/ios/LChatCore/Sources/LChatCore/Keychain.swift
+++ b/ios/LChatCore/Sources/LChatCore/Keychain.swift
@@ -12,22 +12,30 @@ import Security
// flag is a no-op; the data-protection keychain is already the only one.)
public enum Keychain {
private static let service = "com.lchat.client"
+ // The app-family keychain group (same as SharedThreadKeys): lets the
+ // share extension read identities to send directly. Writes try the group
+ // first and fall back without it (ad-hoc dev builds lack the entitlement).
+ private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
public static func load<T: Decodable>(_ type: T.Type, account: String) -> T? {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- kSecUseDataProtectionKeychain as String: true,
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne,
- ]
- var item: CFTypeRef?
- guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
- let data = item as? Data,
- let value = try? JSONDecoder().decode(T.self, from: data)
- else { return nil }
- return value
+ for withGroup in [true, false] {
+ var query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ if withGroup { query[kSecAttrAccessGroup as String] = accessGroup }
+ var item: CFTypeRef?
+ if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data,
+ let value = try? JSONDecoder().decode(T.self, from: data) {
+ return value
+ }
+ }
+ return nil
}
/// Returns whether the item actually landed in the keychain — ad-hoc dev
@@ -42,8 +50,15 @@ public enum Keychain {
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
+ var grouped = base
+ grouped[kSecAttrAccessGroup as String] = accessGroup
+ SecItemDelete(grouped as CFDictionary)
SecItemDelete(base as CFDictionary)
- var add = base
+ var add = grouped
+ add[kSecValueData as String] = data
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ if SecItemAdd(add as CFDictionary, nil) == errSecSuccess { return true }
+ add = base
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 8115931..9c9029e 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -1018,11 +1018,12 @@ struct NativeFeedView: View {
Task {
var tries = 0
while !model.messages.contains(where: { $0.id == j.message }),
- !model.exhausted, tries < 20 {
+ !model.exhausted, tries < 8 {
tries += 1
await model.loadOlder()
try? await Task.sleep(nanoseconds: 550_000_000) // loadOlder's cooldown
}
+ guard model.messages.contains(where: { $0.id == j.message }) else { return }
try? await Task.sleep(nanoseconds: 150_000_000) // let rows land
jump(to: j.message, proxy: proxy)
}
diff --git a/ios/LChatShare/LChatShare.entitlements b/ios/LChatShare/LChatShare.entitlements
index 86255c6..adf9505 100644
--- a/ios/LChatShare/LChatShare.entitlements
+++ b/ios/LChatShare/LChatShare.entitlements
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
+ <key>keychain-access-groups</key>
+ <array>
+ <string>$(AppIdentifierPrefix)com.elevenmessenger.messenger.shared</string>
+ </array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.elevenmessenger.messenger</string>
diff --git a/ios/LChatShare/ShareViewController.swift b/ios/LChatShare/ShareViewController.swift
index ede106a..8595d53 100644
--- a/ios/LChatShare/ShareViewController.swift
+++ b/ios/LChatShare/ShareViewController.swift
@@ -1,79 +1,205 @@
+import LChatCore
+import SwiftUI
import UIKit
import UniformTypeIdentifiers
-// 🤖 The share-sheet receiver: images (and a video) shared from Photos or
-// anywhere land in an app-group inbox; the main app notices on next
-// activation and asks which chat to send them to. The extension itself never
-// touches keys or the network — it only ferries bytes across the app-group
-// boundary, so the E2E posture is unchanged (encryption happens in the app,
-// exactly as if the image had been picked from the composer).
+// 🤖 The share-sheet receiver, direct-send edition: shared images/videos get a
+// native chat picker RIGHT IN the sheet — pick a chat and the files encrypt,
+// upload and send from here (LChatCore: the same ThreadStore/ChatFeedModel the
+// app uses; identities come from the shared keychain group, like the NSE's
+// thread keys). Files are also parked in the app-group inbox first, so if the
+// user cancels or a send fails, opening Eleven offers the picker as a fallback
+// and nothing shared is ever lost.
final class ShareViewController: UIViewController {
static let appGroup = "group.com.elevenmessenger.messenger"
- private let label = UILabel()
-
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
- label.text = "Saving…"
- label.font = .preferredFont(forTextStyle: .headline)
- label.textAlignment = .center
- label.translatesAutoresizingMaskIntoConstraints = false
- view.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
- label.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 20),
- ])
- Task { await ingest() }
+ Task { await start() }
+ }
+
+ private func start() async {
+ let files = await ingest()
+ let picker = SharePickerView(files: files) { [weak self] outcome in
+ switch outcome {
+ case .sent(let urls):
+ ShareInboxLite.clear(urls)
+ self?.extensionContext?.completeRequest(returningItems: nil)
+ case .cancelled:
+ // Keep the inbox copies — the app offers its picker next open.
+ self?.extensionContext?.completeRequest(returningItems: nil)
+ }
+ }
+ let host = UIHostingController(rootView: picker)
+ addChild(host)
+ host.view.frame = view.bounds
+ host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ view.addSubview(host.view)
+ host.didMove(toParent: self)
}
- private func ingest() async {
+ // Park every attachment in the app-group inbox (the no-data-loss floor).
+ private func ingest() async -> [URL] {
guard let inbox = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)?
- .appendingPathComponent("share-inbox", isDirectory: true) else {
- finish("Couldn't save — open Eleven and try attaching instead.")
- return
- }
+ .appendingPathComponent("share-inbox", isDirectory: true) else { return [] }
try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
- var saved = 0
+ var saved: [URL] = []
for item in extensionContext?.inputItems.compactMap({ $0 as? NSExtensionItem }) ?? [] {
for provider in item.attachments ?? [] {
for type in [UTType.image, UTType.movie] where
provider.hasItemConformingToTypeIdentifier(type.identifier) {
- if await save(provider, as: type, into: inbox) { saved += 1 }
+ if let url = await save(provider, as: type, into: inbox) { saved.append(url) }
break
}
}
}
- finish(saved > 0 ? "Saved — open Eleven to choose a chat."
- : "Nothing shareable found.")
+ return saved
}
- private func save(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> Bool {
+ private func save(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> URL? {
await withCheckedContinuation { cont in
provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, _ in
- guard let url else {
- // Some sources hand data, not files (e.g. screenshots).
- provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
- guard let data else { return cont.resume(returning: false) }
- let ext = type == .movie ? "mp4" : "jpg"
- let dest = inbox.appendingPathComponent(UUID().uuidString + "." + ext)
- cont.resume(returning: (try? data.write(to: dest)) != nil)
- }
+ if let url {
+ let dest = inbox.appendingPathComponent(
+ UUID().uuidString + "-" + url.lastPathComponent)
+ cont.resume(returning:
+ (try? FileManager.default.copyItem(at: url, to: dest)) != nil ? dest : nil)
return
}
- let dest = inbox.appendingPathComponent(
- UUID().uuidString + "-" + url.lastPathComponent)
- cont.resume(returning: (try? FileManager.default.copyItem(at: url, to: dest)) != nil)
+ provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
+ guard let data else { return cont.resume(returning: nil) }
+ let ext = type == .movie ? "mp4" : "jpg"
+ let dest = inbox.appendingPathComponent(UUID().uuidString + "." + ext)
+ cont.resume(returning: (try? data.write(to: dest)) != nil ? dest : nil)
+ }
+ }
+ }
+ }
+}
+
+// The extension's own copy of the inbox cleaner (the app has ShareInbox).
+enum ShareInboxLite {
+ static func clear(_ urls: [URL]) {
+ for url in urls { try? FileManager.default.removeItem(at: url) }
+ }
+}
+
+enum ShareOutcome {
+ case sent([URL])
+ case cancelled
+}
+
+struct SharePickerView: View {
+ let files: [URL]
+ let done: (ShareOutcome) -> Void
+
+ struct Row: Identifiable {
+ let instance: SavedInstance
+ let thread: ChatThread
+ let myUserId: String
+ let serverLabel: String
+ var id: String { instance.id + "|" + thread.id }
+ }
+
+ @State private var rows: [Row] = []
+ @State private var loading = true
+ @State private var sendingID: String?
+ @State private var status: String?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if loading {
+ ProgressView("Loading chats…")
+ } else if rows.isEmpty {
+ Text(status ?? "No chats yet — open Eleven first.")
+ .foregroundStyle(.secondary)
+ .padding()
+ } else {
+ List(rows) { row in
+ Button { send(row) } label: {
+ HStack(spacing: 10) {
+ ZStack {
+ Circle().fill(.tint.opacity(0.15))
+ Text(String(row.thread.title.prefix(1)).uppercased())
+ .font(.system(size: 14, weight: .semibold))
+ }
+ .frame(width: 34, height: 34)
+ VStack(alignment: .leading, spacing: 0) {
+ Text(row.thread.title)
+ .font(.body.weight(.medium)).lineLimit(1)
+ Text(row.serverLabel)
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ if sendingID == row.id { ProgressView() }
+ }
+ .contentShape(Rectangle())
+ }
+ .foregroundStyle(.primary)
+ .disabled(sendingID != nil)
+ }
+ }
+ }
+ .navigationTitle(files.count == 1 ? "Send photo to…" : "Send \(files.count) items to…")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { done(.cancelled) }.disabled(sendingID != nil)
+ }
}
+ .overlay(alignment: .bottom) {
+ if let status, sendingID != nil {
+ Text(status).font(.footnote).foregroundStyle(.secondary).padding(8)
+ }
+ }
+ }
+ .task { await loadChats() }
+ }
+
+ @MainActor private func loadChats() async {
+ for instance in InstancesStore.load() {
+ let store = ThreadStore(instance: instance)
+ await store.refresh()
+ let label = instance.displayName
+ rows += store.threads
+ .filter { !$0.hidden && !$0.isApp }
+ .map { Row(instance: instance, thread: $0,
+ myUserId: store.myUserId, serverLabel: label) }
}
+ loading = false
+ if rows.isEmpty { status = "Couldn't load chats — open Eleven to send instead." }
}
- private func finish(_ message: String) {
- label.text = message
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) { [weak self] in
- self?.extensionContext?.completeRequest(returningItems: nil)
+ @MainActor private func send(_ row: Row) {
+ sendingID = row.id
+ status = "Encrypting & uploading…"
+ Task {
+ let model = ChatFeedModel(instance: row.instance, thread: row.thread,
+ myUserId: row.myUserId)
+ var attachments: [Attachment] = []
+ for url in files {
+ guard let data = try? Data(contentsOf: url) else { continue }
+ let ext = url.pathExtension.lowercased()
+ let att: Attachment?
+ if ["mp4", "mov", "m4v"].contains(ext) {
+ att = await model.uploadVideo(data, name: url.lastPathComponent,
+ mimeType: "video/mp4")
+ } else {
+ att = await model.uploadImage(data, name: url.lastPathComponent)
+ }
+ if let att { attachments.append(att) }
+ }
+ guard !attachments.isEmpty,
+ await model.sendViaREST("", attachments: attachments) else {
+ sendingID = nil
+ status = "Couldn't send — open Eleven to try again."
+ return
+ }
+ status = "Sent"
+ done(.sent(files))
}
}
}
diff --git a/ios/project.yml b/ios/project.yml
index 18041bf..561eb82 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -165,6 +165,8 @@ targets:
platform: iOS
sources:
- path: LChatShare
+ dependencies:
+ - package: LChatCore
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: $(LCHAT_APP_BUNDLE_ID).share