| index fada765..34e8746 100644 |
| --- a/ios/LChat/Views/JoinView.swift |
| +++ b/ios/LChat/Views/JoinView.swift |
| @@ -1,5 +1,7 @@ |
| import SwiftUI |
| import WebKit |
| +import VisionKit |
| +import AVFoundation |
| |
| // Add a workspace: paste a new-format invite link (`https://…/join/<slug>#<T>`), then |
| // let the web join flow do the keypair work. The join runs in a one-shot WKWebView |
| @@ -22,6 +24,9 @@ struct AddInstanceView: View { |
| @State private var origin = "" |
| @State private var error: String? |
| @State private var pending: Task<Void, Never>? |
| + #if !targetEnvironment(macCatalyst) |
| + @State private var showScanner = false |
| + #endif |
| |
| // Called after the new chat is added, so the caller can select it (and show |
| // its threads straight away). |
| @@ -80,10 +85,40 @@ struct AddInstanceView: View { |
| .font(.caption) |
| .foregroundStyle(.secondary) |
| } |
| + |
| + #if !targetEnvironment(macCatalyst) |
| + if DataScannerViewController.isSupported { |
| + Button { |
| + error = nil |
| + showScanner = true |
| + } label: { |
| + Label("Scan QR code", systemImage: "qrcode.viewfinder") |
| + } |
| + .buttonStyle(.bordered) |
| + } |
| + #endif |
| } |
| .padding() |
| } |
| + #if !targetEnvironment(macCatalyst) |
| + .fullScreenCover(isPresented: $showScanner) { |
| + QRScanSheet(onScanned: handleScanned) |
| + } |
| + #endif |
| + } |
| + |
| + #if !targetEnvironment(macCatalyst) |
| + // A scanned QR code carries the same invite link a paste would — route it |
| + // through the identical validation/start path so both entry points behave alike. |
| + private func handleScanned(_ payload: String) { |
| + link = payload |
| + guard parseJoinLink(payload) != nil else { |
| + error = "That QR code doesn't look like an invite link." |
| + return |
| + } |
| + start() |
| } |
| + #endif |
| |
| private func step(_ n: Int, _ markdown: String) -> some View { |
| HStack(alignment: .firstTextBaseline, spacing: 8) { |
| @@ -201,3 +236,118 @@ struct JoinWebView: UIViewRepresentable { |
| } |
| } |
| } |
| + |
| +// DataScannerViewController is unavailable on Mac Catalyst; the Mac app has its |
| +// own AddChatView anyway, so QR scanning is iOS/iPadOS only. |
| +#if !targetEnvironment(macCatalyst) |
| + |
| +// Full-screen camera view presented from AddInstanceView's "Scan QR code" button. |
| +// Wraps the scanner with a Cancel bar since DataScannerViewController has no chrome |
| +// of its own. Camera authorization is requested up front — DataScannerViewController |
| +// doesn't reliably surface the system permission prompt itself, and silently starting |
| +// it pre-authorization just shows a dead, empty camera view. |
| +private struct QRScanSheet: View { |
| + let onScanned: (String) -> Void |
| + @Environment(\.dismiss) private var dismiss |
| + @State private var authorized: Bool? |
| + |
| + var body: some View { |
| + ZStack(alignment: .top) { |
| + switch authorized { |
| + case true: |
| + QRScannerView(onScanned: { payload in |
| + dismiss() |
| + onScanned(payload) |
| + }) |
| + .ignoresSafeArea() |
| + case false: |
| + cameraDeniedMessage |
| + case nil: |
| + Color.black.ignoresSafeArea() |
| + } |
| + |
| + HStack { |
| + Button("Cancel") { dismiss() } |
| + .foregroundStyle(.white) |
| + Spacer() |
| + } |
| + .padding() |
| + .background(LinearGradient(colors: [.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom)) |
| + } |
| + .task { |
| + switch AVCaptureDevice.authorizationStatus(for: .video) { |
| + case .authorized: authorized = true |
| + case .notDetermined: authorized = await AVCaptureDevice.requestAccess(for: .video) |
| + default: authorized = false |
| + } |
| + } |
| + } |
| + |
| + private var cameraDeniedMessage: some View { |
| + VStack(spacing: 12) { |
| + Image(systemName: "camera.fill") |
| + .font(.largeTitle) |
| + .foregroundStyle(.white) |
| + Text("Camera access is off") |
| + .font(.headline) |
| + .foregroundStyle(.white) |
| + Text("Turn it on in Settings to scan an invite QR code.") |
| + .font(.callout) |
| + .foregroundStyle(.white.opacity(0.8)) |
| + .multilineTextAlignment(.center) |
| + Button("Open Settings") { |
| + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } |
| + UIApplication.shared.open(url) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .padding(.top, 8) |
| + } |
| + .padding(32) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + .background(Color.black.ignoresSafeArea()) |
| + } |
| +} |
| + |
| +// VisionKit's live camera scanner, restricted to QR payloads — we only want the |
| +// invite-link URL text out of the frame, not general barcode/text recognition. |
| +private struct QRScannerView: UIViewControllerRepresentable { |
| + let onScanned: (String) -> Void |
| + |
| + func makeUIViewController(context: Context) -> DataScannerViewController { |
| + let vc = DataScannerViewController( |
| + recognizedDataTypes: [.barcode(symbologies: [.qr])], |
| + qualityLevel: .balanced, |
| + isHighlightingEnabled: true |
| + ) |
| + vc.delegate = context.coordinator |
| + return vc |
| + } |
| + |
| + func updateUIViewController(_ vc: DataScannerViewController, context: Context) { |
| + guard !vc.isScanning else { return } |
| + try? vc.startScanning() |
| + } |
| + |
| + static func dismantleUIViewController(_ vc: DataScannerViewController, coordinator: Coordinator) { |
| + vc.stopScanning() |
| + } |
| + |
| + func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) } |
| + |
| + final class Coordinator: NSObject, DataScannerViewControllerDelegate { |
| + let onScanned: (String) -> Void |
| + private var done = false |
| + |
| + init(onScanned: @escaping (String) -> Void) { |
| + self.onScanned = onScanned |
| + } |
| + |
| + func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { |
| + guard !done, case let .barcode(barcode) = addedItems.first, let payload = barcode.payloadStringValue else { return } |
| + done = true |
| + onScanned(payload) |
| + } |
| + } |
| +} |
| + |
| +#endif |