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>
8 files changed,
+280
−78
ios/LChat/Views/NativeChatScreen.swift+37 −8ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+25 −0ios/LChatCore/Sources/LChatCore/JumpWave.swift+10 −10ios/LChatCore/Sources/LChatCore/Keychain.swift+30 −15ios/LChatMac/NativeFeedView.swift+2 −1ios/LChatShare/LChatShare.entitlements+4 −0ios/LChatShare/ShareViewController.swift+170 −44ios/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 |