import LChatCore import SwiftUI import UIKit import UniformTypeIdentifiers // 🤖 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" override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground 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) } // 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 { return [] } try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true) 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 let url = await save(provider, as: type, into: inbox) { saved.append(url) } break } } } return saved } // Prefer a file representation (keeps the original filename); fall back to // raw data only when NO file was offered — a file that WAS offered but // couldn't be copied is a hard failure, not a data retry (original // behaviour). The two loads are separate awaited steps rather than nested, // so the fallback never sits inside — and captures `provider` in — the // first load's @Sendable completion closure (NSItemProvider isn't Sendable). private func save(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> URL? { switch await loadFile(provider, as: type, into: inbox) { case .saved(let dest): return dest case .failed: return nil case .absent: return await loadData(provider, as: type, into: inbox) } } private enum FileLoad { case saved(URL); case failed; case absent } private func loadFile(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> FileLoad { await withCheckedContinuation { cont in provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, _ in guard let url else { return cont.resume(returning: .absent) } let dest = inbox.appendingPathComponent( UUID().uuidString + "-" + url.lastPathComponent) cont.resume(returning: (try? FileManager.default.copyItem(at: url, to: dest)) != nil ? .saved(dest) : .failed) } } } private func loadData(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> URL? { let ext = type == .movie ? "mp4" : "jpg" return await withCheckedContinuation { cont in provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in guard let data else { return cont.resume(returning: nil) } 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 private let instances = InstancesStore.load() private let suggested = LastOpenedChat.load() @State private var sending = false @State private var status: String? @State private var sentTo: String? @State private var path: [SavedInstance] = [] @State private var compose: ComposeTarget? private var itemsLabel: String { files.count == 1 ? "photo" : "\(files.count) items" } var body: some View { NavigationStack(path: $path) { List { // The chat you were just in — one tap, no digging. if let suggested, let instance = instances.first(where: { $0.origin == suggested.origin }) { Section("Suggested") { Button { sendToSuggested(suggested, instance: instance) } label: { row(avatar: ShareAvatar(initial: suggested.title), title: suggested.title, caption: instance.displayName, busy: sending) } .foregroundStyle(.primary) .disabled(sending) } } Section("Servers") { ForEach(instances) { instance in NavigationLink(value: instance) { row(avatar: ShareAvatar(initial: instance.displayName, serverOrigin: instance.origin), title: instance.displayName, caption: instance.hostLabel, busy: false) } } } } .navigationTitle("Send \(itemsLabel) to…") .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: SavedInstance.self) { instance in ServerChatsStep(instance: instance, files: files) { target in compose = target } } .navigationDestination(item: $compose) { target in ComposeStep(target: target, files: files, done: done) } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { done(.cancelled) }.disabled(sending) } } .overlay(alignment: .bottom) { if let status { Text(status).font(.footnote).foregroundStyle(.secondary).padding(8) } } .overlay { if let sentTo { SentConfirmation(chat: sentTo) } } // One server and no suggestion → skip the server step entirely. .onAppear { if instances.count == 1, suggested == nil, let only = instances.first { path = [only] } } } } private func sendToSuggested(_ s: LastOpenedChat.Entry, instance: SavedInstance) { sending = true status = "Opening \(s.title)…" Task { // The suggestion carries no key (keys stay where keys live) — a // quick roster refresh recovers the thread, then compose. let store = ThreadStore(instance: instance) await store.refresh() sending = false status = nil guard let thread = store.threads.first(where: { $0.id == s.thread }) else { status = "That chat isn't reachable — pick one below." return } compose = ComposeTarget(instance: instance, thread: thread, myUserId: store.myUserId) } } @ViewBuilder func row(avatar: ShareAvatar, title: String, caption: String, busy: Bool) -> some View { HStack(spacing: 10) { avatar VStack(alignment: .leading, spacing: 0) { Text(title).font(.body.weight(.medium)).lineLimit(1) Text(caption).font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() if busy { ProgressView() } } .contentShape(Rectangle()) } } // Step two: the chosen server's chats, searchable. struct ServerChatsStep: View { let instance: SavedInstance let files: [URL] let onPick: (ComposeTarget) -> Void @State private var store: ThreadStore? @State private var loading = true @State private var search = "" @State private var sendingID: String? @State private var status: String? @State private var sentTo: String? private var threads: [ChatThread] { let all = (store?.threads ?? []).filter { !$0.hidden && !$0.isApp } let q = search.trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return all } return all.filter { $0.title.localizedCaseInsensitiveContains(q) } } var body: some View { Group { if loading { ProgressView("Loading chats…") } else { List(threads) { thread in Button { send(thread) } label: { HStack(spacing: 10) { ShareAvatar(initial: thread.title, source: thread.avatar) Text(thread.title).font(.body.weight(.medium)).lineLimit(1) Spacer() if sendingID == thread.id { ProgressView() } } .contentShape(Rectangle()) } .foregroundStyle(.primary) .disabled(sendingID != nil) } .searchable(text: $search, prompt: "Search chats") } } .navigationTitle(instance.displayName) .navigationBarTitleDisplayMode(.inline) .overlay(alignment: .bottom) { if let status { Text(status).font(.footnote).foregroundStyle(.secondary).padding(8) } } .overlay { if let sentTo { SentConfirmation(chat: sentTo) } } .task { let s = ThreadStore(instance: instance) await s.refresh() store = s loading = false } } @MainActor private func send(_ thread: ChatThread) { guard let store else { return } onPick(ComposeTarget(instance: instance, thread: thread, myUserId: store.myUserId)) } } // An avatar circle for the picker rows: encrypted chat photos come through // BlobImageLoader (fetch + decrypt, LRU) exactly like the app's; server // avatars are the plaintext branding image (/api/server-avatar). Initials // while loading / when there's no photo. struct ShareAvatar: View { let initial: String var source: AvatarSource? = nil var serverOrigin: String? = nil @State private var image: PlatformImage? var body: some View { ZStack { if let image { Image(uiImage: image).resizable().scaledToFill() } else { Circle().fill(.tint.opacity(0.15)) Text(String(initial.prefix(1)).uppercased()) .font(.system(size: 14, weight: .semibold)) } } .frame(width: 34, height: 34) .clipShape(Circle()) .task(id: (source?.blobID ?? "") + (serverOrigin ?? "")) { if let source { image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) } else if let serverOrigin, let url = URL(string: serverOrigin + "/api/server-avatar") { if let (data, resp) = try? await URLSession.shared.data(from: url), (resp as? HTTPURLResponse)?.statusCode == 200 { image = PlatformImage(data: data) } } } } } // The one send path both steps use: encrypt + upload each file, one message. enum ShareSend { @MainActor static func send(files: [URL], text: String, instance: SavedInstance, thread: ChatThread, myUserId: String) async -> Bool { let model = ChatFeedModel(instance: instance, thread: thread, myUserId: 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? = ["mp4", "mov", "m4v"].contains(ext) ? await model.uploadVideo(data, name: url.lastPathComponent, mimeType: "video/mp4") : await model.uploadImage(data, name: url.lastPathComponent) if let att { attachments.append(att) } } guard !attachments.isEmpty else { return false } return await model.sendViaREST(text, attachments: attachments) } } // The beat of confirmation before the sheet closes itself. struct SentConfirmation: View { let chat: String @State private var shown = false var body: some View { VStack(spacing: 10) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 44)) .foregroundStyle(.green) .scaleEffect(shown ? 1 : 0.6) Text("Sent to \(chat)") .font(.headline) } .padding(28) .background(RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(.regularMaterial)) .opacity(shown ? 1 : 0) .onAppear { withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { shown = true } } } } // The final step: your items, an optional message, and the Send button. struct ComposeTarget: Identifiable, Hashable { let instance: SavedInstance let thread: ChatThread let myUserId: String var id: String { instance.id + "|" + thread.id } static func == (a: ComposeTarget, b: ComposeTarget) -> Bool { a.id == b.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } } struct ComposeStep: View { let target: ComposeTarget let files: [URL] let done: (ShareOutcome) -> Void @State private var message = "" @State private var thumbs: [PlatformImage] = [] @State private var sending = false @State private var sentTo: String? @State private var status: String? @FocusState private var messageFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 14) { // What's being sent. ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(thumbs.enumerated()), id: \.offset) { _, img in Image(uiImage: img) .resizable().scaledToFill() .frame(width: 84, height: 84) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } if thumbs.isEmpty { Text(files.count == 1 ? "1 item" : "\(files.count) items") .foregroundStyle(.secondary) } } } TextField("Add a message…", text: $message, axis: .vertical) .lineLimit(1...4) .padding(10) .background(RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(.primary.opacity(0.06))) .focused($messageFocused) if let status { Text(status).font(.footnote).foregroundStyle(.secondary) } Button(action: send) { if sending { ProgressView().frame(maxWidth: .infinity) } else { Label("Send to \(target.thread.title)", systemImage: "paperplane.fill") .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(sending) Spacer() } .padding(16) .navigationTitle(target.thread.title) .navigationBarTitleDisplayMode(.inline) .overlay { if let sentTo { SentConfirmation(chat: sentTo) } } .task { for url in files.prefix(6) { guard let data = try? Data(contentsOf: url), !["mp4", "mov", "m4v"].contains(url.pathExtension.lowercased()), let thumb = processImage(data, maxDim: 320), let img = PlatformImage(data: thumb.bytes) else { continue } thumbs.append(img) } } } private func send() { sending = true status = "Encrypting & uploading…" Task { let ok = await ShareSend.send(files: files, text: message, instance: target.instance, thread: target.thread, myUserId: target.myUserId) sending = false if ok { status = nil sentTo = target.thread.title try? await Task.sleep(nanoseconds: 900_000_000) done(.sent(files)) } else { status = "Couldn't send — open Eleven to try again." } } } }