feat: multi-photo support in feedback (up to 5 screenshots)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

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) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 20:34:25 -05:00
parent f17279d5b8
commit 0b74493db0
2 changed files with 82 additions and 56 deletions

View File

@@ -100,16 +100,23 @@ 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
# 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)
for idx, img_b64 in enumerate(images_b64):
try:
img_bytes = base64.b64decode(image_b64)
boundary = "----FeedbackBoundary123"
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(b'Content-Disposition: form-data; name="attachment"; filename="screenshot.png"\r\n')
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())
@@ -125,7 +132,7 @@ def handle_feedback(handler, body, user):
method="POST",
)
urllib.request.urlopen(img_req, timeout=15)
except Exception as img_err:
except Exception:
pass # Image upload failed but issue was created
handler._send_json({

View File

@@ -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)
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: 40, height: 40)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
Button {
photoData = nil
photoPreview = nil
selectedPhoto = nil
photoPreviews.remove(at: index)
if index < selectedPhotos.count {
selectedPhotos.remove(at: index)
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(Color.textTertiary)
.font(.system(size: 16))
.foregroundStyle(.white)
.background(Circle().fill(.black.opacity(0.5)))
}
.offset(x: 4, y: -4)
}
}
}
}
}
Spacer()
}
if let error {
@@ -131,14 +143,17 @@ 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)