eleven / ocho Public

ios: Camera option on the composer plus button (#1391)

* ios: Camera option on the composer plus button

The plus menu gains a Camera row (device-gated — simulators and
camera-less iPads never show it) that opens the system capture UI
full-screen; the shot stages through the ordinary image-upload path, so
it gets the same downscale + EXIF/location strip + encryption as every
other photo source. Stills only for now — movie capture would drag in a
microphone permission. The camera usage string now covers photo capture
alongside QR scanning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: regenerated Info.plist for the camera usage string

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c64742975e1a44822bd1873177c86388cb453aac parent a0159b7
3 files changed, +55 −2
  • ios/LChat/Info.plist +1 −1
  • ios/LChat/Views/NativeChatScreen.swift +53 −0
  • ios/project.yml +1 −1
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist
index 6163179..c56a4c7 100644
--- a/ios/LChat/Info.plist
+++ b/ios/LChat/Info.plist
@@ -43,7 +43,7 @@
<true/>
</dict>
<key>NSCameraUsageDescription</key>
- <string>Camera access is needed to scan QR codes for joining chats</string>
+ <string>Camera access lets you take photos to share in chats and scan QR codes for joining</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index c7b6249..152b233 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -1057,6 +1057,13 @@ struct NativeChatScreen: View {
// web composer hides its attach button the same way).
if defaultCommand == nil {
Menu {
+ // Simulators (and some iPads) have no camera — the row
+ // just doesn't exist there rather than failing on tap.
+ if UIImagePickerController.isSourceTypeAvailable(.camera) {
+ Button { showCamera = true } label: {
+ Label("Camera", systemImage: "camera")
+ }
+ }
Button { showPhotoPicker = true } label: {
Label("Photo or Video", systemImage: "photo")
}
@@ -1124,6 +1131,13 @@ struct NativeChatScreen: View {
.fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in
if case .success(let url) = result { stageFile(url) }
}
+ // A fresh capture stages exactly like a pasted image — the upload
+ // path's processImage downscales and strips EXIF/location before
+ // encryption, same as every other photo source.
+ .fullScreenCover(isPresented: $showCamera) {
+ CameraCapture { data in stageImageData(data, name: "photo.jpg") }
+ .ignoresSafeArea()
+ }
// Drop-to-attach (iPad split view, iPhone app-to-app drags): images,
// movies and files stage exactly like the pickers' choices. The system
// drag badge is the whole affordance — no overlay (matching the Mac).
@@ -1163,6 +1177,7 @@ struct NativeChatScreen: View {
}
@State private var showPhotoPicker = false
+ @State private var showCamera = false
private var canSend: Bool {
let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@@ -1652,6 +1667,44 @@ struct NativeChatScreen: View {
}
}
+// The system camera (UIImagePickerController — SwiftUI has no native capture
+// view). Stills only, on purpose: movie capture records audio, which would
+// demand a microphone-permission prompt for a flow nobody asked for. The
+// shot comes back as JPEG bytes and rides the ordinary image-upload path.
+private struct CameraCapture: UIViewControllerRepresentable {
+ let onCapture: (Data) -> Void
+ @Environment(\.dismiss) private var dismiss
+
+ func makeUIViewController(context: Context) -> UIImagePickerController {
+ let picker = UIImagePickerController()
+ picker.sourceType = .camera
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
+ let parent: CameraCapture
+ init(_ parent: CameraCapture) { self.parent = parent }
+
+ func imagePickerController(_ picker: UIImagePickerController,
+ didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
+ if let image = info[.originalImage] as? UIImage,
+ let data = image.jpegData(compressionQuality: 0.9) {
+ parent.onCapture(data)
+ }
+ parent.dismiss()
+ }
+
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
+ parent.dismiss()
+ }
+ }
+}
+
// Full-screen encrypted-video playback — an AVPlayerViewController wrapped so
// its letterbox is TRANSPARENT (PlayerSurface): the backdrop is our own layer,
// so the dismiss swipe translates only the video surface (scaling down as it
diff --git a/ios/project.yml b/ios/project.yml
index 7c7b2d9..1f79852 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -67,7 +67,7 @@ targets:
# builds don't stall on the per-build "Missing Compliance" prompt.
ITSAppUsesNonExemptEncryption: false
CFBundleDisplayName: Eleven
- NSCameraUsageDescription: Camera access is needed to scan QR codes for joining chats
+ NSCameraUsageDescription: Camera access lets you take photos to share in chats and scan QR codes for joining
UILaunchScreen: {}
# Communication notifications (sender's photo on message banners): the
# system requires the intent to be declared here, alongside the