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()) result = json.loads(resp.read())
issue_number = result.get("number") issue_number = result.get("number")
# Upload image attachment if provided # Upload image attachments if provided
image_b64 = data.get("image") if issue_number:
if image_b64 and issue_number:
import base64 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: try:
img_bytes = base64.b64decode(image_b64) img_bytes = base64.b64decode(img_b64)
boundary = "----FeedbackBoundary123" filename = f"screenshot{'_' + str(idx + 1) if idx > 0 else ''}.png"
boundary = f"----FeedbackBoundary{idx}"
body_parts = [] body_parts = []
body_parts.append(f"--{boundary}\r\n".encode()) 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(b"Content-Type: image/png\r\n\r\n")
body_parts.append(img_bytes) body_parts.append(img_bytes)
body_parts.append(f"\r\n--{boundary}--\r\n".encode()) body_parts.append(f"\r\n--{boundary}--\r\n".encode())
@@ -125,7 +132,7 @@ def handle_feedback(handler, body, user):
method="POST", method="POST",
) )
urllib.request.urlopen(img_req, timeout=15) urllib.request.urlopen(img_req, timeout=15)
except Exception as img_err: except Exception:
pass # Image upload failed but issue was created pass # Image upload failed but issue was created
handler._send_json({ handler._send_json({

View File

@@ -27,9 +27,8 @@ struct FeedbackSheet: View {
@State private var isSending = false @State private var isSending = false
@State private var sent = false @State private var sent = false
@State private var error: String? @State private var error: String?
@State private var selectedPhoto: PhotosPickerItem? @State private var selectedPhotos: [PhotosPickerItem] = []
@State private var photoData: Data? @State private var photoPreviews: [(Data, UIImage)] = []
@State private var photoPreview: UIImage?
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
@@ -68,13 +67,17 @@ struct FeedbackSheet: View {
.stroke(Color.textTertiary.opacity(0.2), lineWidth: 1) .stroke(Color.textTertiary.opacity(0.2), lineWidth: 1)
) )
// Photo attachment // Photo attachments
HStack { VStack(alignment: .leading, spacing: 8) {
PhotosPicker(selection: $selectedPhoto, matching: .screenshots) { PhotosPicker(
selection: $selectedPhotos,
maxSelectionCount: 5,
matching: .images
) {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.font(.caption) .font(.caption)
Text(photoPreview != nil ? "Change photo" : "Add screenshot") Text(photoPreviews.isEmpty ? "Add screenshots" : "Add more")
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
} }
.foregroundStyle(Color.accentWarm) .foregroundStyle(Color.accentWarm)
@@ -84,25 +87,34 @@ struct FeedbackSheet: View {
.clipShape(Capsule()) .clipShape(Capsule())
} }
if let preview = photoPreview { if !photoPreviews.isEmpty {
Image(uiImage: preview) ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(photoPreviews.enumerated()), id: \.offset) { index, item in
ZStack(alignment: .topTrailing) {
Image(uiImage: item.1)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: 40, height: 40) .frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
Button { Button {
photoData = nil photoPreviews.remove(at: index)
photoPreview = nil if index < selectedPhotos.count {
selectedPhoto = nil selectedPhotos.remove(at: index)
}
} label: { } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.font(.caption) .font(.system(size: 16))
.foregroundStyle(Color.textTertiary) .foregroundStyle(.white)
.background(Circle().fill(.black.opacity(0.5)))
}
.offset(x: 4, y: -4)
}
}
}
} }
} }
Spacer()
} }
if let error { if let error {
@@ -131,14 +143,17 @@ struct FeedbackSheet: View {
} }
.padding() .padding()
.background(Color.surfaceCard) .background(Color.surfaceCard)
.onChange(of: selectedPhoto) { .onChange(of: selectedPhotos) {
Task { Task {
if let item = selectedPhoto, var newPreviews: [(Data, UIImage)] = []
let data = try? await item.loadTransferable(type: Data.self) { for item in selectedPhotos {
photoData = data if let data = try? await item.loadTransferable(type: Data.self),
photoPreview = UIImage(data: data) let image = UIImage(data: data) {
newPreviews.append((data, image))
} }
} }
photoPreviews = newPreviews
}
} }
} }
@@ -149,9 +164,13 @@ struct FeedbackSheet: View {
Task { @MainActor in Task { @MainActor in
do { do {
var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"] var body: [String: Any] = [
if let imgData = photoData { "text": text.trimmingCharacters(in: .whitespaces),
body["image"] = imgData.base64EncodedString() "source": "ios"
]
if !photoPreviews.isEmpty {
let images = photoPreviews.map { $0.0.base64EncodedString() }
body["images"] = images
} }
let jsonData = try JSONSerialization.data(withJSONObject: body) let jsonData = try JSONSerialization.data(withJSONObject: body)
let data = try await APIClient.shared.rawPost(path: "/api/feedback", data: jsonData) let data = try await APIClient.shared.rawPost(path: "/api/feedback", data: jsonData)