| index 8595d53..e5e3ec4 100644 |
| --- a/ios/LChatShare/ShareViewController.swift |
| +++ b/ios/LChatShare/ShareViewController.swift |
| @@ -95,111 +95,196 @@ 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 } |
| - } |
| + private let instances = InstancesStore.load() |
| + private let suggested = LastOpenedChat.load() |
| |
| - @State private var rows: [Row] = [] |
| - @State private var loading = true |
| - @State private var sendingID: String? |
| + @State private var sending = false |
| @State private var status: String? |
| + @State private var path: [SavedInstance] = [] |
| + |
| + private var itemsLabel: String { |
| + files.count == 1 ? "photo" : "\(files.count) items" |
| + } |
| |
| 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()) |
| + 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(initial: suggested.title, title: suggested.title, |
| + caption: instance.displayName, busy: sending) |
| } |
| .foregroundStyle(.primary) |
| - .disabled(sendingID != nil) |
| + .disabled(sending) |
| + } |
| + } |
| + Section("Servers") { |
| + ForEach(instances) { instance in |
| + NavigationLink(value: instance) { |
| + row(initial: instance.displayName, title: instance.displayName, |
| + caption: instance.hostLabel, busy: false) |
| + } |
| } |
| } |
| } |
| - .navigationTitle(files.count == 1 ? "Send photo to…" : "Send \(files.count) items to…") |
| + .navigationTitle("Send \(itemsLabel) to…") |
| .navigationBarTitleDisplayMode(.inline) |
| + .navigationDestination(for: SavedInstance.self) { instance in |
| + ServerChatsStep(instance: instance, files: files, done: done) |
| + } |
| .toolbar { |
| ToolbarItem(placement: .cancellationAction) { |
| - Button("Cancel") { done(.cancelled) }.disabled(sendingID != nil) |
| + Button("Cancel") { done(.cancelled) }.disabled(sending) |
| } |
| } |
| .overlay(alignment: .bottom) { |
| - if let status, sendingID != nil { |
| + if let status { |
| Text(status).font(.footnote).foregroundStyle(.secondary).padding(8) |
| } |
| } |
| + // One server and no suggestion → skip the server step entirely. |
| + .onAppear { |
| + if instances.count == 1, suggested == nil, let only = instances.first { |
| + path = [only] |
| + } |
| + } |
| } |
| - .task { await loadChats() } |
| } |
| |
| - @MainActor private func loadChats() async { |
| - for instance in InstancesStore.load() { |
| + private func sendToSuggested(_ s: LastOpenedChat.Entry, instance: SavedInstance) { |
| + sending = true |
| + status = "Encrypting & uploading…" |
| + Task { |
| + // The suggestion carries no key (keys stay where keys live) — a |
| + // quick roster refresh recovers the thread, then send as usual. |
| 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) } |
| + guard let thread = store.threads.first(where: { $0.id == s.thread }) else { |
| + sending = false |
| + status = "That chat isn't reachable — pick one below." |
| + return |
| + } |
| + let ok = await ShareSend.send(files: files, instance: instance, |
| + thread: thread, myUserId: store.myUserId) |
| + sending = false |
| + if ok { status = "Sent"; done(.sent(files)) } |
| + else { status = "Couldn't send — pick a chat below or open Eleven." } |
| } |
| - loading = false |
| - if rows.isEmpty { status = "Couldn't load chats — open Eleven to send instead." } |
| } |
| |
| - @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) |
| + @ViewBuilder |
| + func row(initial: String, title: String, caption: String, busy: Bool) -> some View { |
| + HStack(spacing: 10) { |
| + ZStack { |
| + Circle().fill(.tint.opacity(0.15)) |
| + Text(String(initial.prefix(1)).uppercased()) |
| + .font(.system(size: 14, weight: .semibold)) |
| + } |
| + .frame(width: 34, height: 34) |
| + 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 done: (ShareOutcome) -> 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? |
| + |
| + 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) { |
| + ZStack { |
| + Circle().fill(.tint.opacity(0.15)) |
| + Text(String(thread.title.prefix(1)).uppercased()) |
| + .font(.system(size: 14, weight: .semibold)) |
| + } |
| + .frame(width: 34, height: 34) |
| + Text(thread.title).font(.body.weight(.medium)).lineLimit(1) |
| + Spacer() |
| + if sendingID == thread.id { ProgressView() } |
| + } |
| + .contentShape(Rectangle()) |
| + } |
| + .foregroundStyle(.primary) |
| + .disabled(sendingID != nil) |
| } |
| - if let att { attachments.append(att) } |
| + .searchable(text: $search, prompt: "Search chats") |
| } |
| - guard !attachments.isEmpty, |
| - await model.sendViaREST("", attachments: attachments) else { |
| - sendingID = nil |
| - status = "Couldn't send — open Eleven to try again." |
| - return |
| + } |
| + .navigationTitle(instance.displayName) |
| + .navigationBarTitleDisplayMode(.inline) |
| + .overlay(alignment: .bottom) { |
| + if let status { |
| + Text(status).font(.footnote).foregroundStyle(.secondary).padding(8) |
| } |
| - status = "Sent" |
| - done(.sent(files)) |
| } |
| + .task { |
| + let s = ThreadStore(instance: instance) |
| + await s.refresh() |
| + store = s |
| + loading = false |
| + } |
| + } |
| + |
| + @MainActor private func send(_ thread: ChatThread) { |
| + guard let store else { return } |
| + sendingID = thread.id |
| + status = "Encrypting & uploading…" |
| + Task { |
| + let ok = await ShareSend.send(files: files, instance: instance, |
| + thread: thread, myUserId: store.myUserId) |
| + if ok { status = "Sent"; done(.sent(files)) } |
| + else { sendingID = nil; status = "Couldn't send — open Eleven to try again." } |
| + } |
| + } |
| +} |
| + |
| +// The one send path both steps use: encrypt + upload each file, one message. |
| +enum ShareSend { |
| + @MainActor static func send(files: [URL], 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("", attachments: attachments) |
| } |
| } |