From 0b74493db04ef3a5ecbf82da19db1e0891ab226d Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 20:34:25 -0500 Subject: [PATCH] feat: multi-photo support in feedback (up to 5 screenshots) iOS: - PhotosPicker with maxSelectionCount: 5 - Horizontal scroll preview strip with individual remove buttons - Sends "images" array instead of single "image" Server: - Gateway accepts both "image" (single, backwards compat) and "images" (array) fields - Uploads each as separate Gitea issue attachment Also closed Gitea issues #11, #12, #13, #14. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/feedback.py | 57 +++++++------ .../Features/Feedback/FeedbackView.swift | 81 ++++++++++++------- 2 files changed, 82 insertions(+), 56 deletions(-) diff --git a/gateway/feedback.py b/gateway/feedback.py index a1fa1ae..459b6b7 100644 --- a/gateway/feedback.py +++ b/gateway/feedback.py @@ -100,33 +100,40 @@ def handle_feedback(handler, body, user): result = json.loads(resp.read()) issue_number = result.get("number") - # Upload image attachment if provided - image_b64 = data.get("image") - if image_b64 and issue_number: + # Upload image attachments if provided + if issue_number: import base64 - try: - img_bytes = base64.b64decode(image_b64) - boundary = "----FeedbackBoundary123" - body_parts = [] - body_parts.append(f"--{boundary}\r\n".encode()) - body_parts.append(b'Content-Disposition: form-data; name="attachment"; filename="screenshot.png"\r\n') - body_parts.append(b"Content-Type: image/png\r\n\r\n") - body_parts.append(img_bytes) - body_parts.append(f"\r\n--{boundary}--\r\n".encode()) - multipart_body = b"".join(body_parts) + # Support single "image" or multiple "images" array + images_b64 = data.get("images", []) + single_image = data.get("image") + if single_image: + images_b64.insert(0, single_image) - img_req = urllib.request.Request( - f"{GITEA_URL}/api/v1/repos/{REPO}/issues/{issue_number}/assets", - data=multipart_body, - headers={ - "Authorization": f"token {GITEA_TOKEN}", - "Content-Type": f"multipart/form-data; boundary={boundary}", - }, - method="POST", - ) - urllib.request.urlopen(img_req, timeout=15) - except Exception as img_err: - pass # Image upload failed but issue was created + for idx, img_b64 in enumerate(images_b64): + try: + img_bytes = base64.b64decode(img_b64) + filename = f"screenshot{'_' + str(idx + 1) if idx > 0 else ''}.png" + boundary = f"----FeedbackBoundary{idx}" + body_parts = [] + body_parts.append(f"--{boundary}\r\n".encode()) + body_parts.append(f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n'.encode()) + body_parts.append(b"Content-Type: image/png\r\n\r\n") + body_parts.append(img_bytes) + body_parts.append(f"\r\n--{boundary}--\r\n".encode()) + multipart_body = b"".join(body_parts) + + img_req = urllib.request.Request( + f"{GITEA_URL}/api/v1/repos/{REPO}/issues/{issue_number}/assets", + data=multipart_body, + headers={ + "Authorization": f"token {GITEA_TOKEN}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + method="POST", + ) + urllib.request.urlopen(img_req, timeout=15) + except Exception: + pass # Image upload failed but issue was created handler._send_json({ "ok": True, diff --git a/ios/Platform/Platform/Features/Feedback/FeedbackView.swift b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift index 54c16cc..3df0565 100644 --- a/ios/Platform/Platform/Features/Feedback/FeedbackView.swift +++ b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift @@ -27,9 +27,8 @@ struct FeedbackSheet: View { @State private var isSending = false @State private var sent = false @State private var error: String? - @State private var selectedPhoto: PhotosPickerItem? - @State private var photoData: Data? - @State private var photoPreview: UIImage? + @State private var selectedPhotos: [PhotosPickerItem] = [] + @State private var photoPreviews: [(Data, UIImage)] = [] var body: some View { VStack(spacing: 16) { @@ -68,13 +67,17 @@ struct FeedbackSheet: View { .stroke(Color.textTertiary.opacity(0.2), lineWidth: 1) ) - // Photo attachment - HStack { - PhotosPicker(selection: $selectedPhoto, matching: .screenshots) { + // Photo attachments + VStack(alignment: .leading, spacing: 8) { + PhotosPicker( + selection: $selectedPhotos, + maxSelectionCount: 5, + matching: .images + ) { HStack(spacing: 6) { Image(systemName: "camera.fill") .font(.caption) - Text(photoPreview != nil ? "Change photo" : "Add screenshot") + Text(photoPreviews.isEmpty ? "Add screenshots" : "Add more") .font(.caption.weight(.medium)) } .foregroundStyle(Color.accentWarm) @@ -84,25 +87,34 @@ struct FeedbackSheet: View { .clipShape(Capsule()) } - if let preview = photoPreview { - Image(uiImage: preview) - .resizable() - .scaledToFill() - .frame(width: 40, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 8)) + if !photoPreviews.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(photoPreviews.enumerated()), id: \.offset) { index, item in + ZStack(alignment: .topTrailing) { + Image(uiImage: item.1) + .resizable() + .scaledToFill() + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) - Button { - photoData = nil - photoPreview = nil - selectedPhoto = nil - } label: { - Image(systemName: "xmark.circle.fill") - .font(.caption) - .foregroundStyle(Color.textTertiary) + Button { + photoPreviews.remove(at: index) + if index < selectedPhotos.count { + selectedPhotos.remove(at: index) + } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.white) + .background(Circle().fill(.black.opacity(0.5))) + } + .offset(x: 4, y: -4) + } + } + } } } - - Spacer() } if let error { @@ -131,13 +143,16 @@ struct FeedbackSheet: View { } .padding() .background(Color.surfaceCard) - .onChange(of: selectedPhoto) { + .onChange(of: selectedPhotos) { Task { - if let item = selectedPhoto, - let data = try? await item.loadTransferable(type: Data.self) { - photoData = data - photoPreview = UIImage(data: data) + var newPreviews: [(Data, UIImage)] = [] + for item in selectedPhotos { + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + newPreviews.append((data, image)) + } } + photoPreviews = newPreviews } } } @@ -149,9 +164,13 @@ struct FeedbackSheet: View { Task { @MainActor in do { - var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"] - if let imgData = photoData { - body["image"] = imgData.base64EncodedString() + var body: [String: Any] = [ + "text": text.trimmingCharacters(in: .whitespaces), + "source": "ios" + ] + if !photoPreviews.isEmpty { + let images = photoPreviews.map { $0.0.base64EncodedString() } + body["images"] = images } let jsonData = try JSONSerialization.data(withJSONObject: body) let data = try await APIClient.shared.rawPost(path: "/api/feedback", data: jsonData)