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>
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." |
| + } |
| + } |
| + } |
| +} |