eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import UIKit
4import UniformTypeIdentifiers
5
6// 🤖 The share-sheet receiver, direct-send edition: shared images/videos get a
7// native chat picker RIGHT IN the sheet — pick a chat and the files encrypt,
8// upload and send from here (LChatCore: the same ThreadStore/ChatFeedModel the
9// app uses; identities come from the shared keychain group, like the NSE's
10// thread keys). Files are also parked in the app-group inbox first, so if the
11// user cancels or a send fails, opening Eleven offers the picker as a fallback
12// and nothing shared is ever lost.
13final class ShareViewController: UIViewController {
14 static let appGroup = "group.com.elevenmessenger.messenger"
15
16 override func viewDidLoad() {
17 super.viewDidLoad()
18 view.backgroundColor = .systemBackground
19 Task { await start() }
20 }
21
22 private func start() async {
23 let files = await ingest()
24 let picker = SharePickerView(files: files) { [weak self] outcome in
25 switch outcome {
26 case .sent(let urls):
27 ShareInboxLite.clear(urls)
28 self?.extensionContext?.completeRequest(returningItems: nil)
29 case .cancelled:
30 // Keep the inbox copies — the app offers its picker next open.
31 self?.extensionContext?.completeRequest(returningItems: nil)
32 }
33 }
34 let host = UIHostingController(rootView: picker)
35 addChild(host)
36 host.view.frame = view.bounds
37 host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
38 view.addSubview(host.view)
39 host.didMove(toParent: self)
40 }
41
42 // Park every attachment in the app-group inbox (the no-data-loss floor).
43 private func ingest() async -> [URL] {
44 guard let inbox = FileManager.default
45 .containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)?
46 .appendingPathComponent("share-inbox", isDirectory: true) else { return [] }
47 try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true)
48 var saved: [URL] = []
49 for item in extensionContext?.inputItems.compactMap({ $0 as? NSExtensionItem }) ?? [] {
50 for provider in item.attachments ?? [] {
51 for type in [UTType.image, UTType.movie] where
52 provider.hasItemConformingToTypeIdentifier(type.identifier) {
53 if let url = await save(provider, as: type, into: inbox) { saved.append(url) }
54 break
55 }
56 }
57 }
58 return saved
59 }
60
61 // Prefer a file representation (keeps the original filename); fall back to
62 // raw data only when NO file was offered — a file that WAS offered but
63 // couldn't be copied is a hard failure, not a data retry (original
64 // behaviour). The two loads are separate awaited steps rather than nested,
65 // so the fallback never sits inside — and captures `provider` in — the
66 // first load's @Sendable completion closure (NSItemProvider isn't Sendable).
67 private func save(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> URL? {
68 switch await loadFile(provider, as: type, into: inbox) {
69 case .saved(let dest): return dest
70 case .failed: return nil
71 case .absent: return await loadData(provider, as: type, into: inbox)
72 }
73 }
74
75 private enum FileLoad { case saved(URL); case failed; case absent }
76
77 private func loadFile(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> FileLoad {
78 await withCheckedContinuation { cont in
79 provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, _ in
80 guard let url else { return cont.resume(returning: .absent) }
81 let dest = inbox.appendingPathComponent(
82 UUID().uuidString + "-" + url.lastPathComponent)
83 cont.resume(returning:
84 (try? FileManager.default.copyItem(at: url, to: dest)) != nil ? .saved(dest) : .failed)
85 }
86 }
87 }
88
89 private func loadData(_ provider: NSItemProvider, as type: UTType, into inbox: URL) async -> URL? {
90 let ext = type == .movie ? "mp4" : "jpg"
91 return await withCheckedContinuation { cont in
92 provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in
93 guard let data else { return cont.resume(returning: nil) }
94 let dest = inbox.appendingPathComponent(UUID().uuidString + "." + ext)
95 cont.resume(returning: (try? data.write(to: dest)) != nil ? dest : nil)
96 }
97 }
98 }
99}
100
101// The extension's own copy of the inbox cleaner (the app has ShareInbox).
102enum ShareInboxLite {
103 static func clear(_ urls: [URL]) {
104 for url in urls { try? FileManager.default.removeItem(at: url) }
105 }
106}
107
108enum ShareOutcome {
109 case sent([URL])
110 case cancelled
111}
112
113struct SharePickerView: View {
114 let files: [URL]
115 let done: (ShareOutcome) -> Void
116
117 private let instances = InstancesStore.load()
118 private let suggested = LastOpenedChat.load()
119
120 @State private var sending = false
121 @State private var status: String?
122 @State private var sentTo: String?
123 @State private var path: [SavedInstance] = []
124 @State private var compose: ComposeTarget?
125
126 private var itemsLabel: String {
127 files.count == 1 ? "photo" : "\(files.count) items"
128 }
129
130 var body: some View {
131 NavigationStack(path: $path) {
132 List {
133 // The chat you were just in — one tap, no digging.
134 if let suggested,
135 let instance = instances.first(where: { $0.origin == suggested.origin }) {
136 Section("Suggested") {
137 Button {
138 sendToSuggested(suggested, instance: instance)
139 } label: {
140 row(avatar: ShareAvatar(initial: suggested.title),
141 title: suggested.title,
142 caption: instance.displayName, busy: sending)
143 }
144 .foregroundStyle(.primary)
145 .disabled(sending)
146 }
147 }
148 Section("Servers") {
149 ForEach(instances) { instance in
150 NavigationLink(value: instance) {
151 row(avatar: ShareAvatar(initial: instance.displayName,
152 serverOrigin: instance.origin),
153 title: instance.displayName,
154 caption: instance.hostLabel, busy: false)
155 }
156 }
157 }
158 }
159 .navigationTitle("Send \(itemsLabel) to…")
160 .navigationBarTitleDisplayMode(.inline)
161 .navigationDestination(for: SavedInstance.self) { instance in
162 ServerChatsStep(instance: instance, files: files) { target in
163 compose = target
164 }
165 }
166 .navigationDestination(item: $compose) { target in
167 ComposeStep(target: target, files: files, done: done)
168 }
169 .toolbar {
170 ToolbarItem(placement: .cancellationAction) {
171 Button("Cancel") { done(.cancelled) }.disabled(sending)
172 }
173 }
174 .overlay(alignment: .bottom) {
175 if let status {
176 Text(status).font(.footnote).foregroundStyle(.secondary).padding(8)
177 }
178 }
179 .overlay { if let sentTo { SentConfirmation(chat: sentTo) } }
180 // One server and no suggestion → skip the server step entirely.
181 .onAppear {
182 if instances.count == 1, suggested == nil, let only = instances.first {
183 path = [only]
184 }
185 }
186 }
187 }
188
189 private func sendToSuggested(_ s: LastOpenedChat.Entry, instance: SavedInstance) {
190 sending = true
191 status = "Opening \(s.title)…"
192 Task {
193 // The suggestion carries no key (keys stay where keys live) — a
194 // quick roster refresh recovers the thread, then compose.
195 let store = ThreadStore(instance: instance)
196 await store.refresh()
197 sending = false
198 status = nil
199 guard let thread = store.threads.first(where: { $0.id == s.thread }) else {
200 status = "That chat isn't reachable — pick one below."
201 return
202 }
203 compose = ComposeTarget(instance: instance, thread: thread,
204 myUserId: store.myUserId)
205 }
206 }
207
208 @ViewBuilder
209 func row(avatar: ShareAvatar, title: String, caption: String, busy: Bool) -> some View {
210 HStack(spacing: 10) {
211 avatar
212 VStack(alignment: .leading, spacing: 0) {
213 Text(title).font(.body.weight(.medium)).lineLimit(1)
214 Text(caption).font(.caption).foregroundStyle(.secondary).lineLimit(1)
215 }
216 Spacer()
217 if busy { ProgressView() }
218 }
219 .contentShape(Rectangle())
220 }
221}
222
223// Step two: the chosen server's chats, searchable.
224struct ServerChatsStep: View {
225 let instance: SavedInstance
226 let files: [URL]
227 let onPick: (ComposeTarget) -> Void
228
229 @State private var store: ThreadStore?
230 @State private var loading = true
231 @State private var search = ""
232 @State private var sendingID: String?
233 @State private var status: String?
234 @State private var sentTo: String?
235
236 private var threads: [ChatThread] {
237 let all = (store?.threads ?? []).filter { !$0.hidden && !$0.isApp }
238 let q = search.trimmingCharacters(in: .whitespaces)
239 guard !q.isEmpty else { return all }
240 return all.filter { $0.title.localizedCaseInsensitiveContains(q) }
241 }
242
243 var body: some View {
244 Group {
245 if loading {
246 ProgressView("Loading chats…")
247 } else {
248 List(threads) { thread in
249 Button { send(thread) } label: {
250 HStack(spacing: 10) {
251 ShareAvatar(initial: thread.title, source: thread.avatar)
252 Text(thread.title).font(.body.weight(.medium)).lineLimit(1)
253 Spacer()
254 if sendingID == thread.id { ProgressView() }
255 }
256 .contentShape(Rectangle())
257 }
258 .foregroundStyle(.primary)
259 .disabled(sendingID != nil)
260 }
261 .searchable(text: $search, prompt: "Search chats")
262 }
263 }
264 .navigationTitle(instance.displayName)
265 .navigationBarTitleDisplayMode(.inline)
266 .overlay(alignment: .bottom) {
267 if let status {
268 Text(status).font(.footnote).foregroundStyle(.secondary).padding(8)
269 }
270 }
271 .overlay { if let sentTo { SentConfirmation(chat: sentTo) } }
272 .task {
273 let s = ThreadStore(instance: instance)
274 await s.refresh()
275 store = s
276 loading = false
277 }
278 }
279
280 @MainActor private func send(_ thread: ChatThread) {
281 guard let store else { return }
282 onPick(ComposeTarget(instance: instance, thread: thread,
283 myUserId: store.myUserId))
284 }
285}
286
287// An avatar circle for the picker rows: encrypted chat photos come through
288// BlobImageLoader (fetch + decrypt, LRU) exactly like the app's; server
289// avatars are the plaintext branding image (/api/server-avatar). Initials
290// while loading / when there's no photo.
291struct ShareAvatar: View {
292 let initial: String
293 var source: AvatarSource? = nil
294 var serverOrigin: String? = nil
295
296 @State private var image: PlatformImage?
297
298 var body: some View {
299 ZStack {
300 if let image {
301 Image(uiImage: image).resizable().scaledToFill()
302 } else {
303 Circle().fill(.tint.opacity(0.15))
304 Text(String(initial.prefix(1)).uppercased())
305 .font(.system(size: 14, weight: .semibold))
306 }
307 }
308 .frame(width: 34, height: 34)
309 .clipShape(Circle())
310 .task(id: (source?.blobID ?? "") + (serverOrigin ?? "")) {
311 if let source {
312 image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel)
313 } else if let serverOrigin, let url = URL(string: serverOrigin + "/api/server-avatar") {
314 if let (data, resp) = try? await URLSession.shared.data(from: url),
315 (resp as? HTTPURLResponse)?.statusCode == 200 {
316 image = PlatformImage(data: data)
317 }
318 }
319 }
320 }
321}
322
323// The one send path both steps use: encrypt + upload each file, one message.
324enum ShareSend {
325 @MainActor static func send(files: [URL], text: String, instance: SavedInstance,
326 thread: ChatThread, myUserId: String) async -> Bool {
327 let model = ChatFeedModel(instance: instance, thread: thread, myUserId: myUserId)
328 var attachments: [Attachment] = []
329 for url in files {
330 guard let data = try? Data(contentsOf: url) else { continue }
331 let ext = url.pathExtension.lowercased()
332 let att: Attachment? = ["mp4", "mov", "m4v"].contains(ext)
333 ? await model.uploadVideo(data, name: url.lastPathComponent, mimeType: "video/mp4")
334 : await model.uploadImage(data, name: url.lastPathComponent)
335 if let att { attachments.append(att) }
336 }
337 guard !attachments.isEmpty else { return false }
338 return await model.sendViaREST(text, attachments: attachments)
339 }
340}
341
342
343// The beat of confirmation before the sheet closes itself.
344struct SentConfirmation: View {
345 let chat: String
346 @State private var shown = false
347
348 var body: some View {
349 VStack(spacing: 10) {
350 Image(systemName: "checkmark.circle.fill")
351 .font(.system(size: 44))
352 .foregroundStyle(.green)
353 .scaleEffect(shown ? 1 : 0.6)
354 Text("Sent to \(chat)")
355 .font(.headline)
356 }
357 .padding(28)
358 .background(RoundedRectangle(cornerRadius: 20, style: .continuous)
359 .fill(.regularMaterial))
360 .opacity(shown ? 1 : 0)
361 .onAppear {
362 withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { shown = true }
363 }
364 }
365}
366
367
368// The final step: your items, an optional message, and the Send button.
369struct ComposeTarget: Identifiable, Hashable {
370 let instance: SavedInstance
371 let thread: ChatThread
372 let myUserId: String
373 var id: String { instance.id + "|" + thread.id }
374
375 static func == (a: ComposeTarget, b: ComposeTarget) -> Bool { a.id == b.id }
376 func hash(into hasher: inout Hasher) { hasher.combine(id) }
377}
378
379struct ComposeStep: View {
380 let target: ComposeTarget
381 let files: [URL]
382 let done: (ShareOutcome) -> Void
383
384 @State private var message = ""
385 @State private var thumbs: [PlatformImage] = []
386 @State private var sending = false
387 @State private var sentTo: String?
388 @State private var status: String?
389 @FocusState private var messageFocused: Bool
390
391 var body: some View {
392 VStack(alignment: .leading, spacing: 14) {
393 // What's being sent.
394 ScrollView(.horizontal, showsIndicators: false) {
395 HStack(spacing: 8) {
396 ForEach(Array(thumbs.enumerated()), id: \.offset) { _, img in
397 Image(uiImage: img)
398 .resizable().scaledToFill()
399 .frame(width: 84, height: 84)
400 .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
401 }
402 if thumbs.isEmpty {
403 Text(files.count == 1 ? "1 item" : "\(files.count) items")
404 .foregroundStyle(.secondary)
405 }
406 }
407 }
408 TextField("Add a message…", text: $message, axis: .vertical)
409 .lineLimit(1...4)
410 .padding(10)
411 .background(RoundedRectangle(cornerRadius: 12, style: .continuous)
412 .fill(.primary.opacity(0.06)))
413 .focused($messageFocused)
414 if let status {
415 Text(status).font(.footnote).foregroundStyle(.secondary)
416 }
417 Button(action: send) {
418 if sending {
419 ProgressView().frame(maxWidth: .infinity)
420 } else {
421 Label("Send to \(target.thread.title)", systemImage: "paperplane.fill")
422 .frame(maxWidth: .infinity)
423 }
424 }
425 .buttonStyle(.borderedProminent)
426 .controlSize(.large)
427 .disabled(sending)
428 Spacer()
429 }
430 .padding(16)
431 .navigationTitle(target.thread.title)
432 .navigationBarTitleDisplayMode(.inline)
433 .overlay { if let sentTo { SentConfirmation(chat: sentTo) } }
434 .task {
435 for url in files.prefix(6) {
436 guard let data = try? Data(contentsOf: url),
437 !["mp4", "mov", "m4v"].contains(url.pathExtension.lowercased()),
438 let thumb = processImage(data, maxDim: 320),
439 let img = PlatformImage(data: thumb.bytes) else { continue }
440 thumbs.append(img)
441 }
442 }
443 }
444
445 private func send() {
446 sending = true
447 status = "Encrypting & uploading…"
448 Task {
449 let ok = await ShareSend.send(files: files, text: message,
450 instance: target.instance,
451 thread: target.thread,
452 myUserId: target.myUserId)
453 sending = false
454 if ok {
455 status = nil
456 sentTo = target.thread.title
457 try? await Task.sleep(nanoseconds: 900_000_000)
458 done(.sent(files))
459 } else {
460 status = "Couldn't send — open Eleven to try again."
461 }
462 }
463 }
464}
465