diff --git a/bitchat/Views/GeohashMapView.swift b/bitchat/Views/GeohashMapView.swift new file mode 100644 index 000000000..47dd2c02f --- /dev/null +++ b/bitchat/Views/GeohashMapView.swift @@ -0,0 +1,410 @@ +import SwiftUI +import WebKit + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +// MARK: - Geohash Picker using Leaflet (like Android) + +struct GeohashMapView: View { + @Binding var selectedGeohash: String + @Environment(\.colorScheme) var colorScheme + + var body: some View { + GeohashWebView(selectedGeohash: $selectedGeohash, colorScheme: colorScheme) + .onAppear { + // Initialize with current location if available + if selectedGeohash.isEmpty { + if let currentChannel = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .city || $0.level == .neighborhood }) { + selectedGeohash = currentChannel.geohash + } + } + } + } +} + +// MARK: - WebKit Bridge + +#if os(iOS) +struct GeohashWebView: UIViewRepresentable { + @Binding var selectedGeohash: String + let colorScheme: ColorScheme + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: config) + + // Enable JavaScript and disable scrolling + webView.isOpaque = false + webView.backgroundColor = UIColor.clear + webView.scrollView.isScrollEnabled = false + webView.scrollView.bounces = false + + // Add JavaScript interface + let contentController = webView.configuration.userContentController + contentController.add(context.coordinator, name: "iOS") + + // Load the HTML content + let htmlString = geohashPickerHTML(theme: colorScheme == .dark ? "dark" : "light") + webView.loadHTMLString(htmlString, baseURL: nil) + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // Update theme if needed + let theme = colorScheme == .dark ? "dark" : "light" + webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')") + + // Focus on geohash if it changed + if !selectedGeohash.isEmpty && context.coordinator.lastGeohash != selectedGeohash { + webView.evaluateJavaScript("window.focusGeohash && window.focusGeohash('\(selectedGeohash)')") + context.coordinator.lastGeohash = selectedGeohash + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } +} +#elseif os(macOS) +struct GeohashWebView: NSViewRepresentable { + @Binding var selectedGeohash: String + let colorScheme: ColorScheme + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: config) + + // Add JavaScript interface + let contentController = webView.configuration.userContentController + contentController.add(context.coordinator, name: "macOS") + + // Load the HTML content + let htmlString = geohashPickerHTML(theme: colorScheme == .dark ? "dark" : "light") + webView.loadHTMLString(htmlString, baseURL: nil) + + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + // Update theme if needed + let theme = colorScheme == .dark ? "dark" : "light" + webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')") + + // Focus on geohash if it changed + if !selectedGeohash.isEmpty && context.coordinator.lastGeohash != selectedGeohash { + webView.evaluateJavaScript("window.focusGeohash && window.focusGeohash('\(selectedGeohash)')") + context.coordinator.lastGeohash = selectedGeohash + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } +} +#endif + +extension GeohashWebView { + class Coordinator: NSObject, WKScriptMessageHandler { + let parent: GeohashWebView + var lastGeohash: String = "" + + init(_ parent: GeohashWebView) { + self.parent = parent + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "iOS" || message.name == "macOS" { + if let geohash = message.body as? String { + DispatchQueue.main.async { + self.parent.selectedGeohash = geohash + self.lastGeohash = geohash + } + } + } + } + } +} + +// MARK: - Leaflet HTML (same as Android) + +private func geohashPickerHTML(theme: String) -> String { + return """ + + +
+ + + + + + + + + + + + +""" +} + +#Preview { + GeohashMapView(selectedGeohash: .constant("9q8yy")) +} diff --git a/bitchat/Views/GeohashPickerSheet.swift b/bitchat/Views/GeohashPickerSheet.swift new file mode 100644 index 000000000..6fa34455e --- /dev/null +++ b/bitchat/Views/GeohashPickerSheet.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct GeohashPickerSheet: View { + @Binding var isPresented: Bool + let onGeohashSelected: (String) -> Void + @State private var selectedGeohash: String = "" + @Environment(\.colorScheme) var colorScheme + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color.white + } + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + // Selected geohash display + HStack { + Text(selectedGeohash.isEmpty ? "pan and zoom to select" : "#\(selectedGeohash)") + .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) + .frame(maxWidth: .infinity, alignment: .leading) + + Button("select") { + if !selectedGeohash.isEmpty { + onGeohashSelected(selectedGeohash) + } + } + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(selectedGeohash.isEmpty ? Color.secondary : textColor) + .disabled(selectedGeohash.isEmpty) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(backgroundColor.opacity(0.1)) + .cornerRadius(8) + + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(backgroundColor.opacity(0.95)) + + Divider() + + // Map view (Leaflet-based, same as Android) + GeohashMapView(selectedGeohash: $selectedGeohash) + } + .background(backgroundColor) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) + #else + .navigationTitle("") + #endif + } + #if os(iOS) + .presentationDetents([.large]) + #endif + #if os(macOS) + .frame(minWidth: 800, idealWidth: 1200, maxWidth: .infinity, minHeight: 600, idealHeight: 800, maxHeight: .infinity) + #endif + .background(backgroundColor) + } +} + +#Preview { + GeohashPickerSheet( + isPresented: .constant(true), + onGeohashSelected: { _ in } + ) +} diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 8fac888f4..1cf88f1dc 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -14,6 +14,7 @@ struct LocationChannelsSheet: View { @Environment(\.colorScheme) var colorScheme @State private var customGeohash: String = "" @State private var customError: String? = nil + @State private var showGeohashMap = false private var backgroundColor: Color { colorScheme == .dark ? .black : .white } @@ -167,6 +168,55 @@ struct LocationChannelsSheet: View { .padding(.vertical, 8) } + + // Custom geohash entry with map picker and teleport + VStack(alignment: .leading, spacing: 6) { + let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") + let isValid = validateGeohash(normalized) + + HStack(spacing: 8) { + Text("#") + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(.secondary) + TextField("geohash", text: $customGeohash) + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.asciiCapable) + #endif + .font(.bitchatSystem(size: 14, design: .monospaced)) + .onChange(of: customGeohash) { newValue in + // Allow only geohash base32 characters, strip '#', limit length + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + let filtered = newValue + .lowercased() + .replacingOccurrences(of: "#", with: "") + .filter { allowed.contains($0) } + if filtered.count > 12 { + customGeohash = String(filtered.prefix(12)) + } else if filtered != newValue { + customGeohash = filtered + } + } + + // Map picker button + Button(action: { showGeohashMap = true }) { + Image(systemName: "map") + .font(.bitchatSystem(size: 14)) + } + .buttonStyle(.plain) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(6) + .sheet(isPresented: $showGeohashMap) { + GeohashPickerSheet(isPresented: $showGeohashMap) { selectedGeohash in + customGeohash = selectedGeohash + showGeohashMap = false + } + .environmentObject(viewModel) + } + + // Teleport button if manager.permissionState == LocationChannelManager.PermissionState.authorized { sectionDivider torToggleSection