ios: share picker gets real avatars and a sent confirmation (#300)
Server rows show the instance's branding photo (/api/server-avatar, plaintext by design); chat rows decrypt their avatars through the same BlobImageLoader the app uses; initials while loading. A送 send now lands a green-check 'Sent to <chat>' card for a beat before the sheet closes itself, instead of vanishing mid-thought.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+90
−19
ios/LChatShare/ShareViewController.swift+90 −19
diff --git a/ios/LChatShare/ShareViewController.swift b/ios/LChatShare/ShareViewController.swift| index e5e3ec4..ab85d4b 100644 |
| --- a/ios/LChatShare/ShareViewController.swift |
| +++ b/ios/LChatShare/ShareViewController.swift |
| @@ -100,6 +100,7 @@ struct SharePickerView: View { |
| @State private var sending = false |
| @State private var status: String? |
| + @State private var sentTo: String? |
| @State private var path: [SavedInstance] = [] |
| private var itemsLabel: String { |
| @@ -116,7 +117,8 @@ struct SharePickerView: View { |
| Button { |
| sendToSuggested(suggested, instance: instance) |
| } label: { |
| - row(initial: suggested.title, title: suggested.title, |
| + row(avatar: ShareAvatar(initial: suggested.title), |
| + title: suggested.title, |
| caption: instance.displayName, busy: sending) |
| } |
| .foregroundStyle(.primary) |
| @@ -126,7 +128,9 @@ struct SharePickerView: View { |
| Section("Servers") { |
| ForEach(instances) { instance in |
| NavigationLink(value: instance) { |
| - row(initial: instance.displayName, title: instance.displayName, |
| + row(avatar: ShareAvatar(initial: instance.displayName, |
| + serverOrigin: instance.origin), |
| + title: instance.displayName, |
| caption: instance.hostLabel, busy: false) |
| } |
| } |
| @@ -147,6 +151,7 @@ struct SharePickerView: View { |
| Text(status).font(.footnote).foregroundStyle(.secondary).padding(8) |
| } |
| } |
| + .overlay { if let sentTo { SentConfirmation(chat: sentTo) } } |
| // One server and no suggestion → skip the server step entirely. |
| .onAppear { |
| if instances.count == 1, suggested == nil, let only = instances.first { |
| @@ -172,20 +177,21 @@ struct SharePickerView: View { |
| 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." } |
| + 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." |
| + } |
| } |
| } |
| @ViewBuilder |
| - func row(initial: String, title: String, caption: String, busy: Bool) -> some View { |
| + func row(avatar: ShareAvatar, 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) |
| + avatar |
| VStack(alignment: .leading, spacing: 0) { |
| Text(title).font(.body.weight(.medium)).lineLimit(1) |
| Text(caption).font(.caption).foregroundStyle(.secondary).lineLimit(1) |
| @@ -208,6 +214,7 @@ struct ServerChatsStep: View { |
| @State private var search = "" |
| @State private var sendingID: String? |
| @State private var status: String? |
| + @State private var sentTo: String? |
| private var threads: [ChatThread] { |
| let all = (store?.threads ?? []).filter { !$0.hidden && !$0.isApp } |
| @@ -224,12 +231,7 @@ struct ServerChatsStep: View { |
| 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) |
| + ShareAvatar(initial: thread.title, source: thread.avatar) |
| Text(thread.title).font(.body.weight(.medium)).lineLimit(1) |
| Spacer() |
| if sendingID == thread.id { ProgressView() } |
| @@ -249,6 +251,7 @@ struct ServerChatsStep: View { |
| Text(status).font(.footnote).foregroundStyle(.secondary).padding(8) |
| } |
| } |
| + .overlay { if let sentTo { SentConfirmation(chat: sentTo) } } |
| .task { |
| let s = ThreadStore(instance: instance) |
| await s.refresh() |
| @@ -264,8 +267,51 @@ struct ServerChatsStep: View { |
| 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." } |
| + 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." |
| + } |
| + } |
| + } |
| +} |
| + |
| +// An avatar circle for the picker rows: encrypted chat photos come through |
| +// BlobImageLoader (fetch + decrypt, LRU) exactly like the app's; server |
| +// avatars are the plaintext branding image (/api/server-avatar). Initials |
| +// while loading / when there's no photo. |
| +struct ShareAvatar: View { |
| + let initial: String |
| + var source: AvatarSource? = nil |
| + var serverOrigin: String? = nil |
| + |
| + @State private var image: PlatformImage? |
| + |
| + var body: some View { |
| + ZStack { |
| + if let image { |
| + Image(uiImage: image).resizable().scaledToFill() |
| + } else { |
| + Circle().fill(.tint.opacity(0.15)) |
| + Text(String(initial.prefix(1)).uppercased()) |
| + .font(.system(size: 14, weight: .semibold)) |
| + } |
| + } |
| + .frame(width: 34, height: 34) |
| + .clipShape(Circle()) |
| + .task(id: (source?.blobID ?? "") + (serverOrigin ?? "")) { |
| + if let source { |
| + image = await BlobImageLoader.shared.image(source) |
| + } else if let serverOrigin, let url = URL(string: serverOrigin + "/api/server-avatar") { |
| + if let (data, resp) = try? await URLSession.shared.data(from: url), |
| + (resp as? HTTPURLResponse)?.statusCode == 200 { |
| + image = PlatformImage(data: data) |
| + } |
| + } |
| } |
| } |
| } |
| @@ -288,3 +334,28 @@ enum ShareSend { |
| return await model.sendViaREST("", attachments: attachments) |
| } |
| } |
| + |
| + |
| +// The beat of confirmation before the sheet closes itself. |
| +struct SentConfirmation: View { |
| + let chat: String |
| + @State private var shown = false |
| + |
| + var body: some View { |
| + VStack(spacing: 10) { |
| + Image(systemName: "checkmark.circle.fill") |
| + .font(.system(size: 44)) |
| + .foregroundStyle(.green) |
| + .scaleEffect(shown ? 1 : 0.6) |
| + Text("Sent to \(chat)") |
| + .font(.headline) |
| + } |
| + .padding(28) |
| + .background(RoundedRectangle(cornerRadius: 20, style: .continuous) |
| + .fill(.regularMaterial)) |
| + .opacity(shown ? 1 : 0) |
| + .onAppear { |
| + withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) { shown = true } |
| + } |
| + } |
| +} |