diff --git a/bitchat/Services/LocationNotesCounter.swift b/bitchat/Services/LocationNotesCounter.swift index 6e51a0ff..2e377c4a 100644 --- a/bitchat/Services/LocationNotesCounter.swift +++ b/bitchat/Services/LocationNotesCounter.swift @@ -1,6 +1,6 @@ import Foundation -/// Lightweight background counter for location notes (kind 1) at block-level geohash. +/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars). @MainActor final class LocationNotesCounter: ObservableObject { static let shared = LocationNotesCounter() @@ -16,10 +16,11 @@ final class LocationNotesCounter: ObservableObject { func subscribe(geohash gh: String) { let norm = gh.lowercased() - if geohash == norm { return } - cancel() + if geohash == norm, subscriptionID != nil { return } + // Unsubscribe previous without clearing count to avoid flicker + if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) } + subscriptionID = nil geohash = norm - count = 0 noteIDs.removeAll() initialLoadComplete = false diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 5f33628b..595c85db 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -62,7 +62,7 @@ final class NotificationService { } func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) { - let title = "🔒 private message from \(sender)" + let title = "🔒 DM from \(sender)" let body = message let identifier = "private-\(UUID().uuidString)" let userInfo = ["peerID": peerID, "senderName": sender] diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index fe03c57f..c8a4e779 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -86,10 +86,14 @@ struct AppInfoView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button("close") { - dismiss() + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .frame(width: 32, height: 32) } - .foregroundColor(textColor) + .buttonStyle(.plain) + .accessibilityLabel("Close") } } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index b2572b64..4b47d810 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -1140,7 +1140,31 @@ struct ContentView: View { .buttonStyle(.plain) .accessibilityLabel("Open unread private chat") } - // Bookmark toggle for current geohash (not shown for mesh) + // Notes icon (mesh only and when location is authorized), to the left of #mesh + if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized { + Button(action: { + // Kick a one-shot refresh and show the sheet immediately. + LocationChannelManager.shared.enableLocationChannels() + LocationChannelManager.shared.refreshChannels() + // If we already have a block geohash, pass it; otherwise wait in the sheet. + notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash + showLocationNotes = true + }) { + HStack(alignment: .center, spacing: 4) { + let currentCount = (notesCounter.count ?? 0) + let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0 + Image(systemName: "long.text.page.and.pencil") + .font(.system(size: 12)) + .foregroundColor(hasNotes ? textColor : Color.gray) + .padding(.top, 1) + } + .fixedSize(horizontal: true, vertical: false) + } + .buttonStyle(.plain) + .accessibilityLabel("Location notes for this place") + } + + // Bookmark toggle (geochats): to the left of #geohash if case .location(let ch) = locationManager.selectedChannel { Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) { Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") @@ -1149,6 +1173,7 @@ struct ContentView: View { .buttonStyle(.plain) .accessibilityLabel("Toggle bookmark for #\(ch.geohash)") } + // Location channels button '#' Button(action: { showLocationChannelsSheet = true }) { let badgeText: String = { @@ -1174,29 +1199,8 @@ struct ContentView: View { .accessibilityLabel("location channels") } .buttonStyle(.plain) - - // Notes icon (mesh only and when location is authorized), to the right of #mesh - if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized { - Button(action: { - // Kick a one-shot refresh and show the sheet immediately. - LocationChannelManager.shared.enableLocationChannels() - LocationChannelManager.shared.refreshChannels() - // If we already have a block geohash, pass it; otherwise wait in the sheet. - notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash - showLocationNotes = true - }) { - HStack(alignment: .center, spacing: 4) { - let hasNotes = ((notesCounter.count ?? 0) > 0) || (sheetNotesCount > 0) - Image(systemName: "long.text.page.and.pencil") - .font(.system(size: 12)) - .foregroundColor(hasNotes ? Color(hue: 0.60, saturation: 0.85, brightness: 0.82) : Color.gray) - .padding(.top, 1) - } - .fixedSize(horizontal: true, vertical: false) - } - .buttonStyle(.plain) - .accessibilityLabel("Location notes for this place") - } + .padding(.leading, 4) + .padding(.trailing, 2) HStack(spacing: 4) { // People icon with count @@ -1208,6 +1212,7 @@ struct ContentView: View { .accessibilityHidden(true) } .foregroundColor(headerCountColor) + .padding(.leading, 2) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) @@ -1277,11 +1282,10 @@ struct ContentView: View { } .onDisappear { LocationChannelManager.shared.endLiveRefresh() - sheetNotesCount = 0 } .onChange(of: locationManager.availableChannels) { channels in if let current = channels.first(where: { $0.level == .building })?.geohash, - notesGeohash != current { + notesGeohash != current { notesGeohash = current #if os(iOS) // Light taptic when geohash changes while the sheet is open @@ -1524,8 +1528,16 @@ extension ContentView { if locationManager.permissionState == .authorized { LocationChannelManager.shared.refreshChannels() } - if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { - LocationNotesCounter.shared.subscribe(geohash: building) + if locationManager.permissionState == .authorized { + if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { + LocationNotesCounter.shared.subscribe(geohash: building) + } else { + // Keep existing subscription if we had one to avoid flicker + // Only cancel if we have no known geohash + if LocationNotesCounter.shared.geohash == nil { + LocationNotesCounter.shared.cancel() + } + } } else { LocationNotesCounter.shared.cancel() } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 162bff2e..19f20083 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -14,12 +14,14 @@ struct LocationChannelsSheet: View { @State private var customGeohash: String = "" @State private var customError: String? = nil + private var backgroundColor: Color { colorScheme == .dark ? .black : .white } + var body: some View { NavigationView { VStack(alignment: .leading, spacing: 12) { Text("#location channels") .font(.system(size: 18, design: .monospaced)) - Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.") + Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.") .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) @@ -54,19 +56,30 @@ struct LocationChannelsSheet: View { } .padding(.horizontal, 16) .padding(.vertical, 12) + .background(backgroundColor) #if os(iOS) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button("close") { isPresented = false } - .font(.system(size: 14, design: .monospaced)) + Button(action: { isPresented = false }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") } } #else .toolbar { ToolbarItem(placement: .automatic) { - Button("close") { isPresented = false } - .font(.system(size: 14, design: .monospaced)) + Button(action: { isPresented = false }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") } } #endif @@ -77,6 +90,7 @@ struct LocationChannelsSheet: View { #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif + .background(backgroundColor) .onAppear { // Refresh channels when opening if manager.permissionState == LocationChannelManager.PermissionState.authorized { @@ -205,44 +219,49 @@ struct LocationChannelsSheet: View { // Bookmarked geohashes if !bookmarks.bookmarks.isEmpty { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text("bookmarked") .font(.system(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(.system(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 + } + .onAppear { bookmarks.resolveNameIfNeeded(for: gh) } + } + } + .padding(12) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(8) } .listRowSeparator(.hidden) - 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(.system(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 - } - .onAppear { bookmarks.resolveNameIfNeeded(for: gh) } - } } // Footer action inside the list @@ -260,9 +279,12 @@ struct LocationChannelsSheet: View { } .buttonStyle(.plain) .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } } .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(backgroundColor) } private func isSelected(_ channel: GeohashChannel) -> Bool { diff --git a/bitchat/Views/LocationNotesSheet.swift b/bitchat/Views/LocationNotesSheet.swift index 8def19b2..a963cf10 100644 --- a/bitchat/Views/LocationNotesSheet.swift +++ b/bitchat/Views/LocationNotesSheet.swift @@ -13,7 +13,7 @@ struct LocationNotesSheet: View { var body: some View { Group { - if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .block })?.geohash { + if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .building })?.geohash { // Found block geohash: show notes view LocationNotesView(geohash: gh) .environmentObject(viewModel) @@ -55,8 +55,8 @@ struct LocationNotesSheet: View { LocationChannelManager.shared.refreshChannels() } .onChange(of: locationManager.availableChannels) { channels in - if notesGeohash == nil, let block = channels.first(where: { $0.level == .block }) { - notesGeohash = block.geohash + if notesGeohash == nil, let building = channels.first(where: { $0.level == .building }) { + notesGeohash = building.geohash } } } diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index 8f05ca90..cb4f9cc7 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -27,6 +27,10 @@ struct LocationNotesView: View { private var secondaryTextColor: Color { colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } + // Slightly darker green for hash suffix emphasis + private var darkerTextColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0) + } var body: some View { VStack(spacing: 0) { @@ -55,8 +59,11 @@ struct LocationNotesView: View { let c = manager.notes.count Text("\(c) \(c == 1 ? "note" : "notes") ") .font(.system(size: 16, weight: .bold, design: .monospaced)) - Text("@ #\(geohash)") + Text("@ ") .font(.system(size: 16, weight: .bold, design: .monospaced)) + Text("#\(geohash)") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundColor(textColor) } if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty { Text(buildingName) @@ -90,10 +97,18 @@ struct LocationNotesView: View { ForEach(manager.notes) { note in VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { - Text(note.displayName) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundColor(secondaryTextColor) - Text(timestampText(for: note.createdAt)) + // Show @name without the #abcd suffix; timestamp in brackets + HStack(spacing: 0) { + Text("@") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + let parts = splitSuffix(from: note.displayName) + Text(parts.0) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + } + let ts = timestampText(for: note.createdAt) + Text(ts.isEmpty ? "" : "[\(ts)]") .font(.system(size: 11, design: .monospaced)) .foregroundColor(secondaryTextColor.opacity(0.8)) } @@ -174,3 +189,16 @@ struct LocationNotesView: View { return f }() } + +// Helper to split a trailing #abcd suffix +private func splitSuffix(from name: String) -> (String, String) { + guard name.count >= 5 else { return (name, "") } + let suffix = String(name.suffix(5)) + if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in + ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) + }) { + let base = String(name.dropLast(5)) + return (base, suffix) + } + return (name, "") +}