From 56038ff72b56d7300d7b70a5320d3a4f3da131ea Mon Sep 17 00:00:00 2001 From: Atrid Ahmetaj Date: Wed, 24 Sep 2025 12:49:52 +0200 Subject: [PATCH 1/3] added geohash map viewer --- bitchat/Views/GeohashMapView.swift | 410 ++++++++++++++++++++++ bitchat/Views/GeohashPickerSheet.swift | 73 ++++ bitchat/Views/LocationChannelsSheet.swift | 29 +- 3 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 bitchat/Views/GeohashMapView.swift create mode 100644 bitchat/Views/GeohashPickerSheet.swift 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 ac6e39d1d..a7976a493 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 } @@ -154,9 +155,12 @@ struct LocationChannelsSheet: View { .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) } - // Custom geohash teleport + // Custom geohash entry with map picker and teleport VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 2) { + 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) @@ -180,8 +184,25 @@ struct LocationChannelsSheet: View { customGeohash = filtered } } - let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") - let isValid = validateGeohash(normalized) + + // 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 Button(action: { let gh = normalized guard isValid else { customError = "invalid geohash"; return } From 8f517187672a2b81dfa6dcf2f841631279d78fab Mon Sep 17 00:00:00 2001 From: Atrid Ahmetaj Date: Wed, 24 Sep 2025 14:53:13 +0200 Subject: [PATCH 2/3] updated location channels sheet --- bitchat/Views/LocationChannelsSheet.swift | 317 +++++++++------------- 1 file changed, 133 insertions(+), 184 deletions(-) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 1cf88f1dc..a7976a493 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -108,66 +108,52 @@ struct LocationChannelsSheet: View { } private var channelList: some View { - ScrollView { - LazyVStack(spacing: 0) { - channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { - manager.select(ChannelID.mesh) - isPresented = false - } - .padding(.vertical, 6) - - let nearby = manager.availableChannels.filter { $0.level != .building } - if !nearby.isEmpty { - ForEach(nearby) { channel in - sectionDivider - let coverage = coverageString(forPrecision: channel.geohash.count) - let nameBase = locationName(for: channel.level) - let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } - let subtitlePrefix = "#\(channel.geohash) • \(coverage)" - let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 - channelRow( - title: geohashTitleWithCount(for: channel), - subtitlePrefix: subtitlePrefix, - subtitleName: namePart, - isSelected: isSelected(channel), - titleBold: highlight, - trailingAccessory: { - Button(action: { bookmarks.toggle(channel.geohash) }) { - Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") - .font(.bitchatSystem(size: 14)) - } - .buttonStyle(.plain) - .padding(.leading, 8) + List { + // Mesh option first (no bookmark) + channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { + manager.select(ChannelID.mesh) + isPresented = false + } + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) + + // Nearby options + if !manager.availableChannels.isEmpty { + ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in + let coverage = coverageString(forPrecision: channel.geohash.count) + let nameBase = locationName(for: channel.level) + let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } + let subtitlePrefix = "#\(channel.geohash) • \(coverage)" + let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 + channelRow( + title: geohashTitleWithCount(for: channel), + subtitlePrefix: subtitlePrefix, + subtitleName: namePart, + isSelected: isSelected(channel), + titleBold: highlight, + trailingAccessory: { + Button(action: { bookmarks.toggle(channel.geohash) }) { + Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") + .font(.bitchatSystem(size: 14)) } - ) { - manager.markTeleported(for: channel.geohash, false) - manager.select(ChannelID.location(channel)) - isPresented = false + .buttonStyle(.plain) + .padding(.leading, 8) } - .padding(.vertical, 6) - } - } else { - sectionDivider - HStack(spacing: 8) { - ProgressView() - Text("finding nearby channels…") - .font(.bitchatSystem(size: 12, design: .monospaced)) + ) { + // Selecting a suggested nearby channel is not a teleport. Persist this. + manager.markTeleported(for: channel.geohash, false) + manager.select(ChannelID.location(channel)) + isPresented = false } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 10) + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) } - - sectionDivider - customTeleportSection - .padding(.vertical, 8) - - let bookmarkedList = bookmarks.bookmarks - if !bookmarkedList.isEmpty { - sectionDivider - bookmarkedSection(bookmarkedList) - .padding(.vertical, 8) + } else { + HStack { + ProgressView() + Text("finding nearby channels…") + .font(.bitchatSystem(size: 12, design: .monospaced)) } - + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) + } // Custom geohash entry with map picker and teleport VStack(alignment: .leading, spacing: 6) { @@ -217,152 +203,113 @@ struct LocationChannelsSheet: View { } // Teleport button - if manager.permissionState == LocationChannelManager.PermissionState.authorized { - sectionDivider - torToggleSection - .padding(.top, 12) Button(action: { - openSystemLocationSettings() + let gh = normalized + guard isValid else { customError = "invalid geohash"; return } + let level = levelForLength(gh.count) + let ch = GeohashChannel(level: level, geohash: gh) + // Mark this selection as a manual teleport + manager.markTeleported(for: ch.geohash, true) + manager.select(ChannelID.location(ch)) + isPresented = false }) { - Text("remove location access") - .font(.bitchatSystem(size: 12, design: .monospaced)) - .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.red.opacity(0.08)) - .cornerRadius(6) + HStack(spacing: 6) { + Text("teleport") + .font(.bitchatSystem(size: 14, design: .monospaced)) + Image(systemName: "face.dashed") + .font(.bitchatSystem(size: 14)) + } } .buttonStyle(.plain) - .padding(.vertical, 8) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) - .background(backgroundColor) - } - .background(backgroundColor) - } - - private var sectionDivider: some View { - Rectangle() - .fill(dividerColor) - .frame(height: 1) - } - - private var dividerColor: Color { - colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) - } - - private var customTeleportSection: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 2) { - 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 - 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 - } - } - let normalized = customGeohash - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "#", with: "") - let isValid = validateGeohash(normalized) - Button(action: { - let gh = normalized - guard isValid else { customError = "invalid geohash"; return } - let level = levelForLength(gh.count) - let ch = GeohashChannel(level: level, geohash: gh) - manager.markTeleported(for: ch.geohash, true) - manager.select(ChannelID.location(ch)) - isPresented = false - }) { - HStack(spacing: 6) { - Text("teleport") - .font(.bitchatSystem(size: 14, design: .monospaced)) - Image(systemName: "face.dashed") - .font(.bitchatSystem(size: 14)) - } + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(6) + .opacity(isValid ? 1.0 : 0.4) + .disabled(!isValid) + } + if let err = customError { + Text(err) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.red) } - .buttonStyle(.plain) - .font(.bitchatSystem(size: 14, design: .monospaced)) - .padding(.vertical, 6) - .padding(.horizontal, 10) - .background(Color.secondary.opacity(0.12)) - .cornerRadius(6) - .opacity(isValid ? 1.0 : 0.4) - .disabled(!isValid) - } - if let err = customError { - Text(err) - .font(.bitchatSystem(size: 12, design: .monospaced)) - .foregroundColor(.red) } - } - } + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) - private func bookmarkedSection(_ entries: [String]) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("bookmarked") - .font(.bitchatSystem(size: 12, design: .monospaced)) - .foregroundColor(.secondary) - LazyVStack(spacing: 0) { - ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in - let level = levelForLength(gh.count) - let channel = GeohashChannel(level: level, geohash: gh) - let coverage = coverageString(forPrecision: gh.count) - let subtitle = "#\(gh) • \(coverage)" - let name = bookmarks.bookmarkNames[gh] - channelRow( - title: geohashHashTitleWithCount(gh), - subtitlePrefix: subtitle, - subtitleName: name.map { formattedNamePrefix(for: level) + $0 }, - isSelected: isSelected(channel), - trailingAccessory: { - Button(action: { bookmarks.toggle(gh) }) { - Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") - .font(.bitchatSystem(size: 14)) + // Bookmarked geohashes + if !bookmarks.bookmarks.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("bookmarked") + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + VStack(spacing: 6) { + ForEach(bookmarks.bookmarks, id: \.self) { gh in + let level = levelForLength(gh.count) + let channel = GeohashChannel(level: level, geohash: gh) + let coverage = coverageString(forPrecision: gh.count) + let subtitle = "#\(gh) • \(coverage)" + let name = bookmarks.bookmarkNames[gh] + channelRow( + title: geohashHashTitleWithCount(gh), + subtitlePrefix: subtitle, + subtitleName: name.map { formattedNamePrefix(for: level) + $0 }, + isSelected: isSelected(channel), + trailingAccessory: { + Button(action: { bookmarks.toggle(gh) }) { + Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") + .font(.bitchatSystem(size: 14)) + } + .buttonStyle(.plain) + .padding(.leading, 8) + } + ) { + // For bookmarked selection, mark teleported based on regional membership + let inRegional = manager.availableChannels.contains { $0.geohash == gh } + if !inRegional && !manager.availableChannels.isEmpty { + manager.markTeleported(for: gh, true) + } else { + manager.markTeleported(for: gh, false) + } + manager.select(ChannelID.location(channel)) + isPresented = false } - .buttonStyle(.plain) - .padding(.leading, 8) - } - ) { - let inRegional = manager.availableChannels.contains { $0.geohash == gh } - if !inRegional && !manager.availableChannels.isEmpty { - manager.markTeleported(for: gh, true) - } else { - manager.markTeleported(for: gh, false) + .onAppear { bookmarks.resolveNameIfNeeded(for: gh) } } - manager.select(ChannelID.location(channel)) - isPresented = false } - .padding(.vertical, 6) - .onAppear { bookmarks.resolveNameIfNeeded(for: gh) } + .padding(12) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(8) + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) + } - if index < entries.count - 1 { - sectionDivider - } + // Footer action inside the list + if manager.permissionState == LocationChannelManager.PermissionState.authorized { + torToggleSection + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0)) + Button(action: { + openSystemLocationSettings() + }) { + Text("remove location access") + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.red.opacity(0.08)) + .cornerRadius(6) } + .buttonStyle(.plain) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0)) } } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(backgroundColor) } - private func isSelected(_ channel: GeohashChannel) -> Bool { if case .location(let ch) = manager.selectedChannel { return ch == channel @@ -511,6 +458,8 @@ extension LocationChannelsSheet { .padding(12) .background(Color.secondary.opacity(0.12)) .cornerRadius(8) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } private var standardGreen: Color { From 9288f7e0cc83cd659bf2ce313841a38fb2df39d7 Mon Sep 17 00:00:00 2001 From: Atrid Ahmetaj Date: Fri, 26 Sep 2025 14:11:43 +0200 Subject: [PATCH 3/3] tap to zoom and select on geohash map viewer and control buttons on the map --- .../Base.lproj/Localizable.strings | 2 + .../Localization/ar.lproj/Localizable.strings | 2 + .../Localization/de.lproj/Localizable.strings | 2 + .../Localization/es.lproj/Localizable.strings | 2 + .../Localization/fr.lproj/Localizable.strings | 2 + .../Localization/he.lproj/Localizable.strings | 2 + .../Localization/id.lproj/Localizable.strings | 2 + .../Localization/it.lproj/Localizable.strings | 2 + .../Localization/ja.lproj/Localizable.strings | 2 + .../Localization/ne.lproj/Localizable.strings | 2 + .../pt-BR.lproj/Localizable.strings | 2 + .../Localization/ru.lproj/Localizable.strings | 2 + .../Localization/uk.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + bitchat/Resources/geohash-map.html | 397 ++++++++++ bitchat/Views/ContentView.swift | 8 +- bitchat/Views/GeohashMapView.swift | 703 ++++++++++-------- bitchat/Views/GeohashPickerSheet.swift | 173 ++++- bitchat/Views/GeohashPickerWindow.swift | 204 +++++ bitchat/Views/LocationChannelsSheet.swift | 41 +- 20 files changed, 1209 insertions(+), 345 deletions(-) create mode 100644 bitchat/Resources/geohash-map.html create mode 100644 bitchat/Views/GeohashPickerWindow.swift diff --git a/bitchat/Localization/Base.lproj/Localizable.strings b/bitchat/Localization/Base.lproj/Localizable.strings index 99bf6f5f7..4e355b94b 100644 --- a/bitchat/Localization/Base.lproj/Localizable.strings +++ b/bitchat/Localization/Base.lproj/Localizable.strings @@ -130,6 +130,8 @@ "geohash_people.none_nearby" = "nobody around..."; "geohash_people.tooltip.blocked" = "blocked in geohash"; "geohash_people.you_suffix" = " (you)"; +"geohash_picker.instruction" = "pan and zoom to select a geohash"; +"geohash_picker.select_button" = "select"; "location_channels.action.open_settings" = "open settings"; "location_channels.action.remove_access" = "remove location access"; "location_channels.action.request_permissions" = "get location and my geohashes"; diff --git a/bitchat/Localization/ar.lproj/Localizable.strings b/bitchat/Localization/ar.lproj/Localizable.strings index afe0eb653..9ec1f76c7 100644 --- a/bitchat/Localization/ar.lproj/Localizable.strings +++ b/bitchat/Localization/ar.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "لا أحد قريب..."; "geohash_people.tooltip.blocked" = "محظور في geohash"; "geohash_people.you_suffix" = " (أنت)"; +"geohash_picker.instruction" = "اسحب وقرب لاختيار geohash"; +"geohash_picker.select_button" = "اختيار"; "location_channels.action.open_settings" = "فتح الإعدادات"; "location_channels.action.remove_access" = "إزالة صلاحية الموقع"; "location_channels.action.request_permissions" = "جلب موقعي و geohash"; diff --git a/bitchat/Localization/de.lproj/Localizable.strings b/bitchat/Localization/de.lproj/Localizable.strings index 69ee58bb8..5496434a5 100644 --- a/bitchat/Localization/de.lproj/Localizable.strings +++ b/bitchat/Localization/de.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "niemand in der nähe..."; "geohash_people.tooltip.blocked" = "in geohash blockiert"; "geohash_people.you_suffix" = " (du)"; +"geohash_picker.instruction" = "verschieben und zoomen um einen geohash auszuwählen"; +"geohash_picker.select_button" = "auswählen"; "location_channels.action.open_settings" = "einstellungen öffnen"; "location_channels.action.remove_access" = "standortzugriff entfernen"; "location_channels.action.request_permissions" = "standort und geohash abrufen"; diff --git a/bitchat/Localization/es.lproj/Localizable.strings b/bitchat/Localization/es.lproj/Localizable.strings index 1516c1f85..234c8b2c3 100644 --- a/bitchat/Localization/es.lproj/Localizable.strings +++ b/bitchat/Localization/es.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "nadie cerca..."; "geohash_people.tooltip.blocked" = "bloqueado en geohash"; "geohash_people.you_suffix" = " (tú)"; +"geohash_picker.instruction" = "arrastra y acerca para seleccionar un geohash"; +"geohash_picker.select_button" = "seleccionar"; "location_channels.action.open_settings" = "abrir ajustes"; "location_channels.action.remove_access" = "eliminar acceso a la ubicación"; "location_channels.action.request_permissions" = "obtener mi ubicación y mis geohashes"; diff --git a/bitchat/Localization/fr.lproj/Localizable.strings b/bitchat/Localization/fr.lproj/Localizable.strings index c41433916..648418d96 100644 --- a/bitchat/Localization/fr.lproj/Localizable.strings +++ b/bitchat/Localization/fr.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "personne à proximité..."; "geohash_people.tooltip.blocked" = "bloqué dans geohash"; "geohash_people.you_suffix" = " (toi)"; +"geohash_picker.instruction" = "faites glisser et zoomez pour sélectionner un geohash"; +"geohash_picker.select_button" = "sélectionner"; "location_channels.action.open_settings" = "ouvrir réglages"; "location_channels.action.remove_access" = "retirer l'accès localisation"; "location_channels.action.request_permissions" = "obtenir ma localisation et mes geohash"; diff --git a/bitchat/Localization/he.lproj/Localizable.strings b/bitchat/Localization/he.lproj/Localizable.strings index 3bac63ccf..56e2bcc06 100644 --- a/bitchat/Localization/he.lproj/Localizable.strings +++ b/bitchat/Localization/he.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "אין אף אחד בסביבה..."; "geohash_people.tooltip.blocked" = "חסום ב-geohash"; "geohash_people.you_suffix" = " (אתה)"; +"geohash_picker.instruction" = "גרור וזום כדי לבחור geohash"; +"geohash_picker.select_button" = "בחר"; "location_channels.action.open_settings" = "פתח הגדרות"; "location_channels.action.remove_access" = "הסר גישת מיקום"; "location_channels.action.request_permissions" = "קבל את המיקום וה-geohash שלי"; diff --git a/bitchat/Localization/id.lproj/Localizable.strings b/bitchat/Localization/id.lproj/Localizable.strings index d131516c3..3798273ef 100644 --- a/bitchat/Localization/id.lproj/Localizable.strings +++ b/bitchat/Localization/id.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "tidak ada siapa pun..."; "geohash_people.tooltip.blocked" = "diblokir di geohash"; "geohash_people.you_suffix" = " (kamu)"; +"geohash_picker.instruction" = "seret dan perbesar untuk pilih geohash"; +"geohash_picker.select_button" = "pilih"; "location_channels.action.open_settings" = "buka pengaturan"; "location_channels.action.remove_access" = "cabut akses lokasi"; "location_channels.action.request_permissions" = "ambil lokasiku dan geohash"; diff --git a/bitchat/Localization/it.lproj/Localizable.strings b/bitchat/Localization/it.lproj/Localizable.strings index c04ae7298..57c235750 100644 --- a/bitchat/Localization/it.lproj/Localizable.strings +++ b/bitchat/Localization/it.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "nessuno nei dintorni..."; "geohash_people.tooltip.blocked" = "bloccato su geohash"; "geohash_people.you_suffix" = " (tu)"; +"geohash_picker.instruction" = "trascina e zooma per selezionare un geohash"; +"geohash_picker.select_button" = "seleziona"; "location_channels.action.open_settings" = "apri impostazioni"; "location_channels.action.remove_access" = "revoca accesso alla posizione"; "location_channels.action.request_permissions" = "ottieni la mia posizione e i geohash"; diff --git a/bitchat/Localization/ja.lproj/Localizable.strings b/bitchat/Localization/ja.lproj/Localizable.strings index d7dd8275e..0c56cd15e 100644 --- a/bitchat/Localization/ja.lproj/Localizable.strings +++ b/bitchat/Localization/ja.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "近くに誰もいません..."; "geohash_people.tooltip.blocked" = "geohashでブロック中"; "geohash_people.you_suffix" = " (あなた)"; +"geohash_picker.instruction" = "ドラッグとズームでgeohashを選択"; +"geohash_picker.select_button" = "選択"; "location_channels.action.open_settings" = "設定を開く"; "location_channels.action.remove_access" = "位置アクセスを解除"; "location_channels.action.request_permissions" = "位置情報とgeohashを取得"; diff --git a/bitchat/Localization/ne.lproj/Localizable.strings b/bitchat/Localization/ne.lproj/Localizable.strings index 0ee5a1d21..4c415390a 100644 --- a/bitchat/Localization/ne.lproj/Localizable.strings +++ b/bitchat/Localization/ne.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "वरिपरि कोही छैन..."; "geohash_people.tooltip.blocked" = "geohash मा ब्लक"; "geohash_people.you_suffix" = " (तिमी)"; +"geohash_picker.instruction" = "geohash छान्न तान्नु र जुम गर्नु"; +"geohash_picker.select_button" = "छान्नु"; "location_channels.action.open_settings" = "सेटिङ खोल"; "location_channels.action.remove_access" = "स्थान पहुँच हटाउ"; "location_channels.action.request_permissions" = "मेरो स्थान र geohash प्राप्त गर"; diff --git a/bitchat/Localization/pt-BR.lproj/Localizable.strings b/bitchat/Localization/pt-BR.lproj/Localizable.strings index 45ddacf3f..34378e57a 100644 --- a/bitchat/Localization/pt-BR.lproj/Localizable.strings +++ b/bitchat/Localization/pt-BR.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "ninguém por perto..."; "geohash_people.tooltip.blocked" = "bloqueado em geohash"; "geohash_people.you_suffix" = " (você)"; +"geohash_picker.instruction" = "arraste e amplie para selecionar um geohash"; +"geohash_picker.select_button" = "selecionar"; "location_channels.action.open_settings" = "abrir ajustes"; "location_channels.action.remove_access" = "remover acesso à localização"; "location_channels.action.request_permissions" = "obter localização e meus geohashes"; diff --git a/bitchat/Localization/ru.lproj/Localizable.strings b/bitchat/Localization/ru.lproj/Localizable.strings index 67edc57c3..4592e013f 100644 --- a/bitchat/Localization/ru.lproj/Localizable.strings +++ b/bitchat/Localization/ru.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "никого рядом..."; "geohash_people.tooltip.blocked" = "заблокирован в geohash"; "geohash_people.you_suffix" = " (ты)"; +"geohash_picker.instruction" = "перетаскивайте и масштабируйте для выбора geohash"; +"geohash_picker.select_button" = "выбрать"; "location_channels.action.open_settings" = "открыть настройки"; "location_channels.action.remove_access" = "отключить доступ к локации"; "location_channels.action.request_permissions" = "получить мою локацию и geohash"; diff --git a/bitchat/Localization/uk.lproj/Localizable.strings b/bitchat/Localization/uk.lproj/Localizable.strings index 8ef9e3e7c..ad6255aa2 100644 --- a/bitchat/Localization/uk.lproj/Localizable.strings +++ b/bitchat/Localization/uk.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "поруч нікого..."; "geohash_people.tooltip.blocked" = "заблоковано в geohash"; "geohash_people.you_suffix" = " (ти)"; +"geohash_picker.instruction" = "перетягуйте та масштабуйте для вибору geohash"; +"geohash_picker.select_button" = "вибрати"; "location_channels.action.open_settings" = "відкрити налаштування"; "location_channels.action.remove_access" = "відключити доступ до локації"; "location_channels.action.request_permissions" = "отримати мою локацію та geohash"; diff --git a/bitchat/Localization/zh-Hans.lproj/Localizable.strings b/bitchat/Localization/zh-Hans.lproj/Localizable.strings index 13d9ffcee..d9b2da570 100644 --- a/bitchat/Localization/zh-Hans.lproj/Localizable.strings +++ b/bitchat/Localization/zh-Hans.lproj/Localizable.strings @@ -128,6 +128,8 @@ "geohash_people.none_nearby" = "附近没人..."; "geohash_people.tooltip.blocked" = "在 geohash 中已屏蔽"; "geohash_people.you_suffix" = " (你)"; +"geohash_picker.instruction" = "拖拽和缩放来选择 geohash"; +"geohash_picker.select_button" = "选择"; "location_channels.action.open_settings" = "打开设置"; "location_channels.action.remove_access" = "移除位置访问"; "location_channels.action.request_permissions" = "获取位置和我的 geohash"; 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 6aa3c71b8..449b0f1b1 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -52,6 +52,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 @ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44 @@ -1303,8 +1304,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) { diff --git a/bitchat/Views/GeohashMapView.swift b/bitchat/Views/GeohashMapView.swift index 47dd2c02f..264ebf8b3 100644 --- a/bitchat/Views/GeohashMapView.swift +++ b/bitchat/Views/GeohashMapView.swift @@ -7,65 +7,274 @@ import UIKit import AppKit #endif -// MARK: - Geohash Picker using Leaflet (like Android) +// 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 { - 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 + 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("pan and zoom to select") + .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) - // Enable JavaScript and disable scrolling + // 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 - webView.scrollView.isScrollEnabled = false + + // 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") - // Load the HTML content - let htmlString = geohashPickerHTML(theme: colorScheme == .dark ? "dark" : "light") - webView.loadHTMLString(htmlString, baseURL: nil) + // 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 { - webView.evaluateJavaScript("window.focusGeohash && window.focusGeohash('\(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) } @@ -73,35 +282,57 @@ struct GeohashWebView: UIViewRepresentable { #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") - // Load the HTML content - let htmlString = geohashPickerHTML(theme: colorScheme == .dark ? "dark" : "light") - webView.loadHTMLString(htmlString, baseURL: nil) + 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) { - // 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)')") + // 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) } @@ -109,14 +340,122 @@ struct GeohashWebView: NSViewRepresentable { #endif extension GeohashWebView { - class Coordinator: NSObject, WKScriptMessageHandler { + 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 { @@ -124,287 +463,39 @@ extension GeohashWebView { 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) + } + } } } } } } -// MARK: - Leaflet HTML (same as Android) - -private func geohashPickerHTML(theme: String) -> String { - return """ - - - - - - - - - -
- - - - - -""" -} - #Preview { - GeohashMapView(selectedGeohash: .constant("9q8yy")) + GeohashMapView(selectedGeohash: .constant(""), initialGeohash: "") } diff --git a/bitchat/Views/GeohashPickerSheet.swift b/bitchat/Views/GeohashPickerSheet.swift index 6fa34455e..bc1ed0eac 100644 --- a/bitchat/Views/GeohashPickerSheet.swift +++ b/bitchat/Views/GeohashPickerSheet.swift @@ -3,71 +3,174 @@ 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 = L10n.string( + "geohash_picker.instruction", + comment: "Instruction text for geohash map picker" + ) + + static let selectButton = L10n.string( + "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 { - NavigationView { - VStack(spacing: 0) { - // Selected geohash display + 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 { - Text(selectedGeohash.isEmpty ? "pan and zoom to select" : "#\(selectedGeohash)") - .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - .frame(maxWidth: .infinity, alignment: .leading) + 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) - Button("select") { + // 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) + ) } - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(selectedGeohash.isEmpty ? Color.secondary : textColor) .disabled(selectedGeohash.isEmpty) + .opacity(selectedGeohash.isEmpty ? 0.6 : 1.0) } - .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) + .padding(.horizontal, 20) + .padding(.bottom, 40) // Move buttons more up in the screen } - .background(backgroundColor) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(true) - #else - .navigationTitle("") - #endif } - #if os(iOS) - .presentationDetents([.large]) - #endif + .background(backgroundColor) #if os(macOS) - .frame(minWidth: 800, idealWidth: 1200, maxWidth: .infinity, minHeight: 600, idealHeight: 800, maxHeight: .infinity) + .frame(minWidth: 800, minHeight: 600) #endif - .background(backgroundColor) + .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..ab8610e83 --- /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 = L10n.string( + "geohash_picker.instruction", + comment: "Instruction text for geohash map picker" + ) + + static let selectButton = L10n.string( + "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 5f821028f..fdabda928 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -7,12 +7,12 @@ 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 @@ -138,7 +138,7 @@ struct LocationChannelsSheet: View { .presentationDetents([.large]) #endif #if os(macOS) - .frame(minWidth: 420, minHeight: 520) + .frame(minWidth: 420, minHeight: 680) #endif .background(backgroundColor) .onAppear { @@ -294,6 +294,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() @@ -630,3 +666,4 @@ private func openSystemLocationSettings() { } #endif } +