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>
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" |