diff --git a/bitchat/Resources/geohash-map.html b/bitchat/Resources/geohash-map.html
new file mode 100644
index 000000000..54068c053
--- /dev/null
+++ b/bitchat/Resources/geohash-map.html
@@ -0,0 +1,397 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift
index 1a5ee44eb..0f5c9448e 100644
--- a/bitchat/Views/ContentView.swift
+++ b/bitchat/Views/ContentView.swift
@@ -56,6 +56,7 @@ struct ContentView: View {
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set = []
@State private var showLocationNotes = false
+ @State private var customGeohash: String = ""
@State private var notesGeohash: String? = nil
@State private var sheetNotesCount: Int = 0
@State private var imagePreviewURL: URL? = nil
@@ -1379,8 +1380,11 @@ struct ContentView: View {
.frame(height: headerHeight)
.padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) {
- LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
- .onAppear { viewModel.isLocationChannelsSheetPresented = true }
+ LocationChannelsSheet(isPresented: $showLocationChannelsSheet, customGeohash: $customGeohash)
+ .onAppear {
+ viewModel.isLocationChannelsSheetPresented = true
+ customGeohash = ""
+ }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.sheet(isPresented: $showLocationNotes, onDismiss: {
diff --git a/bitchat/Views/GeohashMapView.swift b/bitchat/Views/GeohashMapView.swift
new file mode 100644
index 000000000..2b2592cb2
--- /dev/null
+++ b/bitchat/Views/GeohashMapView.swift
@@ -0,0 +1,501 @@
+import SwiftUI
+import WebKit
+
+#if os(iOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+// MARK: - Geohash Picker using Leaflet
+
+struct GeohashMapView: View {
+ @Binding var selectedGeohash: String
+ let initialGeohash: String
+ let showFloatingControls: Bool
+ @Binding var precision: Int?
+ @Environment(\.colorScheme) var colorScheme
+ @State private var webViewCoordinator: GeohashWebView.Coordinator?
+ @State private var currentPrecision: Int = 6 // Default to neighborhood level
+ @State private var isPinned: Bool = false
+
+ init(selectedGeohash: Binding, initialGeohash: String = "", showFloatingControls: Bool = true, precision: Binding = .constant(nil)) {
+ self._selectedGeohash = selectedGeohash
+ self.initialGeohash = initialGeohash
+ self.showFloatingControls = showFloatingControls
+ self._precision = precision
+ }
+
+ private var textColor: Color {
+ colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
+ }
+
+ var body: some View {
+ ZStack {
+ // Full-screen map
+ GeohashWebView(
+ selectedGeohash: $selectedGeohash,
+ initialGeohash: initialGeohash,
+ colorScheme: colorScheme,
+ currentPrecision: $currentPrecision,
+ isPinned: $isPinned,
+ onCoordinatorCreated: { coordinator in
+ DispatchQueue.main.async {
+ self.webViewCoordinator = coordinator
+ }
+ }
+ )
+ .ignoresSafeArea()
+
+ // Floating precision controls
+ if showFloatingControls {
+ VStack {
+ HStack {
+ Spacer()
+ VStack(spacing: 8) {
+ // Plus button
+ Button(action: {
+ if currentPrecision < 12 {
+ currentPrecision += 1
+ isPinned = true
+ webViewCoordinator?.setPrecision(currentPrecision)
+ }
+ }) {
+ Image(systemName: "plus")
+ .font(.system(size: 18, weight: .bold))
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .frame(width: 48, height: 48)
+ .background(
+ Circle()
+ .fill(colorScheme == .dark ? Color.black.opacity(0.8) : Color.white.opacity(0.9))
+ .overlay(
+ Circle()
+ .stroke(Color.secondary.opacity(0.3), lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
+ )
+ }
+ .disabled(currentPrecision >= 12)
+ .opacity(currentPrecision >= 12 ? 0.5 : 1.0)
+
+ // Minus button
+ Button(action: {
+ if currentPrecision > 1 {
+ currentPrecision -= 1
+ isPinned = true
+ webViewCoordinator?.setPrecision(currentPrecision)
+ }
+ }) {
+ Image(systemName: "minus")
+ .font(.system(size: 18, weight: .bold))
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .frame(width: 48, height: 48)
+ .background(
+ Circle()
+ .fill(colorScheme == .dark ? Color.black.opacity(0.8) : Color.white.opacity(0.9))
+ .overlay(
+ Circle()
+ .stroke(Color.secondary.opacity(0.3), lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
+ )
+ }
+ .disabled(currentPrecision <= 1)
+ .opacity(currentPrecision <= 1 ? 0.5 : 1.0)
+ }
+ .padding(.trailing, 16)
+ .padding(.top, 80)
+ }
+ Spacer()
+ }
+ }
+
+ // Bottom geohash info overlay
+ if showFloatingControls {
+ VStack {
+ Spacer()
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ if !selectedGeohash.isEmpty {
+ Text("#\(selectedGeohash)")
+ .font(.bitchatSystem(size: 16, weight: .semibold, design: .monospaced))
+ .foregroundColor(textColor)
+ } else {
+ Text(String(localized: "geohash_picker.instruction", comment: "Instruction text for geohash map picker"))
+ .font(.bitchatSystem(size: 14, design: .monospaced))
+ .foregroundColor(.secondary)
+ }
+
+ Text("precision: \(currentPrecision) • \(levelName(for: currentPrecision))")
+ .font(.bitchatSystem(size: 12, design: .monospaced))
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 16)
+ .background(
+ Rectangle()
+ .fill(colorScheme == .dark ? Color.black.opacity(0.9) : Color.white.opacity(0.9))
+ .overlay(
+ Rectangle()
+ .stroke(Color.secondary.opacity(0.2), lineWidth: 0.5)
+ )
+ )
+ }
+ }
+ }
+ .onAppear {
+ // Set initial precision based on selected geohash length
+ if !selectedGeohash.isEmpty {
+ currentPrecision = selectedGeohash.count
+ } else if !initialGeohash.isEmpty {
+ currentPrecision = initialGeohash.count
+ }
+ }
+ .onChange(of: selectedGeohash) { newValue in
+ if !newValue.isEmpty && newValue.count != currentPrecision && !isPinned {
+ currentPrecision = newValue.count
+ }
+ }
+ .onChange(of: precision) { newValue in
+ if let newPrecision = newValue, newPrecision != currentPrecision {
+ currentPrecision = newPrecision
+ isPinned = true
+ webViewCoordinator?.setPrecision(currentPrecision)
+ }
+ }
+ .onChange(of: currentPrecision) { newValue in
+ precision = newValue
+ }
+ }
+
+ private func levelName(for precision: Int) -> String {
+ let level = levelForPrecision(precision)
+ return level.displayName.lowercased()
+ }
+
+ private func levelForPrecision(_ precision: Int) -> GeohashChannelLevel {
+ switch precision {
+ case 8: return .building
+ case 7: return .block
+ case 6: return .neighborhood
+ case 5: return .city
+ case 4: return .province
+ case 0...3: return .region
+ default: return .neighborhood // Default fallback
+ }
+ }
+
+}
+
+
+
+// MARK: - WebKit Bridge
+
+#if os(iOS)
+struct GeohashWebView: UIViewRepresentable {
+ @Binding var selectedGeohash: String
+ let initialGeohash: String
+ let colorScheme: ColorScheme
+ @Binding var currentPrecision: Int
+ @Binding var isPinned: Bool
+ let onCoordinatorCreated: (Coordinator) -> Void
+
+ func makeUIView(context: Context) -> WKWebView {
+ let config = WKWebViewConfiguration()
+
+ // Configure to allow all touch events to pass through to web content
+ config.allowsInlineMediaPlayback = true
+ config.mediaTypesRequiringUserActionForPlayback = []
+
+ let webView = WKWebView(frame: .zero, configuration: config)
+
+ // Store webView reference in coordinator
+ context.coordinator.webView = webView
+
+ // Notify parent of coordinator creation
+ onCoordinatorCreated(context.coordinator)
+
+ // Enable JavaScript and configure touch gestures
+ webView.isOpaque = false
+ webView.backgroundColor = UIColor.clear
+
+ // Enable touch gestures and zoom
+ webView.scrollView.isScrollEnabled = true
+ webView.scrollView.bounces = false
+ webView.scrollView.bouncesZoom = false
+ webView.scrollView.showsHorizontalScrollIndicator = false
+ webView.scrollView.showsVerticalScrollIndicator = false
+
+ // Allow multiple touch gestures and disable WebView's native zoom to let Leaflet handle it
+ webView.allowsBackForwardNavigationGestures = false
+ webView.isMultipleTouchEnabled = true
+ webView.isUserInteractionEnabled = true
+
+ // Disable WebView's native zoom so Leaflet can handle double-tap zoom
+ webView.scrollView.minimumZoomScale = 1.0
+ webView.scrollView.maximumZoomScale = 1.0
+ webView.scrollView.zoomScale = 1.0
+
+ // Add JavaScript interface
+ let contentController = webView.configuration.userContentController
+ contentController.add(context.coordinator, name: "iOS")
+
+ // Set navigation delegate to handle page load completion
+ webView.navigationDelegate = context.coordinator
+
+ // Load the HTML content from Resources folder
+ if let path = Bundle.main.path(forResource: "geohash-map", ofType: "html"),
+ let htmlString = try? String(contentsOfFile: path) {
+ let theme = colorScheme == .dark ? "dark" : "light"
+ let processedHTML = htmlString.replacingOccurrences(of: "{{THEME}}", with: theme)
+ webView.loadHTMLString(processedHTML, baseURL: Bundle.main.bundleURL)
+ }
+
+ 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 {
+ // Use setTimeout to ensure map is ready
+ webView.evaluateJavaScript("""
+ setTimeout(function() {
+ if (window.focusGeohash) {
+ window.focusGeohash('\(selectedGeohash)');
+ }
+ }, 100);
+ """)
+ context.coordinator.lastGeohash = selectedGeohash
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+}
+#elseif os(macOS)
+struct GeohashWebView: NSViewRepresentable {
+ @Binding var selectedGeohash: String
+ let initialGeohash: String
+ let colorScheme: ColorScheme
+ @Binding var currentPrecision: Int
+ @Binding var isPinned: Bool
+ let onCoordinatorCreated: (Coordinator) -> Void
+
+ func makeNSView(context: Context) -> WKWebView {
+ let config = WKWebViewConfiguration()
+ let webView = WKWebView(frame: .zero, configuration: config)
+
+ // Store webView reference in coordinator
+ context.coordinator.webView = webView
+
+ // Notify parent of coordinator creation
+ onCoordinatorCreated(context.coordinator)
+
+ // Add JavaScript interface
+ let contentController = webView.configuration.userContentController
+ contentController.add(context.coordinator, name: "macOS")
+
+ webView.navigationDelegate = context.coordinator
+
+ // Load the HTML content from Resources folder
+ if let path = Bundle.main.path(forResource: "geohash-map", ofType: "html"),
+ let htmlString = try? String(contentsOfFile: path) {
+ let theme = colorScheme == .dark ? "dark" : "light"
+ let processedHTML = htmlString.replacingOccurrences(of: "{{THEME}}", with: theme)
+ webView.loadHTMLString(processedHTML, baseURL: Bundle.main.bundleURL)
+ }
+
+ return webView
+ }
+
+ func updateNSView(_ webView: WKWebView, context: Context) {
+ 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 {
+ // Use setTimeout to ensure map is ready
+ webView.evaluateJavaScript("""
+ setTimeout(function() {
+ if (window.focusGeohash) {
+ window.focusGeohash('\(selectedGeohash)');
+ }
+ }, 100);
+ """)
+ context.coordinator.lastGeohash = selectedGeohash
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+}
+#endif
+
+extension GeohashWebView {
+ class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
+ let parent: GeohashWebView
+ var webView: WKWebView?
+ var hasLoadedOnce = false
+ var lastGeohash: String = ""
+ var isInitializing = true
+
+ // Map state persistence
+ private let mapStateKey = "GeohashMapView.lastMapState"
+
+ init(_ parent: GeohashWebView) {
+ self.parent = parent
+ super.init()
+ }
+
+ private func saveMapState(lat: Double, lng: Double, zoom: Double, precision: Int?) {
+ var state: [String: Any] = [
+ "lat": lat,
+ "lng": lng,
+ "zoom": zoom
+ ]
+ if let precision = precision {
+ state["precision"] = precision
+ }
+ UserDefaults.standard.set(state, forKey: mapStateKey)
+ }
+
+ private func loadMapState() -> (lat: Double, lng: Double, zoom: Double, precision: Int?)? {
+ guard let state = UserDefaults.standard.dictionary(forKey: mapStateKey),
+ let lat = state["lat"] as? Double,
+ let lng = state["lng"] as? Double,
+ let zoom = state["zoom"] as? Double else {
+ return nil
+ }
+ let precision = state["precision"] as? Int
+ return (lat, lng, zoom, precision)
+ }
+
+ func focusOnCurrentGeohash() {
+ guard let webView = webView, !parent.selectedGeohash.isEmpty else {
+ return
+ }
+ webView.evaluateJavaScript("""
+ setTimeout(function() {
+ if (window.focusGeohash) {
+ window.focusGeohash('\(parent.selectedGeohash)');
+ }
+ }, 100);
+ """)
+ }
+
+ func setPrecision(_ precision: Int) {
+ guard let webView = webView else { return }
+ webView.evaluateJavaScript("""
+ setTimeout(function() {
+ if (window.setPrecision) {
+ window.setPrecision(\(precision));
+ }
+ }, 100);
+ """)
+ }
+
+ func restoreMapState(lat: Double, lng: Double, zoom: Double, precision: Int?) {
+ guard let webView = webView else { return }
+ let precisionValue = precision != nil ? "\(precision!)" : "null"
+ webView.evaluateJavaScript("""
+ setTimeout(function() {
+ if (window.restoreMapState) {
+ window.restoreMapState(\(lat), \(lng), \(zoom), \(precisionValue));
+ }
+ }, 100);
+ """)
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ var geohashToFocus: String? = nil
+
+ if !parent.initialGeohash.isEmpty {
+ geohashToFocus = parent.initialGeohash
+ // Update selectedGeohash to match the initial geohash
+ DispatchQueue.main.async {
+ self.parent.selectedGeohash = self.parent.initialGeohash
+ }
+ }
+ else if !parent.selectedGeohash.isEmpty {
+ geohashToFocus = parent.selectedGeohash
+ }
+ else if !hasLoadedOnce {
+ if let state = loadMapState() {
+ restoreMapState(lat: state.lat, lng: state.lng, zoom: state.zoom, precision: state.precision)
+ hasLoadedOnce = true
+
+ let theme = parent.colorScheme == .dark ? "dark" : "light"
+ webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')")
+
+ isInitializing = false
+ return
+ }
+ else if let currentChannel = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .city || $0.level == .neighborhood }) {
+ geohashToFocus = currentChannel.geohash
+ }
+ }
+
+ hasLoadedOnce = true
+
+ if let geohash = geohashToFocus {
+ lastGeohash = geohash
+ webView.evaluateJavaScript("window.focusGeohash && window.focusGeohash('\(geohash)')")
+ }
+
+ let theme = parent.colorScheme == .dark ? "dark" : "light"
+ webView.evaluateJavaScript("window.setMapTheme && window.setMapTheme('\(theme)')")
+
+ isInitializing = false
+ }
+
+ 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
+ }
+ } else if let dict = message.body as? [String: Any],
+ let type = dict["type"] as? String {
+ if type == "precision", let precision = dict["value"] as? Int {
+ DispatchQueue.main.async {
+ if !self.parent.isPinned {
+ self.parent.currentPrecision = precision
+ }
+ }
+ } else if type == "geohash", let geohash = dict["value"] as? String {
+ // Only update selectedGeohash if this isn't just an automatic center change
+ // during focusing on a specific geohash or during initialization
+ if geohash != self.lastGeohash && !self.isInitializing {
+ DispatchQueue.main.async {
+ self.parent.selectedGeohash = geohash
+ self.lastGeohash = geohash
+ }
+ }
+ } else if type == "saveMapState",
+ let stateData = dict["value"] as? [String: Any],
+ let lat = stateData["lat"] as? Double,
+ let lng = stateData["lng"] as? Double,
+ let zoom = stateData["zoom"] as? Double {
+ let precision = stateData["precision"] as? Int
+ DispatchQueue.main.async {
+ self.saveMapState(lat: lat, lng: lng, zoom: zoom, precision: precision)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ GeohashMapView(selectedGeohash: .constant(""), initialGeohash: "")
+}
diff --git a/bitchat/Views/GeohashPickerSheet.swift b/bitchat/Views/GeohashPickerSheet.swift
new file mode 100644
index 000000000..a7e6c1692
--- /dev/null
+++ b/bitchat/Views/GeohashPickerSheet.swift
@@ -0,0 +1,169 @@
+import SwiftUI
+
+struct GeohashPickerSheet: View {
+ @Binding var isPresented: Bool
+ let onGeohashSelected: (String) -> Void
+ let initialGeohash: String
+ @State private var selectedGeohash: String = ""
+ @State private var currentPrecision: Int? = 6
+ @Environment(\.colorScheme) var colorScheme
+
+ init(isPresented: Binding, initialGeohash: String = "", onGeohashSelected: @escaping (String) -> Void) {
+ self._isPresented = isPresented
+ self.initialGeohash = initialGeohash
+ self.onGeohashSelected = onGeohashSelected
+ self._selectedGeohash = State(initialValue: initialGeohash)
+ }
+
+ private var backgroundColor: Color {
+ colorScheme == .dark ? Color.black : Color.white
+ }
+
+ private enum Strings {
+ static let instruction = String(localized: "geohash_picker.instruction", comment: "Instruction text for geohash map picker")
+ static let selectButton = String(localized: "geohash_picker.select_button", comment: "Select button text in geohash picker")
+ }
+
+ private var textColor: Color {
+ colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
+ }
+
+ var body: some View {
+ ZStack {
+ // Full-screen map
+ GeohashMapView(
+ selectedGeohash: $selectedGeohash,
+ initialGeohash: initialGeohash,
+ showFloatingControls: false,
+ precision: $currentPrecision
+ )
+ .ignoresSafeArea()
+
+ // Top instruction banner
+ VStack {
+ HStack {
+ Text(Strings.instruction)
+ .font(.bitchatSystem(size: 14, design: .monospaced))
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 24)
+ .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95))
+ .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 2)
+ )
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 20) // Smaller top padding and more on top
+ Spacer()
+ }
+
+ // Current geohash display
+ VStack {
+ Spacer()
+ HStack {
+ Spacer()
+ Text("#\(selectedGeohash.isEmpty ? "" : selectedGeohash)")
+ .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 14)
+ .background(
+ RoundedRectangle(cornerRadius: 22)
+ .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95))
+ .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
+ )
+ Spacer()
+ }
+ .padding(.bottom, 120) // Position geohash display a bit down
+ }
+
+ // Bottom controls bar
+ VStack {
+ Spacer()
+ HStack(spacing: 12) {
+ // Minus button
+ Button(action: {
+ if let precision = currentPrecision, precision > 1 {
+ currentPrecision = precision - 1
+ }
+ }) {
+ Image(systemName: "minus")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor((currentPrecision ?? 6) <= 1 ? Color.secondary : textColor)
+ .frame(width: 60, height: 50)
+ .background(
+ RoundedRectangle(cornerRadius: 25)
+ .fill(textColor.opacity(0.15))
+ .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
+ )
+ }
+ .disabled((currentPrecision ?? 6) <= 1)
+
+ // Plus button
+ Button(action: {
+ if let precision = currentPrecision, precision < 12 {
+ currentPrecision = precision + 1
+ }
+ }) {
+ Image(systemName: "plus")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor((currentPrecision ?? 6) >= 12 ? Color.secondary : textColor)
+ .frame(width: 60, height: 50)
+ .background(
+ RoundedRectangle(cornerRadius: 25)
+ .fill(textColor.opacity(0.15))
+ .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
+ )
+ }
+ .disabled((currentPrecision ?? 6) >= 12)
+
+ // Select button
+ Button(action: {
+ if !selectedGeohash.isEmpty {
+ onGeohashSelected(selectedGeohash)
+ }
+ }) {
+ HStack(spacing: 8) {
+ Image(systemName: "checkmark")
+ .font(.system(size: 16, weight: .semibold))
+ Text(Strings.selectButton)
+ .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
+ }
+ .foregroundColor(selectedGeohash.isEmpty ? Color.secondary : Color.secondary)
+ .frame(minWidth: 100, minHeight: 50)
+ .background(
+ RoundedRectangle(cornerRadius: 25)
+ .fill(Color.secondary.opacity(0.15))
+ .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
+ )
+ }
+ .disabled(selectedGeohash.isEmpty)
+ .opacity(selectedGeohash.isEmpty ? 0.6 : 1.0)
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 40) // Move buttons more up in the screen
+ }
+ }
+ .background(backgroundColor)
+ #if os(macOS)
+ .frame(minWidth: 800, minHeight: 600)
+ #endif
+ .onAppear {
+ // Always sync selectedGeohash with initialGeohash when view appears
+ // This ensures we restore the last selected geohash from LocationChannelsSheet
+ if !initialGeohash.isEmpty {
+ selectedGeohash = initialGeohash
+ currentPrecision = initialGeohash.count
+ }
+ }
+ }
+}
+
+#Preview {
+ GeohashPickerSheet(
+ isPresented: .constant(true),
+ initialGeohash: "",
+ onGeohashSelected: { _ in }
+ )
+}
diff --git a/bitchat/Views/GeohashPickerWindow.swift b/bitchat/Views/GeohashPickerWindow.swift
new file mode 100644
index 000000000..716a4c738
--- /dev/null
+++ b/bitchat/Views/GeohashPickerWindow.swift
@@ -0,0 +1,204 @@
+#if os(macOS)
+import SwiftUI
+import AppKit
+
+private var geohashWindowController: GeohashPickerWindowController?
+
+func openGeohashPickerWindow(initialGeohash: String, onSelection: @escaping (String) -> Void) {
+ geohashWindowController = GeohashPickerWindowController(initialGeohash: initialGeohash, onSelection: onSelection)
+ geohashWindowController?.showWindow(nil)
+ geohashWindowController?.window?.makeKeyAndOrderFront(nil)
+}
+
+class GeohashPickerWindowController: NSWindowController {
+ let onSelection: (String) -> Void
+ let initialGeohash: String
+
+ init(initialGeohash: String, onSelection: @escaping (String) -> Void) {
+ self.initialGeohash = initialGeohash
+ self.onSelection = onSelection
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+
+ super.init(window: window)
+
+ window.center()
+ window.setFrameAutosaveName("GeohashPickerWindow")
+
+ // Create the SwiftUI view
+ let contentView = GeohashPickerWindowView(
+ initialGeohash: initialGeohash,
+ onSelection: { [weak self] selectedGeohash in
+ self?.onSelection(selectedGeohash)
+ self?.window?.close()
+ geohashWindowController = nil
+ }
+ )
+
+ window.contentView = NSHostingView(rootView: contentView)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+struct GeohashPickerWindowView: View {
+ let initialGeohash: String
+ let onSelection: (String) -> Void
+ @State private var selectedGeohash: String = ""
+ @State private var currentPrecision: Int? = 6
+ @Environment(\.colorScheme) var colorScheme
+
+ init(initialGeohash: String, onSelection: @escaping (String) -> Void) {
+ self.initialGeohash = initialGeohash
+ self.onSelection = onSelection
+ self._selectedGeohash = State(initialValue: initialGeohash)
+ }
+
+ private enum Strings {
+ static let instruction = String(localized:
+ "geohash_picker.instruction",
+ comment: "Instruction text for geohash map picker"
+ )
+
+ static let selectButton = String(localized:
+ "geohash_picker.select_button",
+ comment: "Select button text in geohash picker"
+ )
+ }
+
+ private var textColor: Color {
+ colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
+ }
+
+ var body: some View {
+ ZStack {
+ // Full-screen map
+ GeohashMapView(
+ selectedGeohash: $selectedGeohash,
+ initialGeohash: initialGeohash,
+ showFloatingControls: false,
+ precision: $currentPrecision
+ )
+
+ // Top instruction banner
+ VStack {
+ HStack {
+ Text(Strings.instruction)
+ .font(.bitchatSystem(size: 14, design: .monospaced))
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 24)
+ .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95))
+ .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 2)
+ )
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 20)
+ Spacer()
+ }
+
+ // Current geohash display
+ VStack {
+ Spacer()
+ HStack {
+ Spacer()
+ Text("#\(selectedGeohash.isEmpty ? "" : selectedGeohash)")
+ .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 14)
+ .background(
+ RoundedRectangle(cornerRadius: 22)
+ .fill(colorScheme == .dark ? Color.black.opacity(0.85) : Color.white.opacity(0.95))
+ .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
+ )
+ Spacer()
+ }
+ .padding(.bottom, 120)
+ }
+
+ // Bottom controls bar
+ VStack {
+ Spacer()
+ HStack(spacing: 12) {
+ // Minus button
+ Button(action: {
+ if let precision = currentPrecision, precision > 1 {
+ currentPrecision = precision - 1
+ }
+ }) {
+ Image(systemName: "minus")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor((currentPrecision ?? 6) <= 1 ? Color.secondary : textColor)
+ .frame(width: 60, height: 50)
+ .background(
+ RoundedRectangle(cornerRadius: 25)
+ .fill(textColor.opacity(0.15))
+ )
+ }
+ .disabled((currentPrecision ?? 6) <= 1)
+ .buttonStyle(.plain)
+
+ // Plus button
+ Button(action: {
+ if let precision = currentPrecision, precision < 12 {
+ currentPrecision = precision + 1
+ }
+ }) {
+ Image(systemName: "plus")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor((currentPrecision ?? 6) >= 12 ? Color.secondary : textColor)
+ .frame(width: 60, height: 50)
+ .background(
+ RoundedRectangle(cornerRadius: 25)
+ .fill(textColor.opacity(0.15))
+ )
+ }
+ .disabled((currentPrecision ?? 6) >= 12)
+ .buttonStyle(.plain)
+
+ // Select button
+ Button(action: {
+ if !selectedGeohash.isEmpty {
+ onSelection(selectedGeohash)
+ }
+ }) {
+ HStack(spacing: 8) {
+ Image(systemName: "checkmark")
+ .font(.system(size: 16, weight: .semibold))
+ Text(Strings.selectButton)
+ .font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
+ }
+ .foregroundColor(selectedGeohash.isEmpty ? Color.secondary : Color.secondary)
+ .frame(minWidth: 100, minHeight: 50)
+ .background(
+ RoundedRectangle(cornerRadius: 25)
+ .fill(Color.secondary.opacity(0.15))
+ )
+ }
+ .disabled(selectedGeohash.isEmpty)
+ .opacity(selectedGeohash.isEmpty ? 0.6 : 1.0)
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 40)
+ }
+ }
+ .onAppear {
+ if !initialGeohash.isEmpty {
+ selectedGeohash = initialGeohash
+ currentPrecision = initialGeohash.count
+ }
+ }
+ }
+}
+#endif
diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift
index 998e60d75..4497e4cbb 100644
--- a/bitchat/Views/LocationChannelsSheet.swift
+++ b/bitchat/Views/LocationChannelsSheet.swift
@@ -7,13 +7,14 @@ import AppKit
#endif
struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
+ @Binding var customGeohash: String
@ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var network = NetworkActivationService.shared
@EnvironmentObject var viewModel: ChatViewModel
@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 }
@@ -126,7 +127,7 @@ struct LocationChannelsSheet: View {
#endif
}
#if os(macOS)
- .frame(minWidth: 420, minHeight: 520)
+ .frame(minWidth: 420, minHeight: 680)
#endif
.background(backgroundColor)
.onAppear {
@@ -282,6 +283,42 @@ struct LocationChannelsSheet: View {
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)
+ #if os(iOS)
+ .sheet(isPresented: $showGeohashMap) {
+ let processedGeohash = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
+ return GeohashPickerSheet(
+ isPresented: $showGeohashMap,
+ initialGeohash: processedGeohash
+ ) { selectedGeohash in
+ customGeohash = selectedGeohash
+ showGeohashMap = false
+ }
+ .environmentObject(viewModel)
+ .onAppear {
+ }
+ .presentationDetents([.large])
+ .presentationDragIndicator(.visible)
+ }
+ #else
+ .onChange(of: showGeohashMap) { newValue in
+ if newValue {
+ let processedGeohash = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
+ openGeohashPickerWindow(initialGeohash: processedGeohash, onSelection: { selectedGeohash in
+ customGeohash = selectedGeohash
+ })
+ showGeohashMap = false
+ }
+ }
+ #endif
let normalized = customGeohash
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
@@ -490,6 +527,8 @@ extension LocationChannelsSheet {
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
+ .listRowSeparator(.hidden)
+ .listRowBackground(Color.clear)
}
private var standardGreen: Color {
@@ -616,3 +655,4 @@ private func openSystemLocationSettings() {
}
#endif
}
+