eleven / ocho Public

ios: staged images show a real preview — no filename chip, proper thumbnail (#565)

A pasted or picked image staged in the native composer rendered as a
26pt postage stamp inside a capsule chip with its synthetic filename
("pasted.jpg") — noise, and far too small to act as a preview. Staged
pictures now render as a real thumbnail tray: 96pt-tall rounded-rect
aspect-fill thumbnails (width follows the image's aspect, clamped
64–160pt), a dark circular ✕ scrim pinned to the top-right, and the
upload progress drawn over the dimmed image. Multiple images scroll
horizontally. Non-image files keep the compact chip WITH the filename —
there the name is the content. Presentation only: staging state, upload
path and send behaviour are untouched.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cf5d29032a7d84857c265462329aa0429680d79b parent 706c558
1 file changed, +68 −25
  • ios/LChat/Views/NativeChatScreen.swift +68 −25
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 80ff5d5..8a851d8 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -1258,41 +1258,84 @@ struct NativeChatScreen: View {
// ---- uploads -------------------------------------------------------------
+ // Staged pictures read as a real preview — a thumbnail tray, WhatsApp/
+ // Signal proportions. No filename on an image (a synthetic "pasted.jpg"
+ // is pure noise); non-image files keep the compact name chip, because
+ // there the filename IS the content.
private var stagedChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 6) {
+ HStack(alignment: .bottom, spacing: 8) {
ForEach(staged) { s in
- HStack(spacing: 5) {
- if let preview = s.preview {
- Image(uiImage: preview)
- .resizable().scaledToFill()
- .frame(width: 26, height: 26)
- .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
- .opacity(s.attachment == nil ? 0.55 : 1)
- }
- if s.attachment == nil {
- ProgressView(value: s.progress).frame(width: 40)
- } else if s.preview == nil {
- Text(fileIcon(s.name)).font(.system(size: 13))
- }
- Text(s.name).font(.system(size: 12)).lineLimit(1)
- Button {
- staged.removeAll { $0.id == s.id }
- } label: {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 12))
- .foregroundStyle(.tertiary)
- }
+ if let preview = s.preview {
+ stagedThumb(s, preview: preview)
+ } else {
+ stagedFileChip(s)
}
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(Capsule().fill(.primary.opacity(0.06)))
}
}
.padding(.horizontal, 14)
}
}
+ private func stagedThumb(_ s: StagedUpload, preview: UIImage) -> some View {
+ // Fixed height, width follows the image's aspect (clamped so a
+ // panorama can't swallow the tray and a tall shot stays readable).
+ let h: CGFloat = 96
+ let aspect = preview.size.height > 0 ? preview.size.width / preview.size.height : 1
+ let w = min(max(h * aspect, 64), 160)
+ return Image(uiImage: preview)
+ .resizable().scaledToFill()
+ .frame(width: w, height: h)
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .opacity(s.attachment == nil ? 0.55 : 1)
+ .overlay(alignment: .bottom) {
+ if s.attachment == nil {
+ ProgressView(value: s.progress)
+ .tint(.white)
+ .padding(.horizontal, 10)
+ .padding(.bottom, 8)
+ }
+ }
+ .overlay(alignment: .topTrailing) {
+ // Dark scrim keeps the ✕ legible over any photo, either theme.
+ Button {
+ staged.removeAll { $0.id == s.id }
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(width: 22, height: 22)
+ .background(Circle().fill(.black.opacity(0.55)))
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .padding(5)
+ .accessibilityLabel("Remove attachment")
+ }
+ }
+
+ private func stagedFileChip(_ s: StagedUpload) -> some View {
+ HStack(spacing: 5) {
+ if s.attachment == nil {
+ ProgressView(value: s.progress).frame(width: 40)
+ } else {
+ Text(fileIcon(s.name)).font(.system(size: 13))
+ }
+ Text(s.name).font(.system(size: 12)).lineLimit(1)
+ Button {
+ staged.removeAll { $0.id == s.id }
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 12))
+ .foregroundStyle(.tertiary)
+ }
+ .accessibilityLabel("Remove attachment")
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Capsule().fill(.primary.opacity(0.06)))
+ }
+
private func stagePickerItem(_ item: PhotosPickerItem) {
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) }
let name = isVideo ? "video.mp4" : "photo.jpg"