eleven / messenger Public

ios: the share picker grows up — server first, Suggested chat, search (#299)

Two-step flow: pick a server, then the chat (searchable; one server
and no suggestion skips straight to chats). A "Suggested" section
offers the chat you most recently had open for one-tap sends — the
app notes it in the SHARED KEYCHAIN group (not app-group defaults:
the decrypted title is user content, and the keychain is where the
NSE already parks decrypted titles). The suggestion carries no key;
a quick refresh recovers the thread before sending.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9008d6da2ccb673f7b959bd280b3b86a3cfbef8c parent 51f8d74
4 files changed, +185 −77
  • ios/LChat/Views/NativeChatScreen.swift +2 −0
  • ios/LChatCore/Sources/LChatCore/Keychain.swift +21 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +1 −1
  • ios/LChatShare/ShareViewController.swift +161 −76
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index d68101f..d189ea6 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -187,6 +187,8 @@ struct NativeChatScreen: View {
}
.onAppear {
model.start()
+ LastOpenedChat.note(origin: instance.origin, thread: thread.id,
+ title: thread.title)
// Share-sheet handoff: stage each shared file as an upload
// (encrypt + upload run exactly like a picked photo), then
// burn the inbox copies.
diff --git a/ios/LChatCore/Sources/LChatCore/Keychain.swift b/ios/LChatCore/Sources/LChatCore/Keychain.swift
index 6cfed30..bd8f8f9 100644
--- a/ios/LChatCore/Sources/LChatCore/Keychain.swift
+++ b/ios/LChatCore/Sources/LChatCore/Keychain.swift
@@ -108,3 +108,24 @@ public enum InstancesStore {
return try? JSONDecoder().decode([SavedInstance].self, from: data)
}
}
+
+// 🤖 The most recent chat the member had open — the share extension's
+// "Suggested" row. Kept in the shared-keychain group (NOT app-group defaults)
+// because the decrypted title is user content: same posture as
+// SharedThreadKeys, which already parks decrypted titles for the NSE.
+public enum LastOpenedChat {
+ public struct Entry: Codable {
+ public let origin: String
+ public let thread: String
+ public let title: String
+ }
+
+ public static func note(origin: String, thread: String, title: String) {
+ Keychain.save(Entry(origin: origin, thread: thread, title: title),
+ account: "last-opened-chat")
+ }
+
+ public static func load() -> Entry? {
+ Keychain.load(Entry.self, account: "last-opened-chat")
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index f9b28cf..6597150 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -6,7 +6,7 @@ import Foundation
// UserDefaults. `userPriv` feeds the native ECIES unwrap (Crypto.swift) to open
// per-thread keys for the sidebar; it is NEVER handed to a webview, which only ever
// receives a single already-unwrapped thread key.
-public struct SavedInstance: Codable, Identifiable, Equatable {
+public struct SavedInstance: Codable, Identifiable, Equatable, Hashable {
public var origin: String // scheme://host[:port] — the instance's URL
public var deviceToken: String // transport auth (NOT a decryption key)
public var screenName: String // our display name in this instance
diff --git a/ios/LChatShare/ShareViewController.swift b/ios/LChatShare/ShareViewController.swift
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)
}
}