eleven / ocho Public

ios: share flow gets a compose step — message + Send (#301)

Picking a chat (or the Suggested row) now lands on a final screen:
thumbnails of what's being shared, an optional 'Add a message…'
field, and a big Send button. The message rides the same encrypted
payload as the attachments; the green-check confirmation stays.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c2027b7a5040aca24fd02b594c255c82dc48624f parent d8ed11c
1 file changed, +117 −33
  • ios/LChatShare/ShareViewController.swift +117 −33
diff --git a/ios/LChatShare/ShareViewController.swift b/ios/LChatShare/ShareViewController.swift
index ab85d4b..7f91d4b 100644
--- a/ios/LChatShare/ShareViewController.swift
+++ b/ios/LChatShare/ShareViewController.swift
@@ -102,6 +102,7 @@ struct SharePickerView: View {
@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"
@@ -139,7 +140,12 @@ struct SharePickerView: View {
.navigationTitle("Send \(itemsLabel) to…")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: SavedInstance.self) { instance in
- ServerChatsStep(instance: instance, files: files, done: done)
+ 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) {
@@ -163,28 +169,20 @@ struct SharePickerView: View {
private func sendToSuggested(_ s: LastOpenedChat.Entry, instance: SavedInstance) {
sending = true
- status = "Encrypting & uploading…"
+ status = "Opening \(s.title)…"
Task {
// The suggestion carries no key (keys stay where keys live) — a
- // quick roster refresh recovers the thread, then send as usual.
+ // 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 {
- 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 = nil
- sentTo = s.title
- try? await Task.sleep(nanoseconds: 900_000_000)
- done(.sent(files))
- } else {
- status = "Couldn't send — pick a chat below or open Eleven."
- }
+ compose = ComposeTarget(instance: instance, thread: thread,
+ myUserId: store.myUserId)
}
}
@@ -207,7 +205,7 @@ struct SharePickerView: View {
struct ServerChatsStep: View {
let instance: SavedInstance
let files: [URL]
- let done: (ShareOutcome) -> Void
+ let onPick: (ComposeTarget) -> Void
@State private var store: ThreadStore?
@State private var loading = true
@@ -262,21 +260,8 @@ struct ServerChatsStep: View {
@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 = nil
- sentTo = thread.title
- try? await Task.sleep(nanoseconds: 900_000_000)
- done(.sent(files))
- } else {
- sendingID = nil
- status = "Couldn't send — open Eleven to try again."
- }
- }
+ onPick(ComposeTarget(instance: instance, thread: thread,
+ myUserId: store.myUserId))
}
}
@@ -318,7 +303,7 @@ struct ShareAvatar: View {
// The one send path both steps use: encrypt + upload each file, one message.
enum ShareSend {
- @MainActor static func send(files: [URL], instance: SavedInstance,
+ @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] = []
@@ -331,7 +316,7 @@ enum ShareSend {
if let att { attachments.append(att) }
}
guard !attachments.isEmpty else { return false }
- return await model.sendViaREST("", attachments: attachments)
+ return await model.sendViaREST(text, attachments: attachments)
}
}
@@ -359,3 +344,102 @@ struct SentConfirmation: View {
}
}
}
+
+
+// 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."
+ }
+ }
+ }
+}