Compare commits

..
8 changed files with 83 additions and 154 deletions
+4 -5
View File
@@ -1,6 +1,6 @@
import Foundation import Foundation
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars). /// Lightweight background counter for location notes (kind 1) at block-level geohash.
@MainActor @MainActor
final class LocationNotesCounter: ObservableObject { final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter() static let shared = LocationNotesCounter()
@@ -16,11 +16,10 @@ final class LocationNotesCounter: ObservableObject {
func subscribe(geohash gh: String) { func subscribe(geohash gh: String) {
let norm = gh.lowercased() let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return } if geohash == norm { return }
// Unsubscribe previous without clearing count to avoid flicker cancel()
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil
geohash = norm geohash = norm
count = 0
noteIDs.removeAll() noteIDs.removeAll()
initialLoadComplete = false initialLoadComplete = false
+1 -1
View File
@@ -62,7 +62,7 @@ final class NotificationService {
} }
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) { func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 DM from \(sender)" let title = "🔒 private message from \(sender)"
let body = message let body = message
let identifier = "private-\(UUID().uuidString)" let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender] let userInfo = ["peerID": peerID, "senderName": sender]
+1 -5
View File
@@ -40,11 +40,7 @@ static void *tor_thread_main(void *arg) {
int rc = tor_run_main(cfg); // blocks until tor exits int rc = tor_run_main(cfg); // blocks until tor exits
tor_main_configuration_free(cfg); tor_main_configuration_free(cfg);
if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; } if (argv_owned) { free(argv_owned); argv_owned = NULL; argv_owned_argc = 0; }
// Do not close owning_fd_tor here: Tor may have already closed it and the if (owning_fd_tor != -1) { close(owning_fd_tor); owning_fd_tor = -1; }
// fd number could have been re-used by the time we get here. On iOS 18,
// attempting to close a re-used guarded fd can trigger EXC_GUARD. Treat the
// tor-side end as owned by Tor and simply forget our reference.
owning_fd_tor = -1;
return (void*)(intptr_t)rc; return (void*)(intptr_t)rc;
} }
+3 -7
View File
@@ -86,14 +86,10 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) { Button("close") {
Image(systemName: "xmark") dismiss()
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
} }
.buttonStyle(.plain) .foregroundColor(textColor)
.accessibilityLabel("Close")
} }
} }
} }
+25 -37
View File
@@ -1140,31 +1140,7 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open unread private chat") .accessibilityLabel("Open unread private chat")
} }
// Notes icon (mesh only and when location is authorized), to the left of #mesh // Bookmark toggle for current geohash (not shown for 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 { if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) { Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
@@ -1173,7 +1149,6 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)") .accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
} }
// Location channels button '#' // Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) { Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = { let badgeText: String = {
@@ -1199,8 +1174,29 @@ struct ContentView: View {
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 4)
.padding(.trailing, 2) // 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")
}
HStack(spacing: 4) { HStack(spacing: 4) {
// People icon with count // People icon with count
@@ -1212,7 +1208,6 @@ struct ContentView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(headerCountColor) .foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(1) .lineLimit(1)
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
@@ -1282,6 +1277,7 @@ struct ContentView: View {
} }
.onDisappear { .onDisappear {
LocationChannelManager.shared.endLiveRefresh() LocationChannelManager.shared.endLiveRefresh()
sheetNotesCount = 0
} }
.onChange(of: locationManager.availableChannels) { channels in .onChange(of: locationManager.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash, if let current = channels.first(where: { $0.level == .building })?.geohash,
@@ -1528,16 +1524,8 @@ extension ContentView {
if locationManager.permissionState == .authorized { if locationManager.permissionState == .authorized {
LocationChannelManager.shared.refreshChannels() LocationChannelManager.shared.refreshChannels()
} }
if locationManager.permissionState == .authorized {
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesCounter.shared.subscribe(geohash: building) 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 { } else {
LocationNotesCounter.shared.cancel() LocationNotesCounter.shared.cancel()
} }
+8 -30
View File
@@ -14,14 +14,12 @@ struct LocationChannelsSheet: View {
@State private var customGeohash: String = "" @State private var customGeohash: String = ""
@State private var customError: String? = nil @State private var customError: String? = nil
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("#location channels") Text("#location channels")
.font(.system(size: 18, design: .monospaced)) .font(.system(size: 18, design: .monospaced))
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.") Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -56,30 +54,19 @@ struct LocationChannelsSheet: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(backgroundColor)
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isPresented = false }) { Button("close") { isPresented = false }
Image(systemName: "xmark") .font(.system(size: 14, design: .monospaced))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
} }
#else #else
.toolbar { .toolbar {
ToolbarItem(placement: .automatic) { ToolbarItem(placement: .automatic) {
Button(action: { isPresented = false }) { Button("close") { isPresented = false }
Image(systemName: "xmark") .font(.system(size: 14, design: .monospaced))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
} }
#endif #endif
@@ -90,7 +77,6 @@ struct LocationChannelsSheet: View {
#if os(macOS) #if os(macOS)
.frame(minWidth: 420, minHeight: 520) .frame(minWidth: 420, minHeight: 520)
#endif #endif
.background(backgroundColor)
.onAppear { .onAppear {
// Refresh channels when opening // Refresh channels when opening
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
@@ -219,11 +205,12 @@ struct LocationChannelsSheet: View {
// Bookmarked geohashes // Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty { if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 6) {
Text("bookmarked") Text("bookmarked")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
VStack(spacing: 6) { }
.listRowSeparator(.hidden)
ForEach(bookmarks.bookmarks, id: \.self) { gh in ForEach(bookmarks.bookmarks, id: \.self) { gh in
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh) let channel = GeohashChannel(level: level, geohash: gh)
@@ -257,12 +244,6 @@ struct LocationChannelsSheet: View {
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) } .onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
} }
} }
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
}
.listRowSeparator(.hidden)
}
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
@@ -279,12 +260,9 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} }
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden)
.background(backgroundColor)
} }
private func isSelected(_ channel: GeohashChannel) -> Bool { private func isSelected(_ channel: GeohashChannel) -> Bool {
+3 -3
View File
@@ -13,7 +13,7 @@ struct LocationNotesSheet: View {
var body: some View { var body: some View {
Group { Group {
if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .building })?.geohash { if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .block })?.geohash {
// Found block geohash: show notes view // Found block geohash: show notes view
LocationNotesView(geohash: gh) LocationNotesView(geohash: gh)
.environmentObject(viewModel) .environmentObject(viewModel)
@@ -55,8 +55,8 @@ struct LocationNotesSheet: View {
LocationChannelManager.shared.refreshChannels() LocationChannelManager.shared.refreshChannels()
} }
.onChange(of: locationManager.availableChannels) { channels in .onChange(of: locationManager.availableChannels) { channels in
if notesGeohash == nil, let building = channels.first(where: { $0.level == .building }) { if notesGeohash == nil, let block = channels.first(where: { $0.level == .block }) {
notesGeohash = building.geohash notesGeohash = block.geohash
} }
} }
} }
+4 -32
View File
@@ -27,10 +27,6 @@ struct LocationNotesView: View {
private var secondaryTextColor: Color { private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -59,11 +55,8 @@ struct LocationNotesView: View {
let c = manager.notes.count let c = manager.notes.count
Text("\(c) \(c == 1 ? "note" : "notes") ") Text("\(c) \(c == 1 ? "note" : "notes") ")
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
Text("@ ") Text("@ #\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced)) .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 { if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
Text(buildingName) Text(buildingName)
@@ -97,18 +90,10 @@ struct LocationNotesView: View {
ForEach(manager.notes) { note in ForEach(manager.notes) { note in
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { HStack(spacing: 6) {
// Show @name without the #abcd suffix; timestamp in brackets Text(note.displayName)
HStack(spacing: 0) {
Text("@")
.font(.system(size: 12, weight: .semibold, design: .monospaced)) .font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(secondaryTextColor)
let parts = splitSuffix(from: note.displayName) Text(timestampText(for: note.createdAt))
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)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8)) .foregroundColor(secondaryTextColor.opacity(0.8))
} }
@@ -189,16 +174,3 @@ struct LocationNotesView: View {
return f 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, "")
}