eleven / messenger Public

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>
Paul Campbell d8ed11cf4cf3de431a907daf5c58e00aef27cc20 parent 9008d6d
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 }
+ }
+ }
+}